From fcb1944b4f76cc74d5092c2b8605d972c095bd3c Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Sat, 6 Jun 2026 13:18:18 +0530 Subject: [PATCH] =?UTF-8?q?feat(credits):=20usage-aware=20credits=20?= =?UTF-8?q?=E2=80=94=20in-session=20notices,=20/usage=20view,=20dev=20read?= =?UTF-8?q?out=20(#40011)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tui): HERMES_DEV_CREDITS live-spend dev readout (L0 tracer for usage-aware credits) L0 of the usage-aware-credits feature: a dev-only, env-gated tracer that exercises the real header -> CreditsState -> TUI pipe end-to-end behind HERMES_DEV_CREDITS, de-risking the L1/L5 build before the notice policy exists. - agent/credits_tracker.py: CreditsState + parse_credits_headers (headers are strings -> paid_access via == "true", never bool(); retain-last-known; only subscription_micros may be negative; *_usd kept verbatim). - run_agent.py: _capture_credits / get_credits_state / get_credits_spent_micros, session-start baseline latch, + dev-gated "credits" capture log. - agent/chat_completion_helpers.py: capture on the streaming response. - agent/agent_init.py: init _credits_state + _credits_session_start_micros. - tui_gateway/server.py: _get_usage emits dev_credits_spent_micros only when flagged. - ui-tui appChrome.tsx / types.ts: cents delta status segment + "(dev credits)" banner. Off by default; silent for normal users. Validated live against staging (capture log delta matches the TUI segment). Throwaway consumer (readout/log/ banner); credits_tracker + the capture plumbing are the real feature foundation. * test(credits): lock parser under 9-state matrix + harden validation (L2) Add tests/agent/test_credits_tracker.py with 92 tests covering the 9-state matrix (healthy, sub_90pct, grant_exhausted, purchased_only, tool_pool_free, depleted, debt, missing, no_org) plus validation edge cases: version strict==1 with warn-once latch for v>1, bool-string trap (paid_access/tool_pool_gated_off == "true"/"false", never bool()), half-pair subscription limit treated as both-absent while parse succeeds, USD regex ^-?\d+\.\d{2}$, non-int micros → None, negative non-subscription micros → None, as_of_ms junk → None, zero limit ZeroDivision guard. Harden agent/credits_tracker.py to match the spec: - Add tool_pool_micros/tool_pool_gated_off/from_header fields to CreditsState - Add depleted property (== not paid_access, never remaining==0) - Change used_fraction guard to key off subscription_limit_micros (the actual denominator) not denominator_kind (metadata) - Replace fail-soft _safe_int with a sentinel-returning variant; full validation now returns None on any malformed field rather than silently defaulting - Add module-level warn-once latch for version > 1 - Add USD regex validation; add denominator_kind allow-list check - Parse x-nous-tool-pool-* prefix headers (not x-nous-credits-tool-pool-*) * feat(credits): notice spine — AgentNotice + notice_callback/notice_clear_callback + TUI binding (L1) L1 of usage-aware credits: the driver-agnostic notice delivery spine that L4's policy will fire through and L5's TUI render will consume. - agent/credits_tracker.py: AgentNotice dataclass (text/level/kind/ttl_ms/key/id; kind defaults "sticky", kept TTL-expressive for a future config seam). - run_agent.py: AIAgent gains notice_callback + notice_clear_callback slots and _emit_notice / _emit_notice_clear emitters (swallow all callback errors — a notice must never break the agent loop; no-op when unbound). - agent/agent_init.py: thread both callbacks through init_agent. - tui_gateway/server.py: bind both in _agent_cbs → notification.show / notification.clear WS events (snake_case payload, matching the existing gateway-event convention). - ui-tui/src/gatewayTypes.ts: notification.show / notification.clear arms on GatewayEvent. - tests/run_agent/test_notice_spine.py: 15 tests (emitter fire + fail-open + no-op, signature threading, TUI binding payload shape). Messaging push is out of v1 (binds neither callback). CLI binding + the TUI render/ decode land with L4 (firing) and L5 (render) so turn-end flush is wired correctly. * feat(credits): threshold reconciliation policy + tests (L4.1) * feat(credits): wire threshold policy into capture + latch (L4.2) After a fresh header parse, _capture_credits runs evaluate_credits_notices against the agent's _credits_latch and emits the result — clears first, then shows (so a recovered depletion clears before the "restored" success lands, and depleted wins the latest-wins slot). Gated on a bound notice_callback: messaging (no callbacks) still caches state for /usage but runs no policy. Parse stays fail-open (miss → keep last-known); the eval/emit path warns on failure rather than swallowing, so a depletion-notice bug can't vanish silently. - run_agent.py: _capture_credits split into parse (swallow→miss) + policy (warn); latch lazy-guarded (object.__new__ safety). - agent/agent_init.py: init agent._credits_latch = {"active": set(), "seen_below_90": False}. * feat(tui): render credits notices in the status bar (L5, Strategy B) The TUI now renders the notification.show / notification.clear gateway events the agent emits — a level-colored notice overrides the status/verb slot when not busy. - Notice state machine on turnController (pendingNotice + dedicated noticeTimer + show/clear/applyNotice/flushPendingNotice/clearNoticeState). createGatewayEventHandler decodes the events and delegates. - Render priority busy > notice > status (appChrome StatusRule); notice text rendered verbatim (its glyph comes from the policy), shrinkable so it never clips model│ctx; dev-credits banner + Δ segment preserved. UiState.notice is snake_case (matches wire). - Busy-wins: a notice arriving mid-turn is held and flushed at the THREE turn-end sites (recordMessageComplete / interruptTurn / recordError) — never idle(), which reset() also calls (would leak across sessions); reset() clears instead. - Dedicated noticeTimer (never statusTimer); TTL starts on visibility with an id-guard; latest-wins cancels the prior timer; clear is key-matched (no-op on mismatch); a sticky survives a turn (flush no-ops with no pending); session reset clears (no cross-session leak). - 20 tests (handler/turnController logic incl. R3-C2 timer isolation + render priority). * feat(credits): cold-start seed for new Nous sessions (L3) A genuinely-new Nous session has no inference header yet, so seed credits state from the authoritative GET /api/oauth/account snapshot at session start (in the new-session branch of _restore_or_build_system_prompt — inline, since the on_session_start plugin hook gets no agent reference). The seed runs the shared notice policy, so a session that opens already depleted warns IMMEDIATELY rather than only after the first turn. - Maps the nested account fields (paid_service_access → paid_access; total_usable / subscription / purchased on paid_service_access_info; rollover on subscription), each None-guarded; float dollars → micros via round(d*1e6), *_usd left "" (render formats from micros — never synthesize a verbatim usd from a float). - Magnitudes-only: no monthlyCredits on the endpoint → subscription_limit_* unset → used_fraction None → no warn90 from the seed (% only once a header lands, per D-E). - Provider-guarded to Nous; fail-open (any error leaves _credits_state None, never blocks startup); paid_access unknown ⇒ True (never falsely depleted). - run_agent.py: extracted the warm-path policy/emit block into a shared _emit_credits_notices() so capture and the seed fire notices identically. * feat(credits): /usage Nous credits magnitudes view + recovery trigger (L6) Add Nous credit dollar magnitudes to /usage (subscription / top-up / total + rollover + renewal + portal CTA), magnitudes-only per v1 (no % until the account endpoint exposes a denominator). Reuses the existing account-usage render machinery via a new pure build_nous_credits_snapshot() that maps a NousPortalAccountInfo to an AccountUsageSnapshot; no nous branch is added to fetch_account_usage (keeps the per-provider boundary intact). CLI /usage also doubles as a depletion-recovery trigger: a force_fresh account fetch, kept in a SEPARATE local so it never clobbers the header-sourced agent._credits_state (which alone carries used_fraction). If paid access recovered while credits.depleted is latched and a notice consumer is bound, it reuses agent._emit_credits_notices() to clear it. Gateway /usage displays magnitudes only — messaging binds no notice consumer, so it performs no recovery emit. Fail-open throughout: any portal hiccup leaves /usage unaffected. * refactor(credits): dedupe HERMES_DEV_CREDITS flag parse via shared helpers The dev-flag truthy check was inlined in three places. Replace with the shared utils.is_truthy_value (run_agent.py, tui_gateway/server.py — also drops a redundant inline `import os`) and a hoisted DEV_CREDITS_MODE export in ui-tui/src/config/env.ts (consumed by appChrome, which also stops recomputing the env check on every render). Behaviour-preserving; identical truthy set. * fix(credits): cut dead /usage recovery trigger + bound portal fetches (L6 review) Adversarial review found the /usage depletion-recovery trigger dead AND broken: the CLI binds no notice_clear_callback, the TUI runs /usage in a separate slash-worker subprocess (its own agent/latch), and the no-clobber rule made it evaluate stale paid_access anyway. Recovery already happens on the next inference (warm path), so the trigger was redundant — remove it and stop the depleted notice over-promising. - cli.py: remove the dead recovery block; bound the /usage portal fetch with a 10s wall-clock timeout (ThreadPoolExecutor) like the per-provider fetch — urllib's per-socket timeout is not a wall-clock guarantee. - agent/credits_tracker.py: reword the depleted CTA to "run /usage for balance" (no false recovery promise; /usage shows fresh magnitudes, sticky clears next turn). - agent/conversation_loop.py: same wall-clock timeout on the cold-start seed fetch so a stalled portal can't hang session startup; tidy its time import. * chore(credits): dev notice-state fixtures (HERMES_DEV_CREDITS_FIXTURE) Throwaway dev scaffolding to exercise the notice pipeline without real spend or Redis seeding. Set HERMES_DEV_CREDITS_FIXTURE to a state name (healthy / sub_90pct / grant_exhausted / depleted / clear) or a file path whose contents name a state (re-read each turn → flip states live for recovery testing). _capture_credits injects the chosen CreditsState instead of parsing real headers and runs the shared notice policy. Deletable with the rest of the HERMES_DEV_CREDITS scaffolding. * feat(credits): /usage monthly-grant % gauge The portal /api/oauth/account subscription block now carries monthly_credits (the per-period grant allowance, the % denominator). The consumer parsed monthly_charge but dropped monthly_credits, so /usage stayed magnitudes-only. Capture monthly_credits into NousPortalSubscriptionInfo + _subscription_from_payload. build_nous_credits_snapshot emits a Subscription usage window (real % used, routed through the existing render machinery) when monthly_credits is a finite positive denominator and credits_remaining is finite and <= cap; otherwise it degrades to magnitudes-only (older portals, rollover-over-cap, or non-finite payloads). Guards (adversarial-review-driven): reject non-finite operands (json.loads parses bare NaN/Infinity by default → would render $nan + a false 100% used), reject bools, guard div-by-zero (cap>0), and suppress the gauge when remaining > cap (rollover spanning the period makes the cap a nonsensical denominator → the $X-of-$Y detail would read as a contradiction). Debt (remaining<0) clamps to 100%. Money rule preserved: the ratio + magnitudes are computed from numeric float account fields via display formatting, never by parsing a server *_usd string (there are none on these dataclasses). 13 gauge tests added (tests/agent/test_nous_credits_gauge.py). * fix(credits): show /usage Nous block whenever a Nous account is present /usage runs in a slash-worker subprocess whose resolved inference provider is often not "nous" even when the user has a Nous account, so gating the Nous credits block on (provider == "nous") hid it entirely — the account data was fully available but never rendered. Gate instead on "a Nous account is logged in": a cheap local auth-state lookup (get_provider_auth_state('nous') has an access_token) decides whether to attempt the portal fetch, regardless of which provider inference runs on. In the gateway the block is also lifted out of the 'if provider:' scope so a Nous-credentialled user with another (or no) resident inference provider still sees their balance. Fail-open and the per-fetch wall-clock timeout are preserved. * fix(credits): show /usage Nous block when there's no live agent (TUI slash-worker) In the TUI, /usage runs in a slash-worker subprocess that resumes the session WITHOUT building an agent (self.agent is None), so _show_usage early-returned "(._.) No active agent" before ever reaching the Nous credits block — which is agent-independent (a portal fetch gated on Nous auth-state). Extract the block into _print_nous_credits_block() and run it at the no-agent / no-calls early-returns too (returns True if it printed, so the fallback message only shows when there's genuinely nothing). Verified live against staging: the block + monthly-grant gauge now render in the slash-worker /usage path (previously hidden). The plain CLI REPL + messaging paths are unchanged (they have a live agent). * feat(credits): escalating 50/75/90 usage bands (single status line) Replace the lone 90%-used warning with three escalating bands (50 info, 75 warn, 90 warn) shown as ONE status-bar line: it displays the highest band the subscription grant has crossed, replaces the line as usage climbs, steps back down on recovery, and clears below 50%. No stacking, no per-turn churn. Bands live in a tunable CREDITS_USAGE_BANDS list; the policy derives everything from it. Single notice key (credits.usage) with a usage_band latch field so the notice only re-emits when the band actually changes. The crossing gate (seen_below_90) is preserved so a fresh live session that opens mid-range stays quiet until it has been observed below the lowest band (cold-start primes it when it wants an open-high warning). Denominator math unchanged: % = subscription grant burn (cap - grant_remaining)/cap, clamped [0,1]; top-up never moves the %. Migrated test_credits_policy.py to the new key + added TestUsageBands (climb, step-down, recovery-clear, idempotent, inclusive boundaries). * feat(credits): hydrate notices at session OPEN via shared seed (TUI + first-turn) Notices previously only fired inside a conversation turn (first message), so a session that opened already depleted / past a usage band showed nothing at 'ready'. Extract the cold-start seed into a shared seed_credits_at_session_start() and call it (a) in the TUI/desktop agent build right after the notice callback is wired (fires at 'ready', before any message) and (b) as the first-turn fallback in conversation_loop. Idempotent (skips once _credits_state exists) and fail-open. The seed now maps monthly_credits -> subscription_limit_micros + denominator_kind='subscription_cap', so used_fraction is computable at seed time and usage-band warnings (not just depletion) hydrate on open. Primes the crossing latch so a session opening already in a band warns immediately. Degrades to depletion-only when monthly_credits is absent (older portals). Adds test_credits_cold_start.py covering open-at-band, depletion, debt, no-cap degradation, and the shared seed (fires/idempotent/skips-non-nous). * feat(credits): /usage monthly-grant % gauge + fixture support + TUI surfacing agent/account_usage.py: build_nous_credits_snapshot emits a subscription %% gauge when the portal supplies a positive, finite monthly_credits denominator with remaining <= cap (guards reject NaN/Infinity and rollover-over-cap, which would render $nan or a contradictory $X-of-$Y); degrades to magnitudes-only otherwise. Adds shared nous_credits_lines() (auth-gated, wall-clock-bounded portal fetch) so the CLI and TUI /usage render the same block, and _snapshot_from_credits_state() so HERMES_DEV_CREDITS_FIXTURE drives /usage offline too. TUI: session.usage RPC carries credits_lines (agent-independent) and the /usage panel renders them regardless of API-call count or resume state — previously the TUI's separate /usage implementation only showed token counts. Money rule preserved: %% and magnitudes come from numeric float account fields via display formatting, never by parsing a server *_usd string. * feat(credits): CLI REPL inline notices (parity with TUI) The plain CLI agent bound no notice callbacks, so credit notices were TUI-only. Bind notice_callback/notice_clear_callback on the CLI AIAgent; _on_notice renders a single level-colored line above the prompt (error red / warn yellow / success green / info dim) via _cprint, and seed credits at session open so a depletion or usage-band warning shows before the first message — the same hydration the TUI got. _on_notice_clear is a no-op (the REPL prints lines, no persistent slot). * test(credits): add sub_50pct + sub_75pct dev fixtures for the new usage bands The fixture set jumped 10%% -> 90%%; add sub_50pct (uf 0.5 -> band 50 info) and sub_75pct (uf 0.75 -> band 75 warn) so the new escalating bands are exercisable via HERMES_DEV_CREDITS_FIXTURE across all three surfaces (notice, session-open seed, /usage gauge). * fix(credits): usage-band notice clears on next prompt (not sticky-forever) A 50/75/90 usage heads-up was sticky and camped the status bar indefinitely. Clear the visible credits.usage notice when a new turn starts (startMessage), so it shows until your next prompt then yields. The server latch is unchanged, so it won't re-nag at the same band — it only re-shows when the band actually changes (climb) or clears when usage drops below the lowest band. Depletion stays sticky. * refactor(credits): consolidate the /usage credits block behind nous_credits_lines() The CLI (_print_nous_credits_block) and the messaging gateway (_handle_usage_command) each re-implemented the auth-gate + portal fetch + render, and both bypassed the dev-fixture short-circuit that only the TUI honored — so /usage ignored HERMES_DEV_CREDITS_FIXTURE on the CLI and in chat. Route both through the shared agent.account_usage.nous_credits_lines() helper: one fetch/render path, one auth gate, and the fixture works on every surface (~60 fewer duplicated lines). The gateway usage test recorded only the last asyncio.to_thread call; /usage now dispatches both the account fetch and the credits fetch, so it records every call and matches the account fetch by its provider arg. * fix(credits): keep the /usage gauge type-safe and log its fail-open path _is_finite_num is now a TypeGuard[float], so the type checker narrows the gauge operands (monthly_credits / credits_remaining) and the magnitudes passed to _fmt_usd through it — no more None-operand warnings on the arithmetic. Add a debug breadcrumb on the nous_credits_lines portal-fetch fail-open so a dead /usage block is diagnosable in agent.log without a dev flag. * fix(credits): harden the header tracker — prod-leak gate, hot-path probe, fire-and-forget seed - Prod-leak guard: dev fixtures (HERMES_DEV_CREDITS_FIXTURE) now also require HERMES_DEV_CREDITS, so a stray fixture var can't surface fabricated balances on a real account. Matches the documented run workflow (both vars set together). - Hot-path probe: parse_credits_headers checks for the version sentinel header before allocating a lowercased copy of the response headers — skips that work on every non-Nous API call. Behaviour-identical and still case-insensitive. - Fire-and-forget seed: the real portal fetch in seed_credits_at_session_start now runs in a daemon thread, so a slow/unreachable portal never delays session "ready" (previously blocked up to 10s). The dev-fixture path stays synchronous; the thread re-checks idempotency before hydrating (a live header may land first). - Diagnostics: debug breadcrumbs on the parse and seed fail-open paths so a crashed parser / dead seed is distinguishable from a legitimate no-headers miss. Cold-start tests set HERMES_DEV_CREDITS alongside the fixture to match the gate. * test(tui): fix env-timing in the StatusRule dev-credits assertion DEV_CREDITS_MODE is read once at module load (config/env), so mutating process.env.HERMES_DEV_CREDITS inside the test couldn't flip it — the dev-banner assertion only passed if the env was exported before vitest started, and failed in a normal run. Move that assertion to a sibling file that mocks config/env with DEV_CREDITS_MODE: true (scoped, no module-reset / React-identity hazard). * test(credits): cover the dev-fixture /usage render and usage-band clear-on-prompt - _snapshot_from_credits_state (the offline /usage renderer) had no direct test: lock the gauge math, the verbatim *_usd magnitudes, the depletion line and the fixture marker, plus the no-cap (no gauge) and None-state cases. - turnController.startMessage had no test for clearing the credits.usage notice on the next prompt while leaving credits.depleted sticky. * feat(credits): deliver credit notices over messaging gateways Bind notice_callback/notice_clear_callback on the per-turn gateway agent so usage-band / depletion / restored notices reach Telegram/Discord/Slack/ etc. Previously the messaging gateway bound neither callback, so the agent's _emit_credits_notices early-returned and a chat user crossing a band got nothing unless they ran /usage manually. - render_notice_line(): AgentNotice -> single plaintext line (level glyph + text), plaintext-only so it renders uniformly without per-platform escaping. Fail-soft on malformed/empty notices. - Standalone push for every notice (messaging has no persistent status bar): route through the shared _deliver_platform_notice rail (honors private/ public delivery + thread metadata), scheduled onto the gateway loop via safe_schedule_threadsafe from the agent's sync worker thread — same pattern as _status_callback_sync. - The fired-once latch lives on the cached (reused-in-place) agent and persists across turns, so a band crosses once -> one push, no per-turn re-nag. Re-fires only after idle-eviction rebuilds the agent (a reminder). - Recovery ('Credit access restored') rides the show path (emitted as a success notice, not a clear). notice_clear_callback is a no-op: a sent platform message can't be cleanly retracted. Tests: render glyph/levels/fail-soft + public/private delivery seam through _deliver_platform_notice + no-adapter no-op. * fix(credits): don't double the glyph on messaging notices render_notice_line prepended a per-level glyph, but the notice policy already bakes the glyph into the text (and the TUI + CLI render it verbatim) — so every credit notice over messaging came out doubled ("⚠ ⚠ Credits 90% used", "⛔ ✕ Credit access paused"). Emit the text verbatim instead; drop the now-dead level→glyph map. The render tests fed glyph-less text (and the success case only checked startswith), so the doubling slipped through. Rework them around the verbatim contract and add an end-to-end regression that runs real evaluate_credits_notices output through render_notice_line and asserts the line is returned unchanged. --- agent/account_usage.py | 226 ++++- agent/agent_init.py | 13 + agent/chat_completion_helpers.py | 1 + agent/conversation_loop.py | 13 + agent/credits_tracker.py | 723 ++++++++++++++ cli.py | 103 +- gateway/run.py | 79 +- hermes_cli/nous_account.py | 2 + run_agent.py | 146 ++- tests/agent/test_credits_cold_start.py | 192 ++++ tests/agent/test_credits_fixture_snapshot.py | 67 ++ tests/agent/test_credits_policy.py | 492 ++++++++++ tests/agent/test_credits_tracker.py | 909 ++++++++++++++++++ tests/agent/test_nous_credits_gauge.py | 150 +++ tests/agent/test_nous_credits_snapshot.py | 126 +++ tests/gateway/test_notice_rendering.py | 174 ++++ tests/gateway/test_usage_command.py | 16 +- tests/run_agent/test_notice_spine.py | 205 ++++ tui_gateway/server.py | 60 +- .../__tests__/appChromeStatusRule.test.tsx | 149 +++ .../appChromeStatusRuleDevCredits.test.tsx | 73 ++ .../createGatewayEventHandler.test.ts | 312 ++++++ .../__tests__/turnControllerNotice.test.ts | 32 + ui-tui/src/app/createGatewayEventHandler.ts | 29 + ui-tui/src/app/interfaces.ts | 17 + ui-tui/src/app/slash/commands/session.ts | 15 +- ui-tui/src/app/turnController.ts | 126 +++ ui-tui/src/app/uiStore.ts | 1 + ui-tui/src/components/appChrome.tsx | 90 +- ui-tui/src/components/appLayout.tsx | 1 + ui-tui/src/config/env.ts | 4 + ui-tui/src/gatewayTypes.ts | 14 + ui-tui/src/types.ts | 1 + 33 files changed, 4535 insertions(+), 26 deletions(-) create mode 100644 agent/credits_tracker.py create mode 100644 tests/agent/test_credits_cold_start.py create mode 100644 tests/agent/test_credits_fixture_snapshot.py create mode 100644 tests/agent/test_credits_policy.py create mode 100644 tests/agent/test_credits_tracker.py create mode 100644 tests/agent/test_nous_credits_gauge.py create mode 100644 tests/agent/test_nous_credits_snapshot.py create mode 100644 tests/gateway/test_notice_rendering.py create mode 100644 tests/run_agent/test_notice_spine.py create mode 100644 ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx create mode 100644 ui-tui/src/__tests__/turnControllerNotice.test.ts diff --git a/agent/account_usage.py b/agent/account_usage.py index be03646021e..2795eb24125 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -1,8 +1,10 @@ from __future__ import annotations +import logging +import math from dataclasses import dataclass from datetime import datetime, timezone -from typing import Any, Optional +from typing import TYPE_CHECKING, Any, Optional import httpx @@ -10,6 +12,11 @@ from agent.anthropic_adapter import _is_oauth_token, resolve_anthropic_token from hermes_cli.auth import _read_codex_tokens, resolve_codex_runtime_credentials from hermes_cli.runtime_provider import resolve_runtime_provider +if TYPE_CHECKING: + from typing import TypeGuard + +logger = logging.getLogger(__name__) + def _utc_now() -> datetime: return datetime.now(timezone.utc) @@ -113,6 +120,223 @@ def render_account_usage_lines(snapshot: Optional[AccountUsageSnapshot], *, mark return lines +def _fmt_usd(d: float) -> str: + return f"${d:,.2f}" + + +def _is_finite_num(v: Any) -> TypeGuard[float]: + """True iff v is a real numeric value (int or float, not bool, not NaN/Inf). + + Typed as a ``TypeGuard[float]`` so the type checker narrows ``v`` to a real + number in the positive branch — callers can then do arithmetic / pass it to + ``_fmt_usd`` without a None-operand warning. + """ + return isinstance(v, (int, float)) and not isinstance(v, bool) and math.isfinite(v) + + +def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]: + """Map a NousPortalAccountInfo into an AccountUsageSnapshot for /usage. + + Shows dollar magnitudes (subscription / top-up / total) + renewal date + a + portal CTA. When the portal supplies a subscription denominator + (``monthly_credits``), also emits a subscription-usage window so the renderer + shows a real ``% used`` gauge; when it's absent (older portals) the view + gracefully degrades to magnitudes-only. Returns None when there's no usable + account info to show (fail-open: caller just shows nothing). + """ + try: + from hermes_cli.nous_account import nous_portal_billing_url + + if account_info is None or not getattr(account_info, "logged_in", False): + return None + + access = getattr(account_info, "paid_service_access_info", None) + sub = getattr(account_info, "subscription", None) + + windows: list[AccountUsageWindow] = [] + details: list[str] = [] + + # Subscription usage gauge — only when the portal supplies a positive + # monthly_credits denominator AND a finite remaining balance that does + # not exceed the cap. Money math is on float dollars (allowed: numeric + # account fields, NOT a server-provided *_usd string). used = cap - + # remaining; clamp [0,100] so a debt balance (remaining < 0) reads 100%. + # Excluded on purpose: + # - non-finite values (NaN/Infinity slip past isinstance and json.loads + # parses bare NaN/Infinity by default) → would render "$nan"/"$inf" + # and a falsely-confident gauge; + # - remaining > cap (rollover balance spanning the period) → monthly_credits + # is no longer a meaningful denominator, and "$X of $Y left" with X>Y + # reads as a contradiction. Both fall back to the magnitudes lines. + if sub is not None: + monthly_credits = getattr(sub, "monthly_credits", None) + sub_remaining = getattr(sub, "credits_remaining", None) + if ( + _is_finite_num(monthly_credits) + and monthly_credits > 0 + and _is_finite_num(sub_remaining) + and sub_remaining <= monthly_credits + ): + used = monthly_credits - sub_remaining + used_pct = max(0.0, min(100.0, used / monthly_credits * 100.0)) + windows.append( + AccountUsageWindow( + label="Subscription", + used_percent=used_pct, + detail=f"{_fmt_usd(sub_remaining)} of {_fmt_usd(monthly_credits)} left", + ) + ) + + if access is not None: + sub_credits = getattr(access, "subscription_credits_remaining", None) + if _is_finite_num(sub_credits): + details.append(f"Subscription credits: {_fmt_usd(sub_credits)}") + purchased = getattr(access, "purchased_credits_remaining", None) + if _is_finite_num(purchased): + details.append(f"Top-up credits: {_fmt_usd(purchased)}") + total_usable = getattr(access, "total_usable_credits", None) + if _is_finite_num(total_usable): + details.append(f"Total usable: {_fmt_usd(total_usable)}") + + if sub is not None: + rollover = getattr(sub, "rollover_credits", None) + if _is_finite_num(rollover) and rollover > 0: + details.append(f"Rollover: {_fmt_usd(rollover)}") + period_end = getattr(sub, "current_period_end", None) + if period_end: + details.append(f"Renews: {period_end}") + + paid = getattr(account_info, "paid_service_access", None) + if paid is False: + details.append("Status: access depleted — top up to restore") + + if not windows and not details: + return None + + details.append(f"Manage / top up: {nous_portal_billing_url(account_info)}") + + plan = getattr(sub, "plan", None) if sub is not None else None + return AccountUsageSnapshot( + provider="nous", + source="portal-account", + fetched_at=_utc_now(), + title="Nous credits", + plan=plan, + windows=tuple(windows), + details=tuple(details), + ) + except (AttributeError, TypeError): + return None + + +def nous_credits_lines(*, markdown: bool = False, timeout: float = 10.0) -> list[str]: + """Return rendered Nous-credits /usage lines, or [] when there's nothing to show. + + Account-independent of any live agent: gated on "a Nous account is logged in" + (a cheap local auth-state check), then a wall-clock-bounded portal fetch. Shared + by the CLI ``_show_usage`` and the TUI ``session.usage`` RPC so both surfaces show + the same block regardless of session API-call count or resume state. Fail-open: + any auth/portal hiccup or timeout returns [] (the caller shows nothing). + + Dev override: when HERMES_DEV_CREDITS_FIXTURE selects a fixture state, /usage + renders from that fixture instead of the real portal (so the block + gauge are + testable without a live account). Throwaway scaffolding. + """ + # Dev fixture short-circuit — render /usage from the injected state, no portal. + try: + from agent.credits_tracker import dev_fixture_credits_state + + fixture = dev_fixture_credits_state() + except Exception: + fixture = None + if fixture is not None: + snapshot = _snapshot_from_credits_state(fixture) + return render_account_usage_lines(snapshot, markdown=markdown) + + 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 [] + except Exception: + return [] + try: + import concurrent.futures + + from hermes_cli.nous_account import get_nous_portal_account_info + + with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool: + account = pool.submit( + get_nous_portal_account_info, force_fresh=True + ).result(timeout=timeout) + snapshot = build_nous_credits_snapshot(account) + return render_account_usage_lines(snapshot, markdown=markdown) + except Exception: + # Fail-open (caller shows nothing), but leave a breadcrumb so a dead + # /usage credits block is diagnosable in agent.log without a dev flag. + logger.debug("credits ▸ /usage portal fetch/render failed (fail-open)", exc_info=True) + return [] + + +def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]: + """Map a header-shaped CreditsState (e.g. a dev fixture) to the /usage snapshot. + + Renders the same magnitudes + monthly-grant % window the portal path produces, + so HERMES_DEV_CREDITS_FIXTURE can exercise /usage without a live account. The + *_usd strings are mock display values here (not server balance to compute on); + the % comes from CreditsState.used_fraction (micros math). Fail-open → None. + """ + try: + if state is None: + return None + + windows: list[AccountUsageWindow] = [] + details: list[str] = [] + + uf = getattr(state, "used_fraction", None) + if isinstance(uf, (int, float)) and math.isfinite(uf): + cap_usd = getattr(state, "subscription_limit_usd", None) + sub_usd = getattr(state, "subscription_usd", None) + detail = None + if sub_usd and cap_usd: + detail = f"${sub_usd} of ${cap_usd} left" + windows.append( + AccountUsageWindow( + label="Subscription", + used_percent=max(0.0, min(100.0, uf * 100.0)), + detail=detail, + ) + ) + + sub_usd = getattr(state, "subscription_usd", None) + if sub_usd: + details.append(f"Subscription credits: ${sub_usd}") + purchased_usd = getattr(state, "purchased_usd", None) + if purchased_usd: + details.append(f"Top-up credits: ${purchased_usd}") + remaining_usd = getattr(state, "remaining_usd", None) + if remaining_usd: + details.append(f"Total usable: ${remaining_usd}") + if getattr(state, "paid_access", True) is False: + details.append("Status: access depleted — top up to restore") + + if not windows and not details: + return None + + details.append("(dev fixture — HERMES_DEV_CREDITS_FIXTURE)") + return AccountUsageSnapshot( + provider="nous", + source="dev-fixture", + fetched_at=_utc_now(), + title="Nous credits", + windows=tuple(windows), + details=tuple(details), + ) + except (AttributeError, TypeError): + return None + + def _resolve_codex_usage_url(base_url: str) -> str: normalized = (base_url or "").strip().rstrip("/") if not normalized: diff --git a/agent/agent_init.py b/agent/agent_init.py index 675130a8840..c343882b28a 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -173,6 +173,8 @@ def init_agent( interim_assistant_callback: callable = None, tool_gen_callback: callable = None, status_callback: callable = None, + notice_callback: callable = None, + notice_clear_callback: callable = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -399,6 +401,8 @@ def init_agent( agent.stream_delta_callback = stream_delta_callback agent.interim_assistant_callback = interim_assistant_callback agent.status_callback = status_callback + agent.notice_callback = notice_callback + agent.notice_clear_callback = notice_clear_callback agent.tool_gen_callback = tool_gen_callback @@ -507,6 +511,15 @@ def init_agent( # after each API call. Accessed by /usage slash command. agent._rate_limit_state: Optional["RateLimitState"] = None + # Credits tracking (dev-only, L0 usage-aware-credits) — updated from + # x-nous-credits-* response headers after each API call. Session-start + # remaining is latched the first time a header is ever seen so we can + # report cumulative micros spent. Surfaced behind HERMES_DEV_CREDITS. + agent._credits_state = None + agent._credits_session_start_micros = None + # Threshold-notice latch (L4): active sticky-notice keys + the warn90 crossing gate. + agent._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None} + # OpenRouter response cache hit counter — incremented when # X-OpenRouter-Cache-Status: HIT is seen in streaming response headers. agent._or_cache_hits: int = 0 diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 496e74e396e..fca2e3bd005 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1733,6 +1733,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta= # The OpenAI SDK Stream object exposes the underlying httpx # response via .response before any chunks are consumed. agent._capture_rate_limits(getattr(stream, "response", None)) + agent._capture_credits(getattr(stream, "response", None)) # Snapshot diagnostic headers (cf-ray, x-openrouter-provider, etc.) # so they survive even when the stream dies before any chunk # arrives. Best-effort; never raises. diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 4d8031516b3..a07d0ed4b0b 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -301,6 +301,19 @@ def _restore_or_build_system_prompt(agent, system_message, conversation_history) except Exception as exc: logger.warning("on_session_start hook failed: %s", exc) + # Cold-start credits seed (L3) — fallback for the first-turn path. The TUI/ + # desktop build seeds at session OPEN (see seed_credits_at_session_start in + # tui_gateway), so this call is usually a no-op there (idempotent: skips when + # _credits_state already exists). For the plain CLI / any path that didn't seed + # at build, it primes credits state from /api/oauth/account (or a fixture) on the + # first turn so depletion / usage-band warnings fire. Fail-open inside the helper. + try: + from agent.credits_tracker import seed_credits_at_session_start + + seed_credits_at_session_start(agent) + except Exception: + logger.debug("cold-start credits seed failed (fail-open)", exc_info=True) + # Persist the system prompt snapshot in SQLite. Failure here used # to log at DEBUG, which silently broke prefix-cache reuse on the # gateway path (fresh AIAgent per turn → reads from this row every diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py new file mode 100644 index 00000000000..79d05dbb196 --- /dev/null +++ b/agent/credits_tracker.py @@ -0,0 +1,723 @@ +"""Credits tracking for Nous inference API responses. + +Parses x-nous-credits-* (and optional x-nous-tool-pool-*) headers from +inference responses into a validated CreditsState dataclass. Provides +depletion detection (paid_access), subscription-cap used_fraction, and +warn-once schema-version gating. This is the hardened parser used by all +live consumers (run_agent, tui_gateway) — not a dev-only shim. + +Header schema (x-nous-credits-* family): + x-nous-credits-version contract/schema version + x-nous-credits-remaining-micros total remaining balance (micros) + x-nous-credits-remaining-usd same, formatted USD string + x-nous-credits-subscription-micros subscription balance (SIGNED; may be negative/debt) + x-nous-credits-subscription-usd same, formatted USD string + x-nous-credits-subscription-limit-micros subscription cap (PAIRED/optional) + x-nous-credits-subscription-limit-usd same, formatted USD string (PAIRED/optional) + x-nous-credits-rollover-micros rolled-over balance (micros) + x-nous-credits-purchased-micros purchased balance (micros) + x-nous-credits-purchased-usd same, formatted USD string + x-nous-credits-denominator-kind "subscription_cap" | "none" + x-nous-credits-paid-access "true" | "false" (STRING!) + x-nous-credits-disabled-reason reason string (header omitted when null) + x-nous-credits-as-of-ms server-side timestamp (ms epoch) + +Tool-pool headers use a SEPARATE prefix: + x-nous-tool-pool-micros tool-pool balance (micros) + x-nous-tool-pool-gated-off "true" | "false" (STRING!) + +Money is handled as micros ints only; *_usd values are preserved verbatim as +the raw strings the server sent (never re-parsed to float). +""" + +from __future__ import annotations + +import logging +import os +import re +import time +from dataclasses import dataclass +from typing import Any, Mapping, Optional + +from utils import is_truthy_value + +logger = logging.getLogger(__name__) + +# Warn-once latch: emit the version-unsupported warning at most once per process. +_version_warning_emitted: bool = False + +# Valid denominator kinds (exhaustive set from the API contract). +_VALID_DENOMINATOR_KINDS = frozenset({"subscription_cap", "none"}) + +# USD format: optional leading minus, one-or-more digits, dot, exactly 2 digits. +_USD_RE = re.compile(r"^-?\d+\.\d{2}$") + + +# ── Internal helpers ───────────────────────────────────────────────────────── + + +_SENTINEL = object() # singleton sentinel for "parse failed" + + +def _safe_int(value: Any) -> Any: + """Parse a header value to an exact int (money-safe). + + The contract guarantees every ``*_micros`` field is an integer string — + we parse with ``int()`` directly, NOT ``int(float(...))``, to avoid float- + precision loss above 2**53 that would silently corrupt large money values. + + Returns the parsed int, or ``_SENTINEL`` if the value is not a valid integer + string (including float-shaped strings like "1.5"). The sentinel lets callers + detect the failure and return None from the overall parse (fail-hard-on-bad- + input, not silently coerce). + """ + if value is None: + return _SENTINEL + try: + return int(str(value)) + except (TypeError, ValueError): + return _SENTINEL + + + +def _validate_usd(value: Optional[str]) -> bool: + """Return True iff value is a non-None string matching ^-?\\d+\\.\\d{2}$.""" + if value is None: + return False + return bool(_USD_RE.match(value)) + + +# ── CreditsState dataclass ─────────────────────────────────────────────────── + + +@dataclass +class CreditsState: + """Full credits state parsed from x-nous-credits-* response headers.""" + + version: int = 0 + remaining_micros: int = 0 + remaining_usd: str = "" + subscription_micros: int = 0 # SIGNED — may be negative (debt). ONLY field allowed negative. + subscription_usd: str = "" + subscription_limit_micros: Optional[int] = None # PAIRED + OPTIONAL (only when subscription_cap) + subscription_limit_usd: Optional[str] = None + rollover_micros: int = 0 + purchased_micros: int = 0 + purchased_usd: str = "" + tool_pool_micros: int = 0 + tool_pool_gated_off: bool = False + denominator_kind: str = "none" # "subscription_cap" | "none" + paid_access: bool = True # depletion keys off THIS == False, NEVER remaining==0 + disabled_reason: Optional[str] = None # header omitted entirely when null + as_of_ms: int = 0 + captured_at: float = 0.0 # time.time() when this was captured + from_header: bool = False # True only when populated by parse_credits_headers() + + @property + def has_data(self) -> bool: + return self.captured_at > 0 + + @property + def age_seconds(self) -> float: + if not self.has_data: + return float("inf") + return time.time() - self.captured_at + + @property + def depleted(self) -> bool: + """True when the account has lost paid access. + + Keyed off ``paid_access == False`` ONLY — never ``remaining_micros == 0``, + which would give a false positive whenever the balance is zero but access + is still live (e.g. subscription renewal pending). + """ + return not self.paid_access + + @property + def used_fraction(self) -> Optional[float]: + """Fraction of the subscription cap consumed, in [0.0, 1.0]. + + Computable only when ``subscription_limit_micros`` is a truthy (non-zero, + non-None) int. Guarded on the LIMIT FIELD, not ``denominator_kind`` — + the limit field is the real denominator; ``denominator_kind`` is metadata. + Returns None when there is no computable denominator (no limit, or limit==0). + """ + if not isinstance(self.subscription_limit_micros, int): + return None + if self.subscription_limit_micros <= 0: + return None + used = self.subscription_limit_micros - self.subscription_micros + return max(0.0, min(1.0, used / self.subscription_limit_micros)) + + +# ── Credits policy constants ───────────────────────────────────────────────── +# Switching credits notices from sticky→TTL later would also require wiring a +# paired *_TTL_MS companion for each notice kind — the field exists on AgentNotice +# but is not yet plumbed through the policy loop. + +CREDITS_NOTICE_KIND = "sticky" # v1: credits notices are sticky +CREDITS_RESTORED_TTL_MS = 8000 # the only TTL notice in v1 (depletion-recovery confirmation) + +# Usage-gauge bands (ascending). Each is (threshold_fraction, level, label_pct). +# The notice shows the HIGHEST band the current used_fraction has reached — a single +# escalating status-bar line (50 → 75 → 90), not three stacked notices. Crossing the +# next band up replaces the line; recovering below a band steps it back down. Edit +# this list to retune the bands; the policy derives everything from it. +CREDITS_USAGE_BANDS: tuple[tuple[float, str, int], ...] = ( + (0.50, "info", 50), + (0.75, "warn", 75), + (0.90, "warn", 90), +) +CREDITS_USAGE_KEY = "credits.usage" # single key for the escalating usage notice + + +# ── AgentNotice (out-of-band notice payload; driver-agnostic) ──────────────── + + +@dataclass +class AgentNotice: + """A structured, driver-agnostic out-of-band notice. + + The agent fires these via ``AIAgent.notice_callback`` (and clears them via + ``notice_clear_callback``); each driver renders it its own way — the TUI as a + status-bar override, the CLI as a console line, etc. v1 credits notices are all + ``kind="sticky"``; ``kind``/``ttl_ms`` are kept fully expressive so a future + config/slash-command can switch them to TTL without touching the policy (a + single default seam — see L4). + """ + + text: str + level: str = "info" # info | warn | error | success + kind: str = "sticky" # sticky | ttl + ttl_ms: Optional[int] = None # honored only when kind == "ttl" + key: Optional[str] = None # dedupe / fired-once-latch / clear key + id: Optional[str] = None + + +# ── evaluate_credits_notices (pure reconciliation function) ────────────────── + + +def evaluate_credits_notices( + state: CreditsState, + latch: dict, +) -> tuple[list[AgentNotice], list[str]]: + """Reconcile credits notices against the latch. Mutates ``latch`` IN PLACE. + + latch = {"active": set[str], "seen_below_90": bool, "usage_band": Optional[int]}. + + Returns ``(to_show: list[AgentNotice], to_clear: list[str])``. + Caller emits to_clear FIRST, then to_show. + + Pure function — no I/O, no agent/run_agent imports. + """ + to_show: list[AgentNotice] = [] + to_clear: list[str] = [] + + uf = state.used_fraction + + # Crossing latch: once we've observed uf below the LOWEST band, escalating + # usage notices may fire. This prevents a brand-new session that opens + # mid-range from firing spuriously on the first observation (the cold-start + # seed primes this explicitly when it WANTS an open-high warning). + _lowest_band = CREDITS_USAGE_BANDS[0][0] + if uf is not None and uf < _lowest_band: + latch["seen_below_90"] = True # gate opened: usage-band notices may now fire + + active = latch["active"] + + # ── Conditions ─────────────────────────────────────────────────────────── + # Highest band whose threshold the current usage has reached (None below all). + current_band: Optional[tuple[float, str, int]] = None + if uf is not None: + for band in CREDITS_USAGE_BANDS: # ascending → last match wins = highest + if uf >= band[0]: + current_band = band + grant_cond = ( + state.denominator_kind == "subscription_cap" + and uf is not None + and uf >= 1.0 + and state.purchased_micros > 0 + ) + depleted_cond = not state.paid_access + + # ── usage gauge (escalating single notice: 50 → 75 → 90) ────────────────── + # Show only the highest crossed band; replace the line when the band changes + # (climb or step-down on recovery); clear entirely when usage drops below the + # lowest band or the denominator disappears (uf is None). + shown_band = latch.get("usage_band") # the pct label currently displayed, or None + target_band = current_band[2] if (current_band and latch["seen_below_90"]) else None + if target_band != shown_band: + if CREDITS_USAGE_KEY in active: + to_clear.append(CREDITS_USAGE_KEY) + active.discard(CREDITS_USAGE_KEY) + if target_band is not None: + # Belt-and-suspenders: a producer could set subscription_limit_micros + # without subscription_limit_usd. Render "$? cap" rather than "$None cap". + _cap_usd = state.subscription_limit_usd or "?" + _level = current_band[1] # type: ignore[index] (current_band set when target_band set) + to_show.append( + AgentNotice( + text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap", + level=_level, + kind=CREDITS_NOTICE_KIND, + key=CREDITS_USAGE_KEY, + id=CREDITS_USAGE_KEY, + ) + ) + active.add(CREDITS_USAGE_KEY) + latch["usage_band"] = target_band + + # ── grant_spent ────────────────────────────────────────────────────────── + if grant_cond and "credits.grant_spent" not in active: + to_show.append( + AgentNotice( + text=f"• Grant spent · ${state.purchased_usd} top-up left", + level="info", + kind=CREDITS_NOTICE_KIND, + key="credits.grant_spent", + id="credits.grant_spent", + ) + ) + active.add("credits.grant_spent") + elif "credits.grant_spent" in active and not grant_cond: + to_clear.append("credits.grant_spent") + active.discard("credits.grant_spent") + + # ── depleted ───────────────────────────────────────────────────────────── + if depleted_cond and "credits.depleted" not in active: + to_show.append( + AgentNotice( + text="✕ Credit access paused · run /usage for balance", + level="error", + kind=CREDITS_NOTICE_KIND, + key="credits.depleted", + id="credits.depleted", + ) + ) + active.add("credits.depleted") + elif "credits.depleted" in active and not depleted_cond: + to_clear.append("credits.depleted") + active.discard("credits.depleted") + # Recovery: also emit the success notice + to_show.append( + AgentNotice( + text="✓ Credit access restored", + level="success", + kind="ttl", + ttl_ms=CREDITS_RESTORED_TTL_MS, + key="credits.restored", + id="credits.restored", + ) + ) + + return (to_show, to_clear) + + +# ── parse_credits_headers ──────────────────────────────────────────────────── + + +def parse_credits_headers( + headers: Mapping[str, str], + provider: str = "", +) -> Optional[CreditsState]: + """Parse x-nous-credits-* (and x-nous-tool-pool-*) headers into a CreditsState. + + Returns None (miss) on ANY of: + - No ``x-nous-credits-version`` header present. + - Version != 1 (> 1 also emits a one-time logger.warning). + - Any ``*_micros`` field is non-integer, or negative for a non-subscription field. + - Any ``*_usd`` field doesn't match ``^-?\\d+\\.\\d{2}$``. + - ``denominator_kind`` is not in {"subscription_cap", "none"}. + - ``paid_access`` / ``tool_pool_gated_off`` is not exactly "true"/"false". + - ``as_of_ms`` is not a valid integer. + - Any unexpected exception. + + Fail-open on the subscription_limit pair: a half-pair (only -micros or only + -usd present) is treated as both-absent; the overall parse STILL SUCCEEDS + but with subscription_limit_micros/usd both None. + """ + global _version_warning_emitted + + try: + # Cheap probe before the full lowercase copy: bail when the version + # sentinel header is absent (the common case for non-Nous providers, on + # every API call) — skips allocating a dict over the whole response's + # headers on the hot path, while preserving case-insensitivity. Behaviour + # is identical: a missing version header was already a None return below. + if not any(k.lower() == "x-nous-credits-version" for k in headers): + return None + # Normalize to lowercase so lookups work regardless of how the server + # capitalises headers (HTTP header names are case-insensitive per RFC 7230). + lowered = {k.lower(): v for k, v in headers.items()} + + # ── Version check ──────────────────────────────────────────────────── + # Must be present and exactly 1; > 1 warns once then returns None. + version_raw = lowered.get("x-nous-credits-version") + if version_raw is None: + return None + version_val = _safe_int(version_raw) + if version_val is _SENTINEL: + return None + if version_val != 1: + if version_val > 1 and not _version_warning_emitted: + _version_warning_emitted = True + logger.warning( + "credits header version %d unsupported, ignoring — update Hermes", + version_val, + ) + return None + + # ── Helper: parse a required non-negative int field (fail → None) ─── + def _req_nonneg(key: str) -> Any: + raw = lowered.get(key) + val = _safe_int(raw) + if val is _SENTINEL: + return _SENTINEL + if val < 0: + return _SENTINEL + return val + + # ── Helper: parse a required int field that may be negative (subscription only) ─ + def _req_int(key: str) -> Any: + raw = lowered.get(key) + val = _safe_int(raw) + if val is _SENTINEL: + return _SENTINEL + return val + + # ── Parse micros fields ────────────────────────────────────────────── + remaining_micros = _req_nonneg("x-nous-credits-remaining-micros") + if remaining_micros is _SENTINEL: + return None + + subscription_micros = _req_int("x-nous-credits-subscription-micros") + if subscription_micros is _SENTINEL: + return None + + rollover_micros = _req_nonneg("x-nous-credits-rollover-micros") + if rollover_micros is _SENTINEL: + return None + + purchased_micros = _req_nonneg("x-nous-credits-purchased-micros") + if purchased_micros is _SENTINEL: + return None + + # tool_pool_micros is OPTIONAL: absent → 0 (default); present-but-invalid → None (miss). + _tp_raw = lowered.get("x-nous-tool-pool-micros") + if _tp_raw is None: + tool_pool_micros = 0 + else: + _tp_val = _safe_int(_tp_raw) + if _tp_val is _SENTINEL or _tp_val < 0: + return None + tool_pool_micros = _tp_val + + as_of_ms = _req_nonneg("x-nous-credits-as-of-ms") + if as_of_ms is _SENTINEL: + return None + + # ── Validate USD strings ───────────────────────────────────────────── + remaining_usd = lowered.get("x-nous-credits-remaining-usd", "") + if not _validate_usd(remaining_usd): + return None + + subscription_usd = lowered.get("x-nous-credits-subscription-usd", "") + if not _validate_usd(subscription_usd): + return None + + purchased_usd = lowered.get("x-nous-credits-purchased-usd", "") + if not _validate_usd(purchased_usd): + return None + + # ── subscription_limit_* PAIRED + OPTIONAL ─────────────────────────── + # Both present → validate both; half-pair → treat BOTH as absent (parse + # still succeeds, just with no limit pair). + sub_limit_micros_raw = lowered.get("x-nous-credits-subscription-limit-micros") + sub_limit_usd_raw = lowered.get("x-nous-credits-subscription-limit-usd") + + subscription_limit_micros: Optional[int] = None + subscription_limit_usd: Optional[str] = None + + if sub_limit_micros_raw is not None and sub_limit_usd_raw is not None: + # Both present — validate both; any invalid → return None (bad data) + lm = _safe_int(sub_limit_micros_raw) + if lm is _SENTINEL: + return None + if lm < 0: + return None + if not _validate_usd(sub_limit_usd_raw): + return None + subscription_limit_micros = lm + subscription_limit_usd = sub_limit_usd_raw + # else: half-pair or both absent → leave both None, parse continues + + # ── denominator_kind ───────────────────────────────────────────────── + denominator_kind = lowered.get("x-nous-credits-denominator-kind", "none") + if denominator_kind not in _VALID_DENOMINATOR_KINDS: + return None + + # ── paid_access / tool_pool_gated_off ──────────────────────────────── + # Both must be exactly "true" or "false" (case-insensitive). An absent + # paid_access header → fail-open (assume access); absent tool_pool_gated_off + # → default False. Present but invalid → return None. + if "x-nous-credits-paid-access" in lowered: + pa_raw = lowered["x-nous-credits-paid-access"].strip().lower() + if pa_raw not in ("true", "false"): + return None + paid_access = pa_raw == "true" + else: + paid_access = True # fail-open + + if "x-nous-tool-pool-gated-off" in lowered: + tpgo_raw = lowered["x-nous-tool-pool-gated-off"].strip().lower() + if tpgo_raw not in ("true", "false"): + return None + tool_pool_gated_off = tpgo_raw == "true" + else: + tool_pool_gated_off = False + + # ── disabled_reason: header omitted when null ──────────────────────── + disabled_reason = lowered.get("x-nous-credits-disabled-reason") # None if absent + + return CreditsState( + version=version_val, + remaining_micros=remaining_micros, + remaining_usd=remaining_usd, + subscription_micros=subscription_micros, + subscription_usd=subscription_usd, + subscription_limit_micros=subscription_limit_micros, + subscription_limit_usd=subscription_limit_usd, + rollover_micros=rollover_micros, + purchased_micros=purchased_micros, + purchased_usd=purchased_usd, + tool_pool_micros=tool_pool_micros, + tool_pool_gated_off=tool_pool_gated_off, + denominator_kind=denominator_kind, + paid_access=paid_access, + disabled_reason=disabled_reason, + as_of_ms=as_of_ms, + captured_at=time.time(), + from_header=True, + ) + + except Exception: + # Fail-open → miss, but leave a breadcrumb so a parser/import regression + # (feature silently dead) is distinguishable from a legitimate no-headers + # response in agent.log, without needing a dev flag. + logger.debug("credits ▸ parse_credits_headers raised (fail-open miss)", exc_info=True) + return None + + +# ── Dev test fixtures (HERMES_DEV_CREDITS_FIXTURE) ─────────────────────────── +# Throwaway dev scaffolding: trigger any notice state on demand for testing, +# without real spend or Redis seeding. Set HERMES_DEV_CREDITS_FIXTURE to either a +# state NAME (fixed for the session) or a FILE PATH whose contents are a state +# name (re-read every turn → flip states live: `echo depleted > /tmp/cf`, take a +# turn; `echo healthy > /tmp/cf`, take a turn → recovery). +# +# A fixture drives THREE surfaces uniformly, so the whole credits UX is testable +# offline: (1) the per-turn capture/notice path (_capture_credits), (2) the +# cold-start seed at session open (conversation_loop → depletion/warn90 hydrate +# immediately), and (3) the /usage view (nous_credits_lines renders the fixture). +# `clear` / `none` / unset → real behaviour. Delete with the rest of the +# HERMES_DEV_CREDITS scaffolding. +_DEV_FIXTURES: dict[str, dict] = { + "healthy": dict( # used_fraction ~0.1, paid → no notice (recovery target) + remaining_micros=30_340_000, remaining_usd="30.34", + subscription_micros=18_000_000, subscription_usd="18.00", + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + purchased_micros=12_340_000, purchased_usd="12.34", + denominator_kind="subscription_cap", paid_access=True, + ), + "sub_50pct": dict( # used_fraction == 0.5 → credits.usage band 50 (info) + remaining_micros=10_000_000, remaining_usd="10.00", + subscription_micros=10_000_000, subscription_usd="10.00", + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + denominator_kind="subscription_cap", paid_access=True, + ), + "sub_75pct": dict( # used_fraction == 0.75 → credits.usage band 75 (warn) + remaining_micros=5_000_000, remaining_usd="5.00", + subscription_micros=5_000_000, subscription_usd="5.00", + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + denominator_kind="subscription_cap", paid_access=True, + ), + "sub_90pct": dict( # used_fraction == 0.9 → credits.usage band 90 (warn) + remaining_micros=2_000_000, remaining_usd="2.00", + subscription_micros=2_000_000, subscription_usd="2.00", + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + denominator_kind="subscription_cap", paid_access=True, + ), + "grant_exhausted": dict( # used_fraction == 1.0 + purchased>0 → credits.grant_spent + remaining_micros=12_340_000, remaining_usd="12.34", + subscription_micros=0, subscription_usd="0.00", + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + purchased_micros=12_340_000, purchased_usd="12.34", + denominator_kind="subscription_cap", paid_access=True, + ), + "depleted": dict( # paid_access False → credits.depleted (sticky) + remaining_micros=0, remaining_usd="0.00", + subscription_micros=0, subscription_usd="0.00", + purchased_micros=0, purchased_usd="0.00", + paid_access=False, disabled_reason="out_of_credits", + ), + "debt": dict( # subscription in debt (negative, the only signed field) → depleted + remaining_micros=0, remaining_usd="0.00", + subscription_micros=-5_000_000, subscription_usd="-5.00", + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + purchased_micros=0, purchased_usd="0.00", + denominator_kind="subscription_cap", paid_access=False, + disabled_reason="out_of_credits", + ), +} + + +def dev_fixture_credits_state() -> Optional[CreditsState]: + """Return a fixture CreditsState for HERMES_DEV_CREDITS_FIXTURE, or None. + + The env value is a state name, OR a path to a file whose contents are a state + name (re-read each call → flip states live without a restart). Unknown name / + "clear" / "none" / unset → None (normal behaviour). Throwaway test scaffolding. + + Hard prod-leak guard: a fixture applies ONLY when the dev flag HERMES_DEV_CREDITS + is also on, so a stray HERMES_DEV_CREDITS_FIXTURE (leaked into a shell profile, a + container env, a launch plist, …) can never surface fabricated balances/notices + on a real account. + """ + if not is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): + return None + raw = os.environ.get("HERMES_DEV_CREDITS_FIXTURE", "").strip() + if not raw: + return None + name = raw + if os.path.sep in raw or "/" in raw: # looks like a path → read the name from the file + try: + with open(raw, "r", encoding="utf-8") as fh: + name = fh.read().strip() + except OSError: + return None + spec = _DEV_FIXTURES.get(name.lower()) + if not spec: + return None + # Stamp the fields the REAL parser always guarantees, so a fixture state is + # field-identical to a parse_credits_headers() result from equivalent headers + # (verified by the differential test): version is always 1, and purchased_usd + # is always a valid usd string (the parser rejects a missing/empty one, so a + # real zero-top-up account still carries "0.00"). Specs may override these. + merged = {"version": 1, "purchased_usd": "0.00", **spec} + return CreditsState(**merged, from_header=True, captured_at=time.time()) + + +def _credits_state_from_account(info) -> Optional[CreditsState]: + """Map a NousPortalAccountInfo into a header-shaped CreditsState for the seed. + + Float account dollars → micros (plus a DISPLAY *_usd string — allowed, since + we're formatting account floats, NOT parsing a server-provided *_usd). Returns + None if the account can't yield a usable state (fail-open).""" + try: + _acc = getattr(info, "paid_service_access_info", None) + _sub = getattr(info, "subscription", None) + + def _to_micros(dollars): + return int(round(dollars * 1_000_000)) if isinstance(dollars, (int, float)) else 0 + + def _to_usd(dollars): + # DISPLAY formatting of an account float (not a server *_usd string); + # "" when absent so render/notice copy falls back gracefully. + return f"{dollars:.2f}" if isinstance(dollars, (int, float)) else "" + + _monthly = getattr(_sub, "monthly_credits", None) + _has_cap = isinstance(_monthly, (int, float)) and _monthly > 0 + _paid = getattr(info, "paid_service_access", None) + return CreditsState( + remaining_micros=_to_micros(getattr(_acc, "total_usable_credits", None)), + remaining_usd=_to_usd(getattr(_acc, "total_usable_credits", None)), + subscription_micros=_to_micros(getattr(_acc, "subscription_credits_remaining", None)), + subscription_usd=_to_usd(getattr(_acc, "subscription_credits_remaining", None)), + subscription_limit_micros=_to_micros(_monthly) if _has_cap else None, + subscription_limit_usd=_to_usd(_monthly) if _has_cap else None, + purchased_micros=_to_micros(getattr(_acc, "purchased_credits_remaining", None)), + purchased_usd=_to_usd(getattr(_acc, "purchased_credits_remaining", None)), + rollover_micros=_to_micros(getattr(_sub, "rollover_credits", None)), + denominator_kind="subscription_cap" if _has_cap else "none", + paid_access=_paid if isinstance(_paid, bool) else True, + from_header=False, + captured_at=time.time(), + ) + except Exception: + logger.debug("credits ▸ seed account→state mapping failed", exc_info=True) + return None + + +def _hydrate_seed_state(agent, state) -> None: + """Install a seed CreditsState on the agent and fire the notice policy once. + + Sets _credits_state, latches session-start remaining, and primes the crossing + gate (the cold-start snapshot IS the first observation, so a session that opens + already in a band warns immediately — the live header path keeps true crossing + semantics), then emits. Safe to call from a worker thread: emit already runs + off-thread in the TUI build path.""" + agent._credits_state = state + if getattr(agent, "_credits_session_start_micros", None) is None: + agent._credits_session_start_micros = state.remaining_micros + _latch = getattr(agent, "_credits_latch", None) + if isinstance(_latch, dict) and state.used_fraction is not None: + _latch["seen_below_90"] = True + emit = getattr(agent, "_emit_credits_notices", None) + if callable(emit): + emit() + + +def seed_credits_at_session_start(agent) -> bool: + """Hydrate agent._credits_state from /api/oauth/account (or a dev fixture) and + fire the notice policy, so depletion / usage-band warnings show at session OPEN. + + Shared by (a) the TUI/desktop agent build (fires at "ready", before any message) + and (b) the first-turn conversation setup (fallback for plain CLI / when the + build path didn't seed). Idempotent: a second call is a no-op once a seed or a + real header has already populated _credits_state. + + Returns True if it seeded this call, False otherwise (not nous / already seeded / + fail-open error). Never raises — credits must never block session startup. + """ + try: + if getattr(agent, "provider", "") != "nous": + return False + # Idempotent: don't re-seed if state already exists (seed or live header). + if getattr(agent, "_credits_state", None) is not None: + return False + fixture = None + try: + fixture = dev_fixture_credits_state() + except Exception: + fixture = None + if fixture is not None: + # Synchronous: a fixture is instant (no network), and tests rely on the + # state + notice landing before this returns. + _hydrate_seed_state(agent, fixture) + return True + + # Real portal fetch is FIRE-AND-FORGET: a slow/unreachable portal must never + # delay session "ready". A daemon thread hydrates + emits when it resolves, + # re-checking idempotency first (a live inference header may land before it). + import threading + + def _bg_seed() -> None: + try: + from hermes_cli.nous_account import get_nous_portal_account_info + info = get_nous_portal_account_info(force_fresh=True) + if getattr(agent, "_credits_state", None) is not None: + return # a live inference header beat us — don't clobber it + state = _credits_state_from_account(info) + if state is not None: + _hydrate_seed_state(agent, state) + except Exception: + logger.debug("credits ▸ session-start seed (background) failed", exc_info=True) + + threading.Thread(target=_bg_seed, name="credits-seed", daemon=True).start() + return True + except Exception: + # Fail-open: any auth/portal hiccup leaves _credits_state as-is, never blocks. + # Innermost log across all four call sites (TUI build / CLI build / first + # turn / desktop), so a dead session-open seed is diagnosable in agent.log. + logger.debug("credits ▸ session-start seed failed (fail-open)", exc_info=True) + return False diff --git a/cli.py b/cli.py index 1c4899c65e7..bb11587562f 100644 --- a/cli.py +++ b/cli.py @@ -4234,6 +4234,52 @@ class HermesCLI: self._tool_start_time = 0.0 # clear tool timer when switching to thinking self._invalidate() + def _on_notice(self, notice) -> None: + """Queue an out-of-band AgentNotice for rendering at the next clean boundary. + + Notices fire from inside the agent turn (cold-start seed during _init_agent, + per-turn _capture_credits after the API call) — printing immediately races the + streaming response and the line gets buried behind the prompt (see _cprint's + bg-thread caveat). So we QUEUE here and flush in _flush_credit_notices(), called + right after run_conversation returns. Fail-soft: never break the turn. + """ + try: + text = getattr(notice, "text", "") or "" + if not text: + return + level = getattr(notice, "level", "info") or "info" + if not hasattr(self, "_pending_credit_notices"): + self._pending_credit_notices = [] + self._pending_credit_notices.append((level, text)) + except Exception: + pass + + def _flush_credit_notices(self) -> None: + """Print any queued credit notices as level-colored lines. Called at turn end + (after run_conversation) where _cprint paints cleanly above the prompt.""" + try: + pending = getattr(self, "_pending_credit_notices", None) + if not pending: + return + self._pending_credit_notices = [] + for level, text in pending: + color = { + "error": "\033[31m", + "warn": "\033[33m", + "success": "\033[32m", + "info": _DIM, + }.get(level, _DIM) + _cprint(f" {color}{text}{_RST}") + except Exception: + pass + + def _on_notice_clear(self, key: str) -> None: + """Notice cleared. The REPL prints lines (no persistent slot to wipe), so + this drops any still-queued notice with that key is not tracked by key here; + it's a no-op for rendering — kept so the agent's clear callback is bound + symmetrically with the show callback (and so future REPL UIs can hook it).""" + return + # ── Streaming display ──────────────────────────────────────────────── def _current_reasoning_callback(self): @@ -5218,6 +5264,8 @@ class HermesCLI: tool_complete_callback=self._on_tool_complete if self._inline_diffs_enabled else None, stream_delta_callback=self._stream_delta if self.streaming_enabled else None, tool_gen_callback=self._on_tool_gen_start if self.streaming_enabled else None, + notice_callback=self._on_notice, + notice_clear_callback=self._on_notice_clear, ) # Store reference for atexit memory provider shutdown global _active_agent_ref @@ -5225,6 +5273,16 @@ class HermesCLI: # Route agent status output through prompt_toolkit so ANSI escape # sequences aren't garbled by patch_stdout's StdoutProxy (#2262). self.agent._print_fn = _cprint + # Hydrate credits notices at session OPEN (parity with the TUI), so a + # depletion / usage-band warning shows before the first message. The + # notice_callback is bound above → _on_notice renders the line. Idempotent + # + fail-open inside the helper; harmless for non-Nous providers. + try: + from agent.credits_tracker import seed_credits_at_session_start + + seed_credits_at_session_start(self.agent) + except Exception: + pass self._active_agent_route_signature = ( effective_model, runtime.get("provider"), @@ -10535,16 +10593,24 @@ class HermesCLI: return True def _show_usage(self): - """Show rate limits (if available) and session token usage.""" + """Rate limits + session token usage (when a live agent exists) + Nous credits. + + The Nous credits block is agent-independent (a portal fetch), so it runs even + with no live agent — important for the TUI, where /usage runs in a slash-worker + subprocess that resumes the session WITHOUT building an agent (self.agent is None), + which would otherwise early-return before any credits showed. + """ if not self.agent: - print("(._.) No active agent -- send a message first.") + if not self._print_nous_credits_block(): + print("(._.) No active agent -- send a message first.") return agent = self.agent calls = agent.session_api_calls if calls == 0: - print("(._.) No API calls made yet in this session.") + if not self._print_nous_credits_block(): + print("(._.) No API calls made yet in this session.") return # ── Rate limits (shown first when available) ──────────────── @@ -10638,6 +10704,10 @@ class HermesCLI: for line in account_lines: print(line) + # Nous credits magnitudes + monthly-grant gauge (agent-independent — also + # runs at the no-agent / no-calls early-returns above). See the helper. + self._print_nous_credits_block() + if self.verbose: logging.getLogger().setLevel(logging.DEBUG) for noisy in ('openai', 'openai._base_client', 'httpx', 'httpcore', 'asyncio', 'hpack', 'grpc', 'modal'): @@ -10652,6 +10722,28 @@ class HermesCLI: # Console quietness is enforced by hermes_logging not # installing a console StreamHandler in non-verbose mode. + def _print_nous_credits_block(self) -> bool: + """Print the Nous credits magnitudes + monthly-grant gauge when a Nous account + is logged in. Returns True if it printed anything. + + Delegates to the shared ``agent.account_usage.nous_credits_lines`` helper — + the single source for the /usage credits block across CLI, gateway, and TUI. + It's agent-independent (a portal fetch gated on "a Nous account is logged in", + NOT the inference-provider string), so /usage shows the block even in the TUI + slash-worker subprocess that resumes WITHOUT a live agent. Fail-open and + wall-clock-bounded inside the helper; also honors HERMES_DEV_CREDITS_FIXTURE + for offline testing — same behavior as every other surface. + """ + from agent.account_usage import nous_credits_lines + + lines = nous_credits_lines() + if not lines: + return False + print() + for line in lines: + print(f" {line}") + return True + def _show_insights(self, command: str = "/insights"): """Show usage insights and analytics from session history.""" # Parse optional --days flag @@ -12406,6 +12498,11 @@ class HermesCLI: "error": _summary, } finally: + # Surface any credit notices queued during the turn (cold-start + # seed / per-turn capture) now that the response is done — printing + # at this boundary paints cleanly above the prompt instead of being + # buried behind the streaming output. + self._flush_credit_notices() # Clear thread-local callbacks so a reused thread doesn't # hold stale references to a disposed CLI instance. try: diff --git a/gateway/run.py b/gateway/run.py index 66cffc3e82a..18aa5ef175f 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -319,6 +319,21 @@ def _prepare_gateway_status_message(platform: Any, event_type: str, message: str return text +def render_notice_line(notice) -> str: + """Render an AgentNotice to a single plaintext line for messaging platforms. + + Messaging has no persistent status bar (unlike the TUI), so a notice is a + one-shot standalone push. The notice policy already bakes the level glyph + (⚠ / • / ✕ / ✓) into the text, and the TUI + CLI REPL render that text + verbatim — so we emit it as-is here too. Prepending a per-level glyph would + DOUBLE it ("⚠ ⚠ Credits 90% used", "⛔ ✕ Credit access paused"). Plaintext + only — no markdown — so it renders uniformly across Telegram/Discord/Slack/ + SMS without per-platform escaping. Fail-soft: a malformed/empty notice + degrades to "" rather than raising on the agent's callback path. + """ + return str(getattr(notice, "text", "") or "").strip() + + async def _send_or_update_status_coro(adapter, chat_id, status_key, content, metadata): """Route a status message through adapter.send_or_update_status when supported. @@ -14098,6 +14113,7 @@ class GatewayRunner: # Fetch account usage off the event loop so slow provider APIs don't # block the gateway. Failures are non-fatal -- account_lines stays []. account_lines: list[str] = [] + credits_lines: list[str] = [] if provider: try: account_snapshot = await asyncio.to_thread( @@ -14111,6 +14127,21 @@ class GatewayRunner: if account_snapshot: account_lines = render_account_usage_lines(account_snapshot, markdown=True) + # ── Nous credits magnitudes + monthly-grant % gauge ───────────── + # Shared with the CLI / TUI /usage block via nous_credits_lines(): a single + # auth-gate + portal-fetch + render path (which also honors the dev fixture). + # Run off the event loop. The helper gates on "a Nous account is logged in" + # — NOT the inference provider and NOT nested under `if provider:` — so a + # Nous-credentialled user running inference elsewhere (or with none resident) + # still sees their balance. NO recovery trigger: messaging binds no notice + # consumer, so /usage only displays. Fail-open: never break /usage. + try: + from agent.account_usage import nous_credits_lines + + credits_lines = await asyncio.to_thread(nous_credits_lines, markdown=True) + except Exception: + credits_lines = [] # fail-open: never break /usage + if agent and hasattr(agent, "session_total_tokens") and agent.session_api_calls > 0: lines = [] @@ -14171,6 +14202,9 @@ class GatewayRunner: if account_lines: lines.append("") lines.extend(account_lines) + if credits_lines: + lines.append("") + lines.extend(credits_lines) return "\n".join(lines) @@ -14190,9 +14224,18 @@ class GatewayRunner: if account_lines: lines.append("") lines.extend(account_lines) + if credits_lines: + lines.append("") + lines.extend(credits_lines) return "\n".join(lines) - if account_lines: - return "\n".join(account_lines) + if account_lines or credits_lines: + # account-only, credits-only, or both — joined with a blank divider. + parts = list(account_lines) + if credits_lines: + if parts: + parts.append("") + parts.extend(credits_lines) + return "\n".join(parts) return t("gateway.usage.no_data") async def _handle_insights_command(self, event: MessageEvent) -> str: @@ -17930,6 +17973,38 @@ class GatewayRunner: agent.stream_delta_callback = _stream_delta_cb agent.interim_assistant_callback = _interim_assistant_cb if _want_interim_messages else None agent.status_callback = _status_callback_sync + + # Credits / out-of-band notices (usage bands, depletion, restored). + # Messaging has no persistent status bar, so each notice is a + # standalone push: render to a single plaintext line and deliver via + # the shared _deliver_platform_notice rail (honors private/public + + # thread metadata). Fires from the agent's sync worker thread, so we + # hop onto the gateway loop with safe_schedule_threadsafe — same + # pattern as _status_callback_sync. The fired-once latch lives on the + # cached agent and persists across turns, so a band crosses → one + # push (no per-turn re-nag). Recovery ("✓ Credit access restored") + # rides the same show path (it's emitted as a success notice, not a + # clear). The clear callback is a no-op: a sent platform message + # can't be cleanly retracted, and the band already fired once. + def _notice_callback_sync(notice) -> None: + if not _status_adapter or not _run_still_current(): + return + try: + line = render_notice_line(notice) + except Exception: + logger.debug("render_notice_line failed", exc_info=True) + return + if not line: + return + safe_schedule_threadsafe( + self._deliver_platform_notice(source, line), + _loop_for_step, + logger=logger, + log_message="notice_callback delivery scheduling error", + ) + + agent.notice_callback = _notice_callback_sync + agent.notice_clear_callback = None agent.reasoning_config = reasoning_config agent.service_tier = self._service_tier agent.request_overrides = turn_route.get("request_overrides") or {} diff --git a/hermes_cli/nous_account.py b/hermes_cli/nous_account.py index 20b851c4f7b..e28c04d4ae3 100644 --- a/hermes_cli/nous_account.py +++ b/hermes_cli/nous_account.py @@ -38,6 +38,7 @@ class NousPortalSubscriptionInfo: plan: Optional[str] = None tier: Optional[int] = None monthly_charge: Optional[float] = None + monthly_credits: Optional[float] = None current_period_end: Optional[str] = None credits_remaining: Optional[float] = None rollover_credits: Optional[float] = None @@ -662,6 +663,7 @@ def _subscription_from_payload(value: Any) -> Optional[NousPortalSubscriptionInf plan=_coerce_str(value.get("plan")), tier=_coerce_int(value.get("tier")), monthly_charge=_coerce_float(value.get("monthly_charge")), + monthly_credits=_coerce_float(value.get("monthly_credits")), current_period_end=_coerce_str(value.get("current_period_end")), credits_remaining=_coerce_float(value.get("credits_remaining")), rollover_credits=_coerce_float(value.get("rollover_credits")), diff --git a/run_agent.py b/run_agent.py index dadae7a5666..cff121333c5 100644 --- a/run_agent.py +++ b/run_agent.py @@ -195,7 +195,7 @@ from agent.tool_dispatch_helpers import ( _extract_error_preview, _trajectory_normalize_msg, # noqa: F401 # re-exported for tests that `from run_agent import _trajectory_normalize_msg` ) -from utils import atomic_json_write, base_url_host_matches, base_url_hostname +from utils import atomic_json_write, base_url_host_matches, base_url_hostname, is_truthy_value @@ -379,6 +379,8 @@ class AIAgent: interim_assistant_callback: callable = None, tool_gen_callback: callable = None, status_callback: callable = None, + notice_callback: callable = None, + notice_clear_callback: callable = None, max_tokens: int = None, reasoning_config: Dict[str, Any] = None, service_tier: str = None, @@ -449,6 +451,8 @@ class AIAgent: interim_assistant_callback=interim_assistant_callback, tool_gen_callback=tool_gen_callback, status_callback=status_callback, + notice_callback=notice_callback, + notice_clear_callback=notice_clear_callback, max_tokens=max_tokens, reasoning_config=reasoning_config, service_tier=service_tier, @@ -795,6 +799,27 @@ class AIAgent: except Exception: logger.debug("status_callback error in _emit_warning", exc_info=True) + def _emit_notice(self, notice) -> None: + """Fire a structured ``AgentNotice`` to the active driver (TUI / CLI). + + Driver-agnostic: the bound ``notice_callback`` renders it however that + driver does (TUI status-bar override, CLI console line). Swallows all + callback errors — a notice must NEVER break the agent loop (D-D fail-open). + """ + if self.notice_callback: + try: + self.notice_callback(notice) + except Exception: + logger.debug("notice_callback error in _emit_notice", exc_info=True) + + def _emit_notice_clear(self, key: str) -> None: + """Clear a previously-fired sticky notice by ``key`` (e.g. on recovery).""" + if self.notice_clear_callback: + try: + self.notice_clear_callback(key) + except Exception: + logger.debug("notice_clear_callback error in _emit_notice_clear", exc_info=True) + # ── Buffered retry/fallback status ──────────────────────────────────── # Retry and fallback chains were flooding the CLI/gateway with status # noise that users found confusing: a single transient 429 could produce @@ -2693,6 +2718,125 @@ class AIAgent: """Return the last captured RateLimitState, or None.""" return self._rate_limit_state + def _capture_credits(self, http_response: Any) -> None: + """Parse x-nous-credits-* headers, cache CreditsState, fire threshold notices. + + Fail-open throughout — header issues never break the agent loop. The PARSE is + swallowed (any error → treated as a miss → keep last-known). The notice + EVALUATION/EMIT is a SEPARATE block that WARNS on failure (R1-M2): a bug in the + depletion-notice path must not vanish silently under the parse swallow. + """ + # Dev test fixture (HERMES_DEV_CREDITS_FIXTURE): inject a chosen notice state + # each turn for repeatable testing, bypassing real headers. Throwaway scaffolding. + try: + from agent.credits_tracker import dev_fixture_credits_state + _fixture = dev_fixture_credits_state() + except Exception: + _fixture = None + if _fixture is not None: + self._credits_state = _fixture + if self._credits_session_start_micros is None: + self._credits_session_start_micros = _fixture.remaining_micros + _latch = getattr(self, "_credits_latch", None) + if isinstance(_latch, dict): + _latch["seen_below_90"] = True # let warn90 fire without a real crossing + _used = _fixture.used_fraction + logger.info( + "credits ▸ [FIXTURE] remaining=%d (%s) · paid=%s · denom=%s · used=%s " + "(real headers bypassed — `echo clear` / unset HERMES_DEV_CREDITS_FIXTURE to restore)", + _fixture.remaining_micros, + _fixture.remaining_usd or "?", + _fixture.paid_access, + _fixture.denominator_kind, + ("%.0f%%" % (_used * 100)) if _used is not None else "n/a", + ) + self._emit_credits_notices() + return + if http_response is None: + return + headers = getattr(http_response, "headers", None) + if not headers: + return + _dev = is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")) + + # ── Parse (fail-open → miss; never overwrite good state with None) ── + try: + from agent.credits_tracker import parse_credits_headers + state = parse_credits_headers(headers, provider=self.provider) + except Exception: + return # parse error → treat as a miss, keep last-known + if state is None: + if _dev: + logger.info( + "credits ▸ response had no valid x-nous-credits-* headers " + "(miss — producer off / non-Nous path / >TTL stale)" + ) + return + + # retain-last-known: only overwrite on a fresh valid parse + self._credits_state = state + # Latch session-start remaining the first time we ever see a header + if self._credits_session_start_micros is None: + self._credits_session_start_micros = state.remaining_micros + if _dev: + # HERMES_DEV_CREDITS: stream each capture to agent.log — watch live with + # `hermes logs -f` (grep 'credits ▸'). Dev-only; silent for normal users. + spent = self.get_credits_spent_micros() + used = state.used_fraction + logger.info( + "credits ▸ remaining=%d (%s) · paid=%s · denom=%s · used=%s " + "· Δspent=%s · age=%s%s", + state.remaining_micros, + state.remaining_usd or "?", + state.paid_access, + state.denominator_kind, + ("%.0f%%" % (used * 100)) if used is not None else "n/a", + ("%.1f¢" % (spent / 10000)) if spent is not None else "n/a", + ("%.0fs" % state.age_seconds) if state.age_seconds != float("inf") else "n/a", + (" · disabled=%s" % state.disabled_reason) if state.disabled_reason else "", + ) + + # Threshold notices — shared with the cold-start seed (see _emit_credits_notices). + self._emit_credits_notices() + + def _emit_credits_notices(self) -> None: + """Run the threshold policy on the current credits state and emit notices. + + Shared by the warm path (_capture_credits) and the L3 cold-start seed, so a + session that opens already depleted warns immediately — not only after the first + inference header. Runs only when a notice consumer is bound (messaging binds none + → state still cached for /usage, no policy). WARNS on failure rather than + swallowing (R1-M2): a depletion-path bug must not vanish silently. Emits clears + FIRST, then shows (so depleted lands last in a latest-wins slot). + """ + if getattr(self, "notice_callback", None) is None and getattr(self, "notice_clear_callback", None) is None: + return + state = getattr(self, "_credits_state", None) + if state is None: + return + try: + from agent.credits_tracker import evaluate_credits_notices + latch = getattr(self, "_credits_latch", None) + if latch is None: + latch = self._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None} + to_show, to_clear = evaluate_credits_notices(state, latch) + for key in to_clear: # clears FIRST … + self._emit_notice_clear(key) + for notice in to_show: # … then shows (depleted lands last in a latest-wins slot) + self._emit_notice(notice) + except Exception: + logger.warning("credits notice evaluation/emit failed", exc_info=True) + + def get_credits_state(self): + """Return the last captured CreditsState, or None.""" + return self._credits_state + + def get_credits_spent_micros(self): + """Session-cumulative micros spent = first_seen_remaining - current_remaining. None if no data.""" + if self._credits_session_start_micros is None or self._credits_state is None: + return None + return self._credits_session_start_micros - self._credits_state.remaining_micros + def _check_openrouter_cache_status(self, http_response: Any) -> None: """Read X-OpenRouter-Cache-Status from response headers and log it. diff --git a/tests/agent/test_credits_cold_start.py b/tests/agent/test_credits_cold_start.py new file mode 100644 index 00000000000..d48b6f972c0 --- /dev/null +++ b/tests/agent/test_credits_cold_start.py @@ -0,0 +1,192 @@ +"""Tests for cold-start credits hydration at session open. + +The L3 cold-start seed primes agent._credits_state from /api/oauth/account (or a +HERMES_DEV_CREDITS_FIXTURE) so depletion AND the 90% grant warning fire immediately +at session open, not only after the first inference header. These tests assert the +notice policy fires correctly for a seed-shaped CreditsState with the warn90 latch +primed the way conversation_loop does it. +""" +import time + +from agent.credits_tracker import CreditsState, evaluate_credits_notices + + +def _cold_start_notices(state: CreditsState): + """Mirror the conversation_loop seed: prime seen_below_90 when used_fraction is + computable (the snapshot IS the first observation), then evaluate once.""" + latch = {"active": set(), "seen_below_90": False} + if state.used_fraction is not None: + latch["seen_below_90"] = True + show, clear = evaluate_credits_notices(state, latch) + return [n.key for n in show] + + +def _state(**kw) -> CreditsState: + kw.setdefault("from_header", False) + kw.setdefault("captured_at", time.time()) + return CreditsState(**kw) + + +def test_cold_start_healthy_no_notice(): + s = _state( + remaining_micros=30_340_000, subscription_micros=18_000_000, + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + denominator_kind="subscription_cap", paid_access=True, + ) + assert abs(s.used_fraction - 0.1) < 1e-9 + assert _cold_start_notices(s) == [] + + +def test_cold_start_opens_already_at_90pct_warns(): + """A session that OPENS already ≥90% must warn immediately — the seed primes + seen_below_90 so warn90 fires without a prior live crossing.""" + s = _state( + remaining_micros=2_000_000, subscription_micros=2_000_000, + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + denominator_kind="subscription_cap", paid_access=True, + ) + assert s.used_fraction == 0.9 + assert "credits.usage" in _cold_start_notices(s) + + +def test_cold_start_grant_exhausted_warns_and_grant_spent(): + s = _state( + remaining_micros=12_340_000, subscription_micros=0, + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + purchased_micros=12_340_000, denominator_kind="subscription_cap", paid_access=True, + ) + assert s.used_fraction == 1.0 + keys = _cold_start_notices(s) + assert "credits.usage" in keys + assert "credits.grant_spent" in keys + + +def test_cold_start_depleted_warns(): + s = _state( + remaining_micros=0, subscription_micros=0, purchased_micros=0, + paid_access=False, disabled_reason="out_of_credits", + ) + assert s.used_fraction is None # no cap → no %, depletion keys off paid_access + assert _cold_start_notices(s) == ["credits.depleted"] + + +def test_cold_start_debt_warns_and_depleted(): + """Negative subscription balance (the only signed field) → 100% used + depleted.""" + s = _state( + remaining_micros=0, subscription_micros=-5_000_000, + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + denominator_kind="subscription_cap", paid_access=False, + disabled_reason="out_of_credits", + ) + assert s.used_fraction == 1.0 + keys = _cold_start_notices(s) + assert "credits.usage" in keys + assert "credits.depleted" in keys + + +def test_cold_start_no_cap_degrades_to_depletion_only(): + """Without monthly_credits (older portals) the seed sets no limit → used_fraction + None → only depletion can fire, never warn90.""" + healthy_no_cap = _state( + remaining_micros=30_000_000, subscription_micros=18_000_000, + subscription_limit_micros=None, denominator_kind="none", paid_access=True, + ) + assert healthy_no_cap.used_fraction is None + assert _cold_start_notices(healthy_no_cap) == [] + + +def test_dev_fixtures_drive_cold_start(): + """Every HERMES_DEV_CREDITS_FIXTURE state produces a valid seed CreditsState.""" + import os + + from agent.credits_tracker import dev_fixture_credits_state + + expected = { + "healthy": [], + "sub_90pct": ["credits.usage"], + "depleted": ["credits.depleted"], + } + for name, want in expected.items(): + os.environ["HERMES_DEV_CREDITS"] = "1" # fixtures gate on the dev flag + os.environ["HERMES_DEV_CREDITS_FIXTURE"] = name + try: + fx = dev_fixture_credits_state() + assert fx is not None, name + assert _cold_start_notices(fx) == want, (name, _cold_start_notices(fx)) + finally: + os.environ.pop("HERMES_DEV_CREDITS_FIXTURE", None) + os.environ.pop("HERMES_DEV_CREDITS", None) + + +# ── seed_credits_at_session_start: the shared session-open hydrator ─────────── + + +class _FakeAgent: + """Minimal agent surface for the seed helper: state slots + an emit that runs + the real policy against the latch.""" + + def __init__(self, provider="nous"): + from agent.credits_tracker import evaluate_credits_notices + + self.provider = provider + self._credits_state = None + self._credits_session_start_micros = None + self._credits_latch = {"active": set(), "seen_below_90": False, "usage_band": None} + self.emitted: list = [] + self._eval = evaluate_credits_notices + + def _emit_credits_notices(self): + if self._credits_state is None: + return + show, clear = self._eval(self._credits_state, self._credits_latch) + self.emitted.append(([n.key for n in show], clear)) + + +def _seed(agent, fixture): + import os + + from agent.credits_tracker import seed_credits_at_session_start + + os.environ["HERMES_DEV_CREDITS"] = "1" # fixtures gate on the dev flag + os.environ["HERMES_DEV_CREDITS_FIXTURE"] = fixture + try: + return seed_credits_at_session_start(agent) + finally: + os.environ.pop("HERMES_DEV_CREDITS_FIXTURE", None) + os.environ.pop("HERMES_DEV_CREDITS", None) + + +def test_seed_fires_usage_band_at_session_open(): + a = _FakeAgent() + assert _seed(a, "sub_90pct") is True + assert a._credits_state is not None + assert a.emitted == [(["credits.usage"], [])] + + +def test_seed_fires_depleted_at_session_open(): + a = _FakeAgent() + assert _seed(a, "depleted") is True + assert a.emitted == [(["credits.depleted"], [])] + + +def test_seed_healthy_no_notice(): + a = _FakeAgent() + assert _seed(a, "healthy") is True + assert a.emitted == [([], [])] + + +def test_seed_is_idempotent(): + a = _FakeAgent() + _seed(a, "sub_90pct") + a.emitted = [] + # second call must no-op (state already populated) + assert _seed(a, "sub_90pct") is False + assert a.emitted == [] + + +def test_seed_skips_non_nous(): + from agent.credits_tracker import seed_credits_at_session_start + + a = _FakeAgent(provider="openrouter") + assert seed_credits_at_session_start(a) is False + assert a._credits_state is None diff --git a/tests/agent/test_credits_fixture_snapshot.py b/tests/agent/test_credits_fixture_snapshot.py new file mode 100644 index 00000000000..a71532a095a --- /dev/null +++ b/tests/agent/test_credits_fixture_snapshot.py @@ -0,0 +1,67 @@ +"""Tests for _snapshot_from_credits_state — the dev-fixture /usage renderer. + +``build_nous_credits_snapshot`` maps a live portal account; ``_snapshot_from_credits_state`` +maps a header-shaped CreditsState (e.g. a HERMES_DEV_CREDITS_FIXTURE) into the SAME +/usage snapshot shape, so the gauge + magnitudes are exercisable offline. These lock +the gauge math, the verbatim *_usd magnitudes (never parseFloat'd), the depletion line, +and the dev-fixture marker. +""" +from __future__ import annotations + +from agent.account_usage import _snapshot_from_credits_state +from agent.credits_tracker import CreditsState + + +def _state(**kw) -> CreditsState: + kw.setdefault("from_header", True) + return CreditsState(**kw) + + +def test_renders_gauge_magnitudes_and_fixture_marker(): + # used_fraction = (20 - 10) / 20 = 0.5 → a 50%-used gauge window + snap = _snapshot_from_credits_state(_state( + remaining_micros=30_340_000, remaining_usd="30.34", + subscription_micros=10_000_000, subscription_usd="10.00", + subscription_limit_micros=20_000_000, subscription_limit_usd="20.00", + purchased_micros=12_340_000, purchased_usd="12.34", + denominator_kind="subscription_cap", paid_access=True, + )) + assert snap is not None and snap.provider == "nous" + + win = next(w for w in snap.windows if w.label == "Subscription") + assert win.used_percent is not None and abs(win.used_percent - 50.0) < 1e-9 + assert win.detail == "$10.00 of $20.00 left" # verbatim *_usd strings, not math + + details = list(snap.details) + assert "Subscription credits: $10.00" in details + assert "Top-up credits: $12.34" in details + assert "Total usable: $30.34" in details + assert any("dev fixture" in d for d in details) # the offline marker + assert all("access depleted" not in d for d in details) + + +def test_depleted_adds_status_line(): + snap = _snapshot_from_credits_state(_state( + remaining_micros=0, remaining_usd="0.00", + subscription_micros=0, subscription_usd="0.00", + purchased_micros=0, purchased_usd="0.00", + denominator_kind="none", paid_access=False, + )) + assert snap is not None + assert any("access depleted" in d for d in snap.details) + + +def test_no_cap_yields_no_gauge_window(): + # No subscription cap → used_fraction is None → no gauge window, magnitudes only. + snap = _snapshot_from_credits_state(_state( + remaining_micros=5_000_000, remaining_usd="5.00", + subscription_micros=5_000_000, subscription_usd="5.00", + subscription_limit_micros=None, denominator_kind="none", paid_access=True, + )) + assert snap is not None + assert all(w.label != "Subscription" for w in snap.windows) + assert "Total usable: $5.00" in snap.details + + +def test_none_state_is_safe(): + assert _snapshot_from_credits_state(None) is None diff --git a/tests/agent/test_credits_policy.py b/tests/agent/test_credits_policy.py new file mode 100644 index 00000000000..a9686e0450c --- /dev/null +++ b/tests/agent/test_credits_policy.py @@ -0,0 +1,492 @@ +"""Tests for evaluate_credits_notices — pure threshold reconciliation policy (L4.1). + +All tests use fresh latch = {"active": set(), "seen_below_90": False, "usage_band": None} per scenario. +CreditsState is constructed directly (not parsed from headers). +""" + +from __future__ import annotations + +import pytest + +from agent.credits_tracker import ( + CREDITS_NOTICE_KIND, + CREDITS_RESTORED_TTL_MS, + AgentNotice, + CreditsState, + evaluate_credits_notices, +) + + +# ── Helpers ────────────────────────────────────────────────────────────────── + + +def fresh_latch() -> dict: + return {"active": set(), "seen_below_90": False, "usage_band": None} + + +def state_with_fraction( + uf: float | None, + *, + paid_access: bool = True, + denominator_kind: str = "subscription_cap", + purchased_micros: int = 0, + purchased_usd: str = "0.00", + subscription_limit_usd: str | None = "20.00", +) -> CreditsState: + """Build a minimal CreditsState that yields the desired used_fraction. + + used_fraction = (limit - subscription_micros) / limit + + When uf is None, we set limit to None so used_fraction returns None. + """ + if uf is None: + return CreditsState( + subscription_limit_micros=None, + subscription_limit_usd=None, + subscription_micros=0, + denominator_kind="none", + paid_access=paid_access, + purchased_micros=purchased_micros, + purchased_usd=purchased_usd, + ) + # We want (limit - sub) / limit == uf → sub = limit * (1 - uf) + limit = 20_000_000 # $20 in micros + sub = int(limit * (1.0 - uf)) + return CreditsState( + subscription_limit_micros=limit, + subscription_limit_usd=subscription_limit_usd, + subscription_micros=sub, + denominator_kind=denominator_kind, + paid_access=paid_access, + purchased_micros=purchased_micros, + purchased_usd=purchased_usd, + ) + + +# ── Scenario 1: crossing 90% threshold ─────────────────────────────────────── + + +class TestWarn90Crossing: + def test_below_lowest_band_no_notice_but_latch_set(self): + latch = fresh_latch() + s = state_with_fraction(0.10) # below the 50% band + to_show, to_clear = evaluate_credits_notices(s, latch) + assert all(n.key != "credits.usage" for n in to_show) + assert "credits.usage" not in to_clear + assert latch["seen_below_90"] is True + + def test_crossing_to_90_fires_once(self): + latch = fresh_latch() + # First call: uf < 0.5 — sets seen_below_90 (below lowest band) + s1 = state_with_fraction(0.10) + evaluate_credits_notices(s1, latch) + # Second call: uf >= 0.9 — should fire the usage band at 90 + s2 = state_with_fraction(0.95) + to_show, to_clear = evaluate_credits_notices(s2, latch) + keys = [n.key for n in to_show] + assert "credits.usage" in keys + assert "credits.usage" not in to_clear + + def test_no_refire_on_repeated_over_90(self): + latch = fresh_latch() + s_below = state_with_fraction(0.10) + evaluate_credits_notices(s_below, latch) + s_over = state_with_fraction(0.95) + evaluate_credits_notices(s_over, latch) + # Third call: still ≥ 0.9 — must NOT re-fire + to_show, to_clear = evaluate_credits_notices(s_over, latch) + assert all(n.key != "credits.usage" for n in to_show) + assert "credits.usage" not in to_clear + + +# ── Scenario 2: recovery + re-cross ────────────────────────────────────────── + + +class TestWarn90RecoveryReCross: + def test_recovery_clears_warn90(self): + latch = fresh_latch() + # Cross below → above + evaluate_credits_notices(state_with_fraction(0.10), latch) + evaluate_credits_notices(state_with_fraction(0.95), latch) + # Recovery: uf drops back below ALL bands → usage notice clears entirely + to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.10), latch) + assert "credits.usage" in to_clear + assert "credits.usage" not in latch["active"] + + def test_recross_after_recovery_fires_again(self): + latch = fresh_latch() + evaluate_credits_notices(state_with_fraction(0.10), latch) + evaluate_credits_notices(state_with_fraction(0.95), latch) + evaluate_credits_notices(state_with_fraction(0.10), latch) # recovery + # Re-cross: uf >= 0.9 again — should fire again because the band is clearable + to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.95), latch) + keys = [n.key for n in to_show] + assert "credits.usage" in keys + + +# ── Scenario 3: open-already-over (hybrid Q3 gate) ─────────────────────────── + + +class TestOpenAlreadyOver: + def test_warn90_does_not_fire_without_seen_below_90(self): + """First call uf≥0.9 with seen_below_90=False — warn90 must NOT fire.""" + latch = fresh_latch() + assert latch["seen_below_90"] is False + s = state_with_fraction(0.95) + to_show, to_clear = evaluate_credits_notices(s, latch) + assert all(n.key != "credits.usage" for n in to_show) + assert "credits.usage" not in to_clear + + +# ── Scenario 3b: boundary — exact 0.9 and just-below-1.0 ──────────────────── + + +class TestBoundaryFractions: + def test_exact_0_9_fires_warn90(self): + """used_fraction == 0.9 exactly must fire warn90 (threshold is inclusive).""" + latch = fresh_latch() + # First: prime seen_below_90 with a sub-50% observation + evaluate_credits_notices(state_with_fraction(0.10), latch) + # Now construct a state where used_fraction is EXACTLY 0.9: + # subscription_limit_micros=20_000_000, subscription_micros=2_000_000 + # → used = 18_000_000 / 20_000_000 = 0.9 exactly + s = CreditsState( + subscription_limit_micros=20_000_000, + subscription_limit_usd="20.00", + subscription_micros=2_000_000, + denominator_kind="subscription_cap", + paid_access=True, + ) + assert s.used_fraction == 0.9 + to_show, to_clear = evaluate_credits_notices(s, latch) + keys = [n.key for n in to_show] + assert "credits.usage" in keys + assert "credits.usage" not in to_clear + + def test_just_below_1_0_does_not_fire_grant_spent(self): + """subscription_micros = limit - 1 (used_fraction just under 1.0) must NOT fire grant_spent. + + Locks the boundary so a future used_fraction clamp refactor cannot fire + grant_spent a micro early. + """ + latch = fresh_latch() + limit = 20_000_000 + s = CreditsState( + subscription_limit_micros=limit, + subscription_limit_usd="20.00", + subscription_micros=1, # limit - 1 → used_fraction < 1.0 + denominator_kind="subscription_cap", + purchased_micros=5_000_000, + purchased_usd="5.00", + paid_access=True, + ) + assert s.used_fraction is not None and s.used_fraction < 1.0 + to_show, to_clear = evaluate_credits_notices(s, latch) + assert all(n.key != "credits.grant_spent" for n in to_show) + assert "credits.grant_spent" not in to_clear + + +# ── Scenario 4: grant_spent ─────────────────────────────────────────────────── + + +class TestGrantSpent: + def _grant_state(self, purchased_micros: int = 12_340_000) -> CreditsState: + return state_with_fraction( + 1.0, + denominator_kind="subscription_cap", + purchased_micros=purchased_micros, + purchased_usd="12.34", + ) + + def test_grant_spent_fires_on_first_obs(self): + """No crossing gate for grant_spent — fires immediately on first obs.""" + latch = fresh_latch() + to_show, to_clear = evaluate_credits_notices(self._grant_state(), latch) + keys = [n.key for n in to_show] + assert "credits.grant_spent" in keys + + def test_grant_spent_no_refire(self): + latch = fresh_latch() + evaluate_credits_notices(self._grant_state(), latch) + to_show, to_clear = evaluate_credits_notices(self._grant_state(), latch) + assert all(n.key != "credits.grant_spent" for n in to_show) + assert "credits.grant_spent" not in to_clear + + def test_grant_spent_clears_when_purchased_zero(self): + latch = fresh_latch() + evaluate_credits_notices(self._grant_state(), latch) + # Now purchased → 0: grant_cond becomes False + s_no_purchase = state_with_fraction( + 1.0, + denominator_kind="subscription_cap", + purchased_micros=0, + purchased_usd="0.00", + ) + to_show, to_clear = evaluate_credits_notices(s_no_purchase, latch) + assert "credits.grant_spent" in to_clear + assert all(n.key != "credits.grant_spent" for n in to_show) + + +# ── Scenario 5: depleted + recovery ────────────────────────────────────────── + + +class TestDepleted: + def test_depleted_fires_level_error_kind_sticky(self): + latch = fresh_latch() + s = CreditsState(paid_access=False) + to_show, to_clear = evaluate_credits_notices(s, latch) + depleted_notices = [n for n in to_show if n.key == "credits.depleted"] + assert len(depleted_notices) == 1 + n = depleted_notices[0] + assert n.level == "error" + assert n.kind == CREDITS_NOTICE_KIND + + def test_recovery_emits_clear_and_restored(self): + latch = fresh_latch() + # Fire depleted + evaluate_credits_notices(CreditsState(paid_access=False), latch) + # Now recovered + to_show, to_clear = evaluate_credits_notices(CreditsState(paid_access=True), latch) + assert "credits.depleted" in to_clear + restored = [n for n in to_show if n.key == "credits.restored"] + assert len(restored) == 1 + r = restored[0] + assert r.level == "success" + assert r.kind == "ttl" + assert r.ttl_ms == CREDITS_RESTORED_TTL_MS + + def test_depleted_refires_after_recovery(self): + latch = fresh_latch() + evaluate_credits_notices(CreditsState(paid_access=False), latch) + evaluate_credits_notices(CreditsState(paid_access=True), latch) + # Goes depleted again + to_show, to_clear = evaluate_credits_notices(CreditsState(paid_access=False), latch) + keys = [n.key for n in to_show] + assert "credits.depleted" in keys + + +# ── Scenario 6: denominator none (uf is None) ──────────────────────────────── + + +class TestDenominatorNone: + def test_no_warn90_when_uf_none(self): + latch = fresh_latch() + s = state_with_fraction(None) + to_show, to_clear = evaluate_credits_notices(s, latch) + assert all(n.key != "credits.usage" for n in to_show) + assert "credits.usage" not in to_clear + + def test_no_grant_spent_when_uf_none(self): + latch = fresh_latch() + s = CreditsState( + subscription_limit_micros=None, + denominator_kind="none", + purchased_micros=5_000_000, + purchased_usd="5.00", + ) + to_show, to_clear = evaluate_credits_notices(s, latch) + assert all(n.key != "credits.grant_spent" for n in to_show) + + def test_warn90_clears_when_uf_becomes_none(self): + """If warn90 was active and uf becomes None, it should clear.""" + latch = fresh_latch() + # Establish usage notice active: cross below → above + evaluate_credits_notices(state_with_fraction(0.10), latch) + evaluate_credits_notices(state_with_fraction(0.95), latch) + assert "credits.usage" in latch["active"] + # Now uf becomes None (denominator changed to "none") + s_none = state_with_fraction(None) + to_show, to_clear = evaluate_credits_notices(s_none, latch) + assert "credits.usage" in to_clear + assert "credits.usage" not in latch["active"] + + +# ── Scenario 7: copy / verbatim USD strings ────────────────────────────────── + + +class TestNoticeCopy: + def test_warn90_contains_verbatim_subscription_limit_usd(self): + latch = fresh_latch() + evaluate_credits_notices(state_with_fraction(0.10), latch) + s = state_with_fraction(0.95, subscription_limit_usd="20.00") + to_show, _ = evaluate_credits_notices(s, latch) + warn_notice = next(n for n in to_show if n.key == "credits.usage") + assert "$20.00" in warn_notice.text + assert "cap" in warn_notice.text + + def test_grant_spent_contains_verbatim_purchased_usd(self): + latch = fresh_latch() + s = state_with_fraction( + 1.0, + denominator_kind="subscription_cap", + purchased_micros=12_340_000, + purchased_usd="12.34", + ) + to_show, _ = evaluate_credits_notices(s, latch) + grant_notice = next(n for n in to_show if n.key == "credits.grant_spent") + assert "$12.34" in grant_notice.text + assert "top-up left" in grant_notice.text + + def test_depleted_mentions_usage_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 + + +# ── Scenario 8: severity order in a single call ────────────────────────────── + + +class TestSeverityOrder: + def test_multiple_new_notices_ordered_ascending_severity(self): + """warn90 < grant_spent < depleted in to_show when all fire in one call.""" + # Construct a state where all three conditions fire simultaneously + # on first call (no latch state yet): + # - warn90: uf >= 0.9 AND seen_below_90 must be True → won't fire fresh latch + # So we pre-seed seen_below_90=True to allow warn90 to fire. + latch = {"active": set(), "seen_below_90": True, "usage_band": None} + + # Build state: subscription_cap, uf >= 1.0, purchased_micros > 0, NOT paid_access + # warn90_cond: uf >= 0.9 ✓ (uf=1.0) + # grant_cond: subscription_cap + uf >= 1.0 + purchased > 0 ✓ + # depleted_cond: not paid_access ✓ + s = CreditsState( + subscription_limit_micros=20_000_000, + subscription_limit_usd="20.00", + subscription_micros=0, # uf = 1.0 + denominator_kind="subscription_cap", + purchased_micros=5_000_000, + purchased_usd="5.00", + paid_access=False, + ) + to_show, _ = evaluate_credits_notices(s, latch) + keys = [n.key for n in to_show] + assert "credits.usage" in keys + assert "credits.grant_spent" in keys + assert "credits.depleted" in keys + # Ascending severity: warn90 before grant_spent before depleted + assert keys.index("credits.usage") < keys.index("credits.grant_spent") + assert keys.index("credits.grant_spent") < keys.index("credits.depleted") + + +# ── Invariant: never fire + clear same key in one call ──────────────────────── + + +class TestNoFireAndClearSameKey: + def test_usage_never_both_fired_and_cleared(self): + latch = fresh_latch() + # Run many state transitions; across each, assert no key is in both lists + states = [ + state_with_fraction(0.10), + state_with_fraction(0.95), + state_with_fraction(0.10), + state_with_fraction(0.95), + state_with_fraction(None), + ] + for s in states: + to_show, to_clear = evaluate_credits_notices(s, latch) + fired_keys = {n.key for n in to_show} + cleared_keys = set(to_clear) + overlap = fired_keys & cleared_keys + assert not overlap, f"Key(s) both fired and cleared: {overlap}" + + def test_depleted_never_both_fired_and_cleared(self): + latch = fresh_latch() + states = [ + CreditsState(paid_access=False), + CreditsState(paid_access=True), + CreditsState(paid_access=False), + ] + for s in states: + to_show, to_clear = evaluate_credits_notices(s, latch) + fired_keys = {n.key for n in to_show} + cleared_keys = set(to_clear) + overlap = fired_keys & cleared_keys + assert not overlap, f"Key(s) both fired and cleared: {overlap}" + + +# ── Scenario 9: escalating usage bands (50 → 75 → 90) ──────────────────────── + + +class TestUsageBands: + """The usage notice shows the HIGHEST crossed band as a single escalating line.""" + + def _band_text(self, to_show): + n = next((n for n in to_show if n.key == "credits.usage"), None) + return n.text if n else None + + def test_50_band_fires_info(self): + latch = fresh_latch() + evaluate_credits_notices(state_with_fraction(0.10), latch) # prime + to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch) + n = next(n for n in to_show if n.key == "credits.usage") + assert "50%" in n.text and n.level == "info" + assert latch["usage_band"] == 50 + + def test_75_band_fires_warn(self): + latch = fresh_latch() + evaluate_credits_notices(state_with_fraction(0.10), latch) + to_show, _ = evaluate_credits_notices(state_with_fraction(0.80), latch) + n = next(n for n in to_show if n.key == "credits.usage") + assert "75%" in n.text and n.level == "warn" + assert latch["usage_band"] == 75 + + def test_climb_replaces_band(self): + """Climbing 50→75→90 replaces the single line (clear old + show new).""" + latch = fresh_latch() + evaluate_credits_notices(state_with_fraction(0.10), latch) + # 55% → 50 band + evaluate_credits_notices(state_with_fraction(0.55), latch) + assert latch["usage_band"] == 50 + # 80% → climbs to 75, clearing the 50 line + to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch) + assert "credits.usage" in to_clear + assert "75%" in self._band_text(to_show) + assert latch["usage_band"] == 75 + # 95% → climbs to 90 + to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.95), latch) + assert "credits.usage" in to_clear + assert "90%" in self._band_text(to_show) + assert latch["usage_band"] == 90 + + def test_step_down_on_recovery(self): + """Recovering steps the band back down, then clears below the lowest band.""" + latch = fresh_latch() + evaluate_credits_notices(state_with_fraction(0.10), latch) + evaluate_credits_notices(state_with_fraction(0.95), latch) + assert latch["usage_band"] == 90 + # drop to 80% → steps down to 75 + to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch) + assert "credits.usage" in to_clear + assert "75%" in self._band_text(to_show) + # drop to 55% → steps down to 50 + to_show, _ = evaluate_credits_notices(state_with_fraction(0.55), latch) + assert "50%" in self._band_text(to_show) + # drop below 50% → clears entirely + to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.10), latch) + assert "credits.usage" in to_clear + assert latch["usage_band"] is None + + def test_no_refire_same_band(self): + latch = fresh_latch() + evaluate_credits_notices(state_with_fraction(0.10), latch) + evaluate_credits_notices(state_with_fraction(0.80), latch) # fires 75 + # still 80% → same band, no re-emit, no clear + to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.80), latch) + assert all(n.key != "credits.usage" for n in to_show) + assert "credits.usage" not in to_clear + + def test_exact_band_boundaries_inclusive(self): + """Thresholds are inclusive: exactly 0.50 / 0.75 / 0.90 land in their band.""" + for uf, want in [(0.50, 50), (0.75, 75), (0.90, 90)]: + latch = fresh_latch() + latch["seen_below_90"] = True # allow firing + evaluate_credits_notices(state_with_fraction(uf), latch) + assert latch["usage_band"] == want, (uf, latch["usage_band"]) + + def test_open_below_lowest_band_no_notice(self): + latch = fresh_latch() + to_show, to_clear = evaluate_credits_notices(state_with_fraction(0.30), latch) + assert all(n.key != "credits.usage" for n in to_show) + assert latch["usage_band"] is None diff --git a/tests/agent/test_credits_tracker.py b/tests/agent/test_credits_tracker.py new file mode 100644 index 00000000000..c658e5b3cbf --- /dev/null +++ b/tests/agent/test_credits_tracker.py @@ -0,0 +1,909 @@ +"""Tests for agent.credits_tracker — CreditsState + parse_credits_headers. + +Covers the 9-state matrix plus validation edge cases. All header values +arrive as STRINGS (the producer calls String(...) on every field). +""" + +from __future__ import annotations + +import logging +import time +from typing import Optional +import pytest + +from agent.credits_tracker import CreditsState, parse_credits_headers + + +# ── Helpers ───────────────────────────────────────────────────────────────── + + +def micros(dollars: float) -> str: + """Convert a dollar amount to a micros string for header fixtures.""" + return str(round(dollars * 1_000_000)) + + +# ── 9-State matrix fixtures ────────────────────────────────────────────────── + + +def _base_headers(**overrides) -> dict: + """Base headers present in every valid response.""" + h = { + "x-nous-credits-version": "1", + "x-nous-credits-remaining-micros": micros(0), + "x-nous-credits-remaining-usd": "0.00", + "x-nous-credits-subscription-micros": micros(0), + "x-nous-credits-subscription-usd": "0.00", + "x-nous-credits-rollover-micros": micros(0), + "x-nous-credits-purchased-micros": micros(0), + "x-nous-credits-purchased-usd": "0.00", + "x-nous-tool-pool-micros": micros(0), + "x-nous-tool-pool-gated-off": "false", + "x-nous-credits-denominator-kind": "none", + "x-nous-credits-paid-access": "true", + "x-nous-credits-as-of-ms": "1717000000000", + } + h.update(overrides) + return h + + +# ── 9 STATES ──────────────────────────────────────────────────────────────── + + +HEALTHY_HEADERS = _base_headers( + **{ + "x-nous-credits-remaining-micros": micros(30.34), + "x-nous-credits-remaining-usd": "30.34", + "x-nous-credits-subscription-micros": micros(18.00), + "x-nous-credits-subscription-usd": "18.00", + "x-nous-credits-subscription-limit-micros": micros(20.00), + "x-nous-credits-subscription-limit-usd": "20.00", + "x-nous-credits-rollover-micros": micros(0), + "x-nous-credits-purchased-micros": micros(12.34), + "x-nous-credits-purchased-usd": "12.34", + "x-nous-tool-pool-micros": micros(2.00), + "x-nous-tool-pool-gated-off": "true", + "x-nous-credits-denominator-kind": "subscription_cap", + "x-nous-credits-paid-access": "true", + } +) + +SUB_90PCT_HEADERS = _base_headers( + **{ + "x-nous-credits-remaining-micros": micros(2.00), + "x-nous-credits-remaining-usd": "2.00", + "x-nous-credits-subscription-micros": micros(2.00), + "x-nous-credits-subscription-usd": "2.00", + "x-nous-credits-subscription-limit-micros": micros(20.00), + "x-nous-credits-subscription-limit-usd": "20.00", + "x-nous-credits-purchased-micros": micros(0), + "x-nous-credits-purchased-usd": "0.00", + "x-nous-credits-denominator-kind": "subscription_cap", + "x-nous-credits-paid-access": "true", + } +) + +GRANT_EXHAUSTED_HEADERS = _base_headers( + **{ + "x-nous-credits-remaining-micros": micros(12.34), + "x-nous-credits-remaining-usd": "12.34", + "x-nous-credits-subscription-micros": micros(0), + "x-nous-credits-subscription-usd": "0.00", + "x-nous-credits-subscription-limit-micros": micros(20.00), + "x-nous-credits-subscription-limit-usd": "20.00", + "x-nous-credits-purchased-micros": micros(12.34), + "x-nous-credits-purchased-usd": "12.34", + "x-nous-credits-denominator-kind": "subscription_cap", + "x-nous-credits-paid-access": "true", + } +) + +PURCHASED_ONLY_HEADERS = _base_headers( + **{ + "x-nous-credits-remaining-micros": micros(30.00), + "x-nous-credits-remaining-usd": "30.00", + "x-nous-credits-subscription-micros": micros(0), + "x-nous-credits-subscription-usd": "0.00", + "x-nous-credits-purchased-micros": micros(30.00), + "x-nous-credits-purchased-usd": "30.00", + "x-nous-credits-denominator-kind": "none", + "x-nous-credits-paid-access": "true", + # No limit pair — denominator_kind=none + } +) + +TOOL_POOL_FREE_HEADERS = _base_headers( + **{ + "x-nous-credits-remaining-micros": micros(0.05), + "x-nous-credits-remaining-usd": "0.05", + "x-nous-tool-pool-micros": micros(0.05), + "x-nous-tool-pool-gated-off": "false", + "x-nous-credits-paid-access": "true", + } +) + +DEPLETED_HEADERS = _base_headers( + **{ + "x-nous-credits-remaining-micros": micros(0), + "x-nous-credits-remaining-usd": "0.00", + "x-nous-credits-subscription-micros": micros(0), + "x-nous-credits-subscription-usd": "0.00", + "x-nous-credits-purchased-micros": micros(0), + "x-nous-credits-purchased-usd": "0.00", + "x-nous-credits-paid-access": "false", + "x-nous-credits-disabled-reason": "out_of_credits", + } +) + +DEBT_HEADERS = _base_headers( + **{ + "x-nous-credits-remaining-micros": micros(0), + "x-nous-credits-remaining-usd": "0.00", + "x-nous-credits-subscription-micros": str(-5_000_000), + "x-nous-credits-subscription-usd": "-5.00", + "x-nous-credits-purchased-micros": micros(0), + "x-nous-credits-purchased-usd": "0.00", + "x-nous-credits-paid-access": "false", + } +) + + +# ── State 1: healthy ───────────────────────────────────────────────────────── + + +class TestHealthyState: + def test_parses_successfully(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state is not None + + def test_from_header_set(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state.from_header is True + + def test_captured_at_set(self): + before = time.time() + state = parse_credits_headers(HEALTHY_HEADERS) + after = time.time() + assert before <= state.captured_at <= after + + def test_remaining_fields(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state.remaining_micros == round(30.34 * 1_000_000) + assert state.remaining_usd == "30.34" + + def test_subscription_fields(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state.subscription_micros == round(18.00 * 1_000_000) + assert state.subscription_usd == "18.00" + assert state.subscription_limit_micros == round(20.00 * 1_000_000) + assert state.subscription_limit_usd == "20.00" + + def test_rollover_and_purchased(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state.rollover_micros == 0 + assert state.purchased_micros == round(12.34 * 1_000_000) + assert state.purchased_usd == "12.34" + + def test_tool_pool(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state.tool_pool_micros == round(2.00 * 1_000_000) + assert state.tool_pool_gated_off is True + + def test_denominator_and_access(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state.denominator_kind == "subscription_cap" + assert state.paid_access is True + assert state.disabled_reason is None + + def test_used_fraction(self): + state = parse_credits_headers(HEALTHY_HEADERS) + # (20.00 - 18.00) / 20.00 = 0.10 + assert state.used_fraction == pytest.approx(0.10) + + def test_has_data(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state.has_data is True + + def test_not_depleted(self): + state = parse_credits_headers(HEALTHY_HEADERS) + assert state.depleted is False + + def test_age_seconds_reasonable(self): + state = parse_credits_headers(HEALTHY_HEADERS) + # Should be very small — just parsed + assert 0 <= state.age_seconds < 5 + + +# ── State 2: sub_90pct ─────────────────────────────────────────────────────── + + +class TestSub90Pct: + def test_parses_successfully(self): + state = parse_credits_headers(SUB_90PCT_HEADERS) + assert state is not None + + def test_used_fraction_90pct(self): + state = parse_credits_headers(SUB_90PCT_HEADERS) + # (20.00 - 2.00) / 20.00 = 0.90 + assert state.used_fraction == pytest.approx(0.90) + + def test_paid_access(self): + state = parse_credits_headers(SUB_90PCT_HEADERS) + assert state.paid_access is True + assert state.depleted is False + + +# ── State 3: grant_exhausted ───────────────────────────────────────────────── + + +class TestGrantExhausted: + def test_used_fraction_100pct(self): + state = parse_credits_headers(GRANT_EXHAUSTED_HEADERS) + assert state is not None + # subscription_micros=0, limit=20.00 → (20-0)/20 = 1.0 + assert state.used_fraction == pytest.approx(1.0) + + def test_paid_access_still_true(self): + state = parse_credits_headers(GRANT_EXHAUSTED_HEADERS) + assert state.paid_access is True + assert state.depleted is False + + +# ── State 4: purchased_only ────────────────────────────────────────────────── + + +class TestPurchasedOnly: + def test_parses_successfully(self): + state = parse_credits_headers(PURCHASED_ONLY_HEADERS) + assert state is not None + + def test_denominator_kind_none(self): + state = parse_credits_headers(PURCHASED_ONLY_HEADERS) + assert state.denominator_kind == "none" + + def test_used_fraction_is_none_no_limit(self): + state = parse_credits_headers(PURCHASED_ONLY_HEADERS) + # No subscription_limit_micros → used_fraction is None + assert state.used_fraction is None + + def test_no_limit_pair(self): + state = parse_credits_headers(PURCHASED_ONLY_HEADERS) + assert state.subscription_limit_micros is None + assert state.subscription_limit_usd is None + + +# ── State 5: tool_pool_free ────────────────────────────────────────────────── + + +class TestToolPoolFree: + def test_parses_successfully(self): + state = parse_credits_headers(TOOL_POOL_FREE_HEADERS) + assert state is not None + + def test_tool_pool_gated_off_false(self): + state = parse_credits_headers(TOOL_POOL_FREE_HEADERS) + assert state.tool_pool_gated_off is False + + def test_tool_pool_micros(self): + state = parse_credits_headers(TOOL_POOL_FREE_HEADERS) + assert state.tool_pool_micros == round(0.05 * 1_000_000) + + def test_paid_access(self): + state = parse_credits_headers(TOOL_POOL_FREE_HEADERS) + assert state.paid_access is True + + +# ── State 6: depleted ──────────────────────────────────────────────────────── + + +class TestDepleted: + def test_parses_successfully(self): + state = parse_credits_headers(DEPLETED_HEADERS) + assert state is not None + + def test_paid_access_false(self): + state = parse_credits_headers(DEPLETED_HEADERS) + assert state.paid_access is False + + def test_depleted_true(self): + state = parse_credits_headers(DEPLETED_HEADERS) + assert state.depleted is True + + def test_disabled_reason(self): + state = parse_credits_headers(DEPLETED_HEADERS) + assert state.disabled_reason == "out_of_credits" + + def test_remaining_zero(self): + state = parse_credits_headers(DEPLETED_HEADERS) + assert state.remaining_micros == 0 + + +# ── State 7: debt ──────────────────────────────────────────────────────────── + + +class TestDebt: + def test_parses_successfully(self): + # Negative subscription_micros should NOT cause the parse to fail + state = parse_credits_headers(DEBT_HEADERS) + assert state is not None + + def test_negative_subscription_accepted(self): + state = parse_credits_headers(DEBT_HEADERS) + assert state.subscription_micros == -5_000_000 + + def test_negative_subscription_usd_accepted(self): + state = parse_credits_headers(DEBT_HEADERS) + assert state.subscription_usd == "-5.00" + + def test_paid_access_false(self): + state = parse_credits_headers(DEBT_HEADERS) + assert state.paid_access is False + assert state.depleted is True + + +# ── State 8: missing ───────────────────────────────────────────────────────── + + +class TestMissing: + def test_no_credits_headers_returns_none(self): + state = parse_credits_headers({}) + assert state is None + + def test_completely_empty_dict(self): + assert parse_credits_headers({}) is None + + +# ── State 9: no_org ────────────────────────────────────────────────────────── + + +class TestNoOrg: + def test_irrelevant_headers_return_none(self): + headers = { + "content-type": "application/json", + "x-request-id": "abc123", + "server": "nginx", + } + state = parse_credits_headers(headers) + assert state is None + + def test_api_key_path_no_org_returns_none(self): + # Headers that might appear on an api-key path with no org + headers = { + "content-type": "application/json", + "authorization": "Bearer sk-test", + } + assert parse_credits_headers(headers) is None + + +# ── Version validation ─────────────────────────────────────────────────────── + + +class TestVersionValidation: + def test_version_string_1_parses(self): + headers = _base_headers(**{"x-nous-credits-version": "1"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.version == 1 + + def test_version_2_returns_none(self): + headers = _base_headers(**{"x-nous-credits-version": "2"}) + state = parse_credits_headers(headers) + assert state is None + + def test_version_absent_returns_none(self): + headers = {k: v for k, v in _base_headers().items() if k != "x-nous-credits-version"} + state = parse_credits_headers(headers) + assert state is None + + def test_version_greater_than_1_warns_once(self, caplog): + """Version > 1 must log a warning, and ONLY ONCE across multiple calls.""" + import agent.credits_tracker as ct + + original = ct._version_warning_emitted + try: + # Reset the warn-once latch so this test starts clean regardless of order + ct._version_warning_emitted = False + + headers = _base_headers(**{"x-nous-credits-version": "3"}) + with caplog.at_level(logging.WARNING, logger="agent.credits_tracker"): + parse_credits_headers(headers) + parse_credits_headers(headers) + parse_credits_headers(headers) + + warning_records = [r for r in caplog.records if "unsupported" in r.message.lower() or "version" in r.message.lower()] + assert len(warning_records) == 1, ( + f"Expected exactly 1 version warning, got {len(warning_records)}: {[r.message for r in warning_records]}" + ) + finally: + ct._version_warning_emitted = original + + def test_version_0_returns_none(self): + headers = _base_headers(**{"x-nous-credits-version": "0"}) + assert parse_credits_headers(headers) is None + + def test_version_non_int_returns_none(self): + headers = _base_headers(**{"x-nous-credits-version": "abc"}) + assert parse_credits_headers(headers) is None + + +# ── Bool-string trap ───────────────────────────────────────────────────────── + + +class TestBoolStringTrap: + """Explicit tests for the bool("false") == True trap.""" + + def test_paid_access_string_false_means_depleted(self): + """paid_access='false' must yield paid_access=False — NOT True.""" + headers = _base_headers(**{"x-nous-credits-paid-access": "false"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.paid_access is False + assert state.depleted is True + + def test_paid_access_string_true_means_not_depleted(self): + headers = _base_headers(**{"x-nous-credits-paid-access": "true"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.paid_access is True + assert state.depleted is False + + def test_paid_access_case_insensitive_FALSE(self): + headers = _base_headers(**{"x-nous-credits-paid-access": "FALSE"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.paid_access is False + + def test_paid_access_case_insensitive_True(self): + headers = _base_headers(**{"x-nous-credits-paid-access": "True"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.paid_access is True + + def test_tool_pool_gated_off_false(self): + headers = _base_headers(**{"x-nous-tool-pool-gated-off": "false"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.tool_pool_gated_off is False + + def test_tool_pool_gated_off_true(self): + headers = _base_headers(**{"x-nous-tool-pool-gated-off": "true"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.tool_pool_gated_off is True + + +# ── Tool-pool optional headers ──────────────────────────────────────────────── + + +class TestToolPoolOptional: + """x-nous-tool-pool-* headers are optional; absent → defaults; present-but-malformed → miss.""" + + def _no_tool_pool_headers(self) -> dict: + """Base headers with BOTH tool-pool headers removed.""" + h = _base_headers() + h.pop("x-nous-tool-pool-micros", None) + h.pop("x-nous-tool-pool-gated-off", None) + return h + + def test_absent_tool_pool_headers_parse_succeeds(self): + """Valid credits headers with no x-nous-tool-pool-* → parse succeeds.""" + state = parse_credits_headers(self._no_tool_pool_headers()) + assert state is not None + + def test_absent_tool_pool_micros_defaults_to_zero(self): + state = parse_credits_headers(self._no_tool_pool_headers()) + assert state.tool_pool_micros == 0 + + def test_absent_tool_pool_gated_off_defaults_to_false(self): + state = parse_credits_headers(self._no_tool_pool_headers()) + assert state.tool_pool_gated_off is False + + def test_present_malformed_tool_pool_micros_returns_none(self): + """x-nous-tool-pool-micros present but non-int → parse miss (returns None).""" + headers = _base_headers(**{"x-nous-tool-pool-micros": "not-a-number"}) + assert parse_credits_headers(headers) is None + + def test_present_negative_tool_pool_micros_returns_none(self): + """x-nous-tool-pool-micros present but negative → parse miss (returns None).""" + headers = _base_headers(**{"x-nous-tool-pool-micros": "-1000"}) + assert parse_credits_headers(headers) is None + + def test_only_tool_pool_micros_absent_still_succeeds(self): + """Only micros absent (gated-off still present) → tool_pool_micros = 0, parse succeeds.""" + h = _base_headers() + h.pop("x-nous-tool-pool-micros", None) + state = parse_credits_headers(h) + assert state is not None + assert state.tool_pool_micros == 0 + + +# ── Half-pair subscription limit ───────────────────────────────────────────── + + +class TestHalfPairLimit: + def test_only_limit_micros_present_both_absent(self): + """Only -micros present → both None, parse SUCCEEDS.""" + headers = _base_headers( + **{ + "x-nous-credits-subscription-limit-micros": micros(20.00), + "x-nous-credits-denominator-kind": "subscription_cap", + } + ) + state = parse_credits_headers(headers) + assert state is not None + assert state.subscription_limit_micros is None + assert state.subscription_limit_usd is None + + def test_only_limit_usd_present_both_absent(self): + """Only -usd present → both None, parse SUCCEEDS.""" + headers = _base_headers( + **{ + "x-nous-credits-subscription-limit-usd": "20.00", + "x-nous-credits-denominator-kind": "subscription_cap", + } + ) + state = parse_credits_headers(headers) + assert state is not None + assert state.subscription_limit_micros is None + assert state.subscription_limit_usd is None + + def test_half_pair_used_fraction_is_none(self): + """With no limit pair, used_fraction is None regardless of denominator_kind.""" + headers = _base_headers( + **{ + "x-nous-credits-subscription-limit-micros": micros(20.00), + "x-nous-credits-denominator-kind": "subscription_cap", + } + ) + state = parse_credits_headers(headers) + assert state is not None + assert state.used_fraction is None + + def test_full_pair_present_parsed_correctly(self): + """Both present → both populated, used_fraction computable.""" + headers = _base_headers( + **{ + "x-nous-credits-subscription-micros": micros(10.00), + "x-nous-credits-subscription-usd": "10.00", + "x-nous-credits-subscription-limit-micros": micros(20.00), + "x-nous-credits-subscription-limit-usd": "20.00", + "x-nous-credits-denominator-kind": "subscription_cap", + } + ) + state = parse_credits_headers(headers) + assert state is not None + assert state.subscription_limit_micros == round(20.00 * 1_000_000) + assert state.subscription_limit_usd == "20.00" + assert state.used_fraction == pytest.approx(0.50) + + +# ── Negative value validation ───────────────────────────────────────────────── + + +class TestNegativeValues: + def test_negative_remaining_micros_returns_none(self): + headers = _base_headers(**{"x-nous-credits-remaining-micros": "-1000"}) + assert parse_credits_headers(headers) is None + + def test_negative_purchased_micros_returns_none(self): + headers = _base_headers(**{"x-nous-credits-purchased-micros": "-500"}) + assert parse_credits_headers(headers) is None + + def test_negative_rollover_micros_returns_none(self): + headers = _base_headers(**{"x-nous-credits-rollover-micros": "-100"}) + assert parse_credits_headers(headers) is None + + def test_negative_limit_micros_returns_none(self): + headers = _base_headers( + **{ + "x-nous-credits-subscription-limit-micros": "-1000", + "x-nous-credits-subscription-limit-usd": "-0.00", + "x-nous-credits-denominator-kind": "subscription_cap", + } + ) + assert parse_credits_headers(headers) is None + + def test_negative_subscription_accepted(self): + """subscription_micros is the ONLY field allowed to be negative.""" + headers = _base_headers(**{"x-nous-credits-subscription-micros": "-5000000", + "x-nous-credits-subscription-usd": "-5.00"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.subscription_micros == -5_000_000 + + +# ── USD format validation ───────────────────────────────────────────────────── + + +class TestUsdValidation: + def test_valid_usd_format(self): + headers = _base_headers(**{"x-nous-credits-remaining-usd": "18.00"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.remaining_usd == "18.00" + + def test_usd_one_decimal_returns_none(self): + """'18.0' does not match ^-?\d+\.\d{2}$""" + headers = _base_headers(**{"x-nous-credits-remaining-usd": "18.0"}) + assert parse_credits_headers(headers) is None + + def test_usd_no_decimal_returns_none(self): + headers = _base_headers(**{"x-nous-credits-remaining-usd": "18"}) + assert parse_credits_headers(headers) is None + + def test_usd_with_dollar_sign_returns_none(self): + headers = _base_headers(**{"x-nous-credits-remaining-usd": "$18.00"}) + assert parse_credits_headers(headers) is None + + def test_usd_with_comma_returns_none(self): + headers = _base_headers(**{"x-nous-credits-remaining-usd": "1,800.00"}) + assert parse_credits_headers(headers) is None + + def test_usd_negative_valid(self): + """Negative USD string should parse (e.g. subscription debt).""" + headers = _base_headers( + **{ + "x-nous-credits-subscription-micros": "-5000000", + "x-nous-credits-subscription-usd": "-5.00", + } + ) + state = parse_credits_headers(headers) + assert state is not None + assert state.subscription_usd == "-5.00" + + +# ── Non-int micros validation ───────────────────────────────────────────────── + + +class TestMicrosValidation: + def test_non_int_micros_string_returns_none(self): + headers = _base_headers(**{"x-nous-credits-remaining-micros": "abc"}) + assert parse_credits_headers(headers) is None + + def test_float_string_micros_returns_none(self): + """'1.5' is not an integer string — should fail validation.""" + headers = _base_headers(**{"x-nous-credits-remaining-micros": "1.5"}) + assert parse_credits_headers(headers) is None + + def test_non_int_purchased_returns_none(self): + headers = _base_headers(**{"x-nous-credits-purchased-micros": "abc"}) + assert parse_credits_headers(headers) is None + + +# ── as_of_ms validation ─────────────────────────────────────────────────────── + + +class TestAsOfMs: + def test_junk_as_of_ms_returns_none(self): + headers = _base_headers(**{"x-nous-credits-as-of-ms": "not-a-timestamp"}) + assert parse_credits_headers(headers) is None + + def test_valid_as_of_ms(self): + headers = _base_headers(**{"x-nous-credits-as-of-ms": "1717000000000"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.as_of_ms == 1717000000000 + + +# ── denominator_kind validation ──────────────────────────────────────────────── + + +class TestDenominatorKind: + def test_subscription_cap_valid(self): + headers = _base_headers( + **{ + "x-nous-credits-denominator-kind": "subscription_cap", + "x-nous-credits-subscription-limit-micros": micros(20.00), + "x-nous-credits-subscription-limit-usd": "20.00", + } + ) + state = parse_credits_headers(headers) + assert state is not None + assert state.denominator_kind == "subscription_cap" + + def test_none_valid(self): + headers = _base_headers(**{"x-nous-credits-denominator-kind": "none"}) + state = parse_credits_headers(headers) + assert state is not None + assert state.denominator_kind == "none" + + def test_invalid_denominator_kind_returns_none(self): + headers = _base_headers(**{"x-nous-credits-denominator-kind": "invalid_kind"}) + assert parse_credits_headers(headers) is None + + +# ── Zero-division guard ──────────────────────────────────────────────────────── + + +class TestZeroDivisionGuard: + def test_subscription_limit_zero_used_fraction_is_none(self): + """subscription_limit_micros='0' + subscription_cap → used_fraction is None (no ZeroDivisionError).""" + headers = _base_headers( + **{ + "x-nous-credits-subscription-limit-micros": "0", + "x-nous-credits-subscription-limit-usd": "0.00", + "x-nous-credits-denominator-kind": "subscription_cap", + } + ) + state = parse_credits_headers(headers) + assert state is not None + # limit == 0, so used_fraction must be None (guard prevents division) + assert state.used_fraction is None + + +# ── Unknown headers ignored ──────────────────────────────────────────────────── + + +class TestUnknownHeaders: + def test_unknown_extra_header_ignored(self): + headers = { + **_base_headers(), + "x-nous-credits-future-field": "some-value", + "x-request-id": "abc123", + } + state = parse_credits_headers(headers) + assert state is not None + + def test_mixed_with_other_providers_headers(self): + headers = { + **_base_headers(), + "x-ratelimit-limit-requests": "800", + "content-type": "application/json", + } + state = parse_credits_headers(headers) + assert state is not None + + +# ── Header normalization ────────────────────────────────────────────────────── + + +class TestHeaderNormalization: + def test_uppercase_headers_parsed(self): + headers = {k.upper(): v for k, v in _base_headers().items()} + state = parse_credits_headers(headers) + assert state is not None + + def test_mixed_case_headers_parsed(self): + headers = { + "X-Nous-Credits-Version": "1", + "X-Nous-Credits-Remaining-Micros": micros(5.00), + "X-Nous-Credits-Remaining-Usd": "5.00", + "X-Nous-Credits-Subscription-Micros": micros(5.00), + "X-Nous-Credits-Subscription-Usd": "5.00", + "X-Nous-Credits-Rollover-Micros": "0", + "X-Nous-Credits-Purchased-Micros": "0", + "X-Nous-Credits-Purchased-Usd": "0.00", + "X-Nous-Tool-Pool-Micros": "0", + "X-Nous-Tool-Pool-Gated-Off": "false", + "X-Nous-Credits-Denominator-Kind": "none", + "X-Nous-Credits-Paid-Access": "true", + "X-Nous-Credits-As-Of-Ms": "1717000000000", + } + state = parse_credits_headers(headers) + assert state is not None + assert state.remaining_micros == round(5.00 * 1_000_000) + + +# ── CreditsState dataclass defaults ────────────────────────────────────────── + + +class TestCreditsStateDefaults: + def test_default_state(self): + state = CreditsState() + assert state.version == 0 + assert state.remaining_micros == 0 + assert state.remaining_usd == "" + assert state.subscription_micros == 0 + assert state.subscription_usd == "" + assert state.subscription_limit_micros is None + assert state.subscription_limit_usd is None + assert state.rollover_micros == 0 + assert state.purchased_micros == 0 + assert state.purchased_usd == "" + assert state.tool_pool_micros == 0 + assert state.tool_pool_gated_off is False + assert state.denominator_kind == "none" + assert state.paid_access is True + assert state.disabled_reason is None + assert state.as_of_ms == 0 + assert state.captured_at == 0.0 + assert state.from_header is False + + def test_has_data_false_when_no_captured_at(self): + state = CreditsState() + assert state.has_data is False + + def test_age_seconds_inf_when_no_data(self): + state = CreditsState() + assert state.age_seconds == float("inf") + + def test_depleted_false_by_default(self): + state = CreditsState() + assert state.depleted is False + + def test_used_fraction_none_by_default(self): + state = CreditsState() + assert state.used_fraction is None + + +# ── depleted property ───────────────────────────────────────────────────────── + + +class TestDepletedProperty: + def test_depleted_equals_not_paid_access(self): + """depleted must be exactly `not paid_access`, never `remaining==0`.""" + state = CreditsState(paid_access=False, remaining_micros=0, captured_at=time.time()) + assert state.depleted is True + + def test_not_depleted_when_paid_access_true(self): + state = CreditsState(paid_access=True, remaining_micros=0, captured_at=time.time()) + # remaining==0 but paid_access is True → NOT depleted + assert state.depleted is False + + def test_depleted_independent_of_remaining(self): + """Even with remaining > 0, if paid_access is False, depleted is True.""" + state = CreditsState(paid_access=False, remaining_micros=1_000_000, captured_at=time.time()) + assert state.depleted is True + + +# ── used_fraction edge cases ────────────────────────────────────────────────── + + +class TestUsedFraction: + def test_none_without_limit(self): + state = CreditsState( + denominator_kind="subscription_cap", + subscription_limit_micros=None, + captured_at=time.time(), + ) + assert state.used_fraction is None + + def test_none_when_limit_zero(self): + state = CreditsState( + denominator_kind="subscription_cap", + subscription_limit_micros=0, + subscription_micros=0, + captured_at=time.time(), + ) + assert state.used_fraction is None + + def test_clamped_at_zero(self): + """If subscription_micros > limit (over-credited), fraction clamps to 0.""" + state = CreditsState( + denominator_kind="subscription_cap", + subscription_limit_micros=10_000_000, + subscription_micros=15_000_000, # more than limit + captured_at=time.time(), + ) + assert state.used_fraction == pytest.approx(0.0) + + def test_clamped_at_one(self): + """If subscription_micros is very negative (debt), fraction clamps to 1.0.""" + state = CreditsState( + denominator_kind="subscription_cap", + subscription_limit_micros=10_000_000, + subscription_micros=-5_000_000, # deep debt + captured_at=time.time(), + ) + assert state.used_fraction == pytest.approx(1.0) + + def test_guarded_by_limit_field_not_denominator(self): + """used_fraction depends on subscription_limit_micros being truthy, not denominator_kind.""" + # limit present but denominator_kind="none" — spec says guard on LIMIT FIELD + state = CreditsState( + denominator_kind="none", + subscription_limit_micros=20_000_000, + subscription_micros=10_000_000, + captured_at=time.time(), + ) + # With limit_micros set, fraction should be computable regardless of denominator_kind + assert state.used_fraction == pytest.approx(0.50) + + def test_none_when_denominator_cap_but_no_limit(self): + """denominator_kind=subscription_cap but no limit pair → None.""" + state = CreditsState( + denominator_kind="subscription_cap", + subscription_limit_micros=None, + subscription_micros=5_000_000, + captured_at=time.time(), + ) + assert state.used_fraction is None diff --git a/tests/agent/test_nous_credits_gauge.py b/tests/agent/test_nous_credits_gauge.py new file mode 100644 index 00000000000..8764ede1e85 --- /dev/null +++ b/tests/agent/test_nous_credits_gauge.py @@ -0,0 +1,150 @@ +"""Tests for the Nous-credits subscription % gauge in build_nous_credits_snapshot. + +Covers the monthly_credits denominator path added when the portal /api/oauth/account +subscription block began carrying `monthly_credits`. Magnitudes-only fallback, clamp, +and the non-finite / rollover guards (surfaced by adversarial review) are all asserted. +""" +from hermes_cli.nous_account import ( + NousPortalAccountInfo, + NousPaidServiceAccessInfo, + NousPortalSubscriptionInfo, + _subscription_from_payload, +) +from agent.account_usage import build_nous_credits_snapshot, render_account_usage_lines + + +def _acct(**kwargs): + kwargs.setdefault("logged_in", True) + kwargs.setdefault("source", "account_api") + kwargs.setdefault("fresh", True) + kwargs.setdefault("portal_base_url", "https://portal.nousresearch.com") + return NousPortalAccountInfo(**kwargs) + + +def _window(snap): + return snap.windows[0] if (snap and snap.windows) else None + + +def test_parser_captures_monthly_credits(): + sub = _subscription_from_payload({ + "plan": "Ultra", "tier": 14, "monthly_charge": 200, "monthly_credits": 220, + "current_period_end": "2026-06-28T05:21:54.000Z", + "credits_remaining": 219.27341839, "rollover_credits": 0, + }) + assert sub.monthly_credits == 220 + assert abs(sub.credits_remaining - 219.27341839) < 1e-6 + + +def test_parser_monthly_credits_absent_is_none(): + sub = _subscription_from_payload({"plan": "Ultra", "credits_remaining": 10.0}) + assert sub.monthly_credits is None + + +def test_gauge_present_with_monthly_credits(): + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo( + plan="Ultra", monthly_credits=220, credits_remaining=219.27341839, + current_period_end="2026-06-28"), + paid_service_access_info=NousPaidServiceAccessInfo( + subscription_credits_remaining=219.27, total_usable_credits=219.27), + )) + w = _window(snap) + assert w is not None and w.label == "Subscription" + assert abs(w.used_percent - (220 - 219.27341839) / 220 * 100) < 1e-9 + blob = "\n".join(render_account_usage_lines(snap)) + assert "% used" in blob or "% remaining" in blob + assert "of $220.00 left" in blob + + +def test_gauge_90pct(): + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=22.0), + )) + assert abs(_window(snap).used_percent - 90.0) < 1e-9 + + +def test_gauge_debt_clamps_to_100(): + snap = build_nous_credits_snapshot(_acct( + paid_service_access=False, + subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=-5.0), + paid_service_access_info=NousPaidServiceAccessInfo(subscription_credits_remaining=-5.0), + )) + assert _window(snap).used_percent == 100.0 + + +def test_gauge_at_cap_is_zero_used(): + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=220.0), + )) + assert _window(snap).used_percent == 0.0 + + +def test_no_monthly_credits_falls_back_to_magnitudes(): + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo(plan="Ultra", credits_remaining=-0.79), + paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=991.96), + )) + assert _window(snap) is None + blob = "\n".join(render_account_usage_lines(snap)) + assert "%" not in blob + assert "Top-up credits: $991.96" in blob + + +def test_nan_remaining_no_window_no_nan_string(): + """json.loads parses bare NaN by default; isinstance(nan, float) is True. + The gauge must reject it rather than render '$nan' + a false 100% used.""" + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=float("nan")), + paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=5.0), + )) + assert _window(snap) is None + assert "$nan" not in "\n".join(render_account_usage_lines(snap)).lower() + + +def test_inf_cap_no_window(): + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo(monthly_credits=float("inf"), credits_remaining=10.0), + paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=5.0), + )) + assert _window(snap) is None + + +def test_rollover_balance_exceeds_cap_no_window(): + """remaining > cap (rollover spanning the period) makes monthly_credits a + nonsensical denominator → suppress the gauge, keep magnitudes.""" + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo(monthly_credits=220, credits_remaining=300, rollover_credits=80), + paid_service_access_info=NousPaidServiceAccessInfo(subscription_credits_remaining=300.0), + )) + assert _window(snap) is None + assert "of $220.00 left" not in "\n".join(render_account_usage_lines(snap)) + + +def test_bool_monthly_credits_no_window(): + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo(monthly_credits=True, credits_remaining=1.0), + paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=5.0), + )) + assert _window(snap) is None + + +def test_zero_monthly_credits_no_divzero(): + snap = build_nous_credits_snapshot(_acct( + paid_service_access=True, + subscription=NousPortalSubscriptionInfo(monthly_credits=0, credits_remaining=0.0), + paid_service_access_info=NousPaidServiceAccessInfo(purchased_credits_remaining=5.0), + )) + assert _window(snap) is None + + +def test_failopen_none_and_logged_out(): + assert build_nous_credits_snapshot(None) is None + assert build_nous_credits_snapshot(_acct(logged_in=False)) is None diff --git a/tests/agent/test_nous_credits_snapshot.py b/tests/agent/test_nous_credits_snapshot.py new file mode 100644 index 00000000000..764c83e20df --- /dev/null +++ b/tests/agent/test_nous_credits_snapshot.py @@ -0,0 +1,126 @@ +"""Tests for build_nous_credits_snapshot (L6-A, magnitudes-only).""" + +from __future__ import annotations + +from agent.account_usage import build_nous_credits_snapshot +from hermes_cli.nous_account import ( + NousPaidServiceAccessInfo, + NousPortalAccountInfo, + NousPortalSubscriptionInfo, +) + + +def _account(**kwargs) -> NousPortalAccountInfo: + kwargs.setdefault("logged_in", True) + kwargs.setdefault("source", "account_api") + kwargs.setdefault("fresh", True) + return NousPortalAccountInfo(**kwargs) + + +def _all_lines(snapshot) -> list[str]: + return list(snapshot.details) + + +def test_healthy(): + info = _account( + paid_service_access=True, + paid_service_access_info=NousPaidServiceAccessInfo( + subscription_credits_remaining=18.0, + purchased_credits_remaining=12.34, + total_usable_credits=30.34, + ), + subscription=NousPortalSubscriptionInfo( + plan="Pro", + current_period_end="2026-07-01", + ), + ) + snap = build_nous_credits_snapshot(info) + assert snap is not None + assert snap.available is True + assert snap.plan == "Pro" + assert snap.provider == "nous" + assert snap.title == "Nous credits" + blob = "\n".join(_all_lines(snap)) + assert "$18.00" in blob + assert "$12.34" in blob + assert "$30.34" in blob + assert "Renews: 2026-07-01" in blob + assert "/billing" in blob + # money-rule: magnitudes-only, never a percentage + assert "%" not in blob + + +def test_money_rule_no_percent(): + info = _account( + paid_service_access=True, + paid_service_access_info=NousPaidServiceAccessInfo( + subscription_credits_remaining=18.0, + purchased_credits_remaining=12.34, + total_usable_credits=30.34, + ), + subscription=NousPortalSubscriptionInfo(plan="Pro"), + ) + snap = build_nous_credits_snapshot(info) + assert snap is not None + for line in snap.details: + assert "%" not in line + + +def test_depleted(): + info = _account( + paid_service_access=False, + paid_service_access_info=NousPaidServiceAccessInfo( + subscription_credits_remaining=0.0, + purchased_credits_remaining=0.0, + total_usable_credits=0.0, + ), + subscription=NousPortalSubscriptionInfo(plan="Pro"), + ) + snap = build_nous_credits_snapshot(info) + assert snap is not None + blob = "\n".join(_all_lines(snap)) + assert "access depleted" in blob + assert "/billing" in blob + + +def test_purchased_only(): + info = _account( + paid_service_access=True, + paid_service_access_info=NousPaidServiceAccessInfo( + subscription_credits_remaining=None, + 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)) + assert "Subscription credits" not in blob + assert "Top-up credits: $30.00" in blob + assert snap.plan is None + + +def test_logged_out(): + info = _account( + logged_in=False, + paid_service_access=True, + paid_service_access_info=NousPaidServiceAccessInfo( + total_usable_credits=10.0, + ), + ) + assert build_nous_credits_snapshot(info) is None + + +def test_none(): + assert build_nous_credits_snapshot(None) is None + + +def test_never_raises_empty(): + info = _account( + paid_service_access=True, + paid_service_access_info=None, + subscription=None, + ) + # No usable numbers and not depleted -> None, without raising. + assert build_nous_credits_snapshot(info) is None diff --git a/tests/gateway/test_notice_rendering.py b/tests/gateway/test_notice_rendering.py new file mode 100644 index 00000000000..ca35497fa0e --- /dev/null +++ b/tests/gateway/test_notice_rendering.py @@ -0,0 +1,174 @@ +"""Unit tests for messaging-gateway credit-notice rendering. + +Covers render_notice_line — the pure helper that turns an AgentNotice into the +single plaintext line pushed standalone over a messaging platform (no status +bar, unlike the TUI). Behavior contracts, not data snapshots. +""" +from agent.credits_tracker import AgentNotice +from gateway.run import render_notice_line + + +class TestRenderNoticeLine: + """render_notice_line emits the notice text VERBATIM. + + The notice policy already bakes the level glyph (⚠ / • / ✕ / ✓) into the + text, and the TUI + CLI REPL render it as-is — so messaging must NOT add a + second glyph, which would double it ("⚠ ⚠ Credits 90% used", "⛔ ✕ Credit + access paused"). + """ + + def test_returns_text_verbatim_with_its_baked_glyph(self): + assert ( + render_notice_line(AgentNotice(text="⚠ Credits 90% used · $20.00 cap", level="warn")) + == "⚠ Credits 90% used · $20.00 cap" + ) + assert ( + render_notice_line(AgentNotice(text="• Grant spent · $5.00 top-up left", level="info")) + == "• Grant spent · $5.00 top-up left" + ) + assert ( + render_notice_line( + AgentNotice(text="✕ Credit access paused · run /usage for balance", level="error") + ) + == "✕ Credit access paused · run /usage for balance" + ) + + def test_does_not_prepend_a_second_glyph(self): + # Regression: the text already carries its glyph; the level must not add + # another (the bug produced "⚠ ⚠ …" / "⛔ ✕ …"). + line = render_notice_line(AgentNotice(text="⚠ Credits 90% used", level="warn")) + assert line == "⚠ Credits 90% used" + assert "⚠ ⚠" not in line + + def test_text_is_stripped(self): + assert render_notice_line(AgentNotice(text=" ⚠ padded ", level="warn")) == "⚠ padded" + + def test_empty_text_returns_empty_string(self): + # Empty/whitespace → "" → the callback suppresses the push. Fail-soft. + assert render_notice_line(AgentNotice(text="", level="warn")) == "" + assert render_notice_line(AgentNotice(text=" ", level="warn")) == "" + + def test_malformed_notice_does_not_raise(self): + # Duck-typed: a stand-in lacking the expected attrs degrades to "". + class _Bare: + pass + + assert render_notice_line(_Bare()) == "" + + +def test_real_policy_notices_render_without_doubling(): + """End-to-end regression: every notice evaluate_credits_notices emits already + carries its glyph, so render_notice_line must return it unchanged (no second + glyph prepended) for the messaging push.""" + from agent.credits_tracker import CreditsState, evaluate_credits_notices + + def _emitted(uf=None, paid=True, purchased=0): + latch = {"active": set(), "seen_below_90": True, "usage_band": None} + if uf is None: + st = CreditsState( + subscription_limit_micros=None, subscription_micros=0, + denominator_kind="none", paid_access=paid, + purchased_micros=purchased, purchased_usd="%.2f" % (purchased / 1e6), + ) + else: + lim = 20_000_000 + st = CreditsState( + subscription_limit_micros=lim, subscription_limit_usd="20.00", + subscription_micros=int(lim * (1 - uf)), denominator_kind="subscription_cap", + paid_access=paid, purchased_micros=purchased, + purchased_usd="%.2f" % (purchased / 1e6), + ) + show, _ = evaluate_credits_notices(st, latch) + return show + + notices = ( + _emitted(uf=0.9) # band 90 (warn) + + _emitted(uf=0.5) # band 50 (info) + + _emitted(uf=1.0, purchased=5_000_000) # band 90 + grant_spent + + _emitted(uf=None, paid=False) # depleted + ) + assert notices, "policy produced no notices to check" + for n in notices: + assert render_notice_line(n) == n.text # verbatim — no prepended glyph + + +# ── Delivery seam: a rendered notice line goes out via _deliver_platform_notice ── + +import threading +from unittest.mock import AsyncMock, MagicMock + +import pytest + + +def _make_source(platform_value="telegram", chat_id="555", user_id="u1"): + src = MagicMock() + plat = MagicMock() + plat.value = platform_value + src.platform = plat + src.chat_id = chat_id + src.user_id = user_id + return src + + +def _make_runner_with_adapter(source, adapter): + from gateway.run import GatewayRunner + + runner = object.__new__(GatewayRunner) + runner.adapters = {source.platform: adapter} + runner.config = MagicMock() + runner.config.get_notice_delivery = MagicMock(return_value="public") + runner._thread_metadata_for_source = MagicMock(return_value={"thread": "t"}) + return runner + + +class TestDeliverNoticeLine: + """The seam between render_notice_line and the platform adapter. + + Proves a rendered credit-notice line reaches adapter.send (public) / + send_private_notice (private) through the shared _deliver_platform_notice + rail — the path the gateway notice_callback schedules onto the loop. + """ + + @pytest.mark.asyncio + async def test_public_delivery_sends_rendered_line(self): + source = _make_source() + adapter = MagicMock() + adapter.send = AsyncMock(return_value=MagicMock(success=True)) + runner = _make_runner_with_adapter(source, adapter) + + line = render_notice_line( + AgentNotice(text="⚠ Credits 90% used · $20.00 cap", level="warn") + ) + await runner._deliver_platform_notice(source, line) + + adapter.send.assert_awaited_once() + args, kwargs = adapter.send.call_args + assert args[0] == "555" + # Delivered verbatim — the policy's single glyph, not a doubled one. + assert args[1] == "⚠ Credits 90% used · $20.00 cap" + + @pytest.mark.asyncio + async def test_private_delivery_prefers_private_notice(self): + source = _make_source() + adapter = MagicMock() + adapter.send = AsyncMock(return_value=MagicMock(success=True)) + adapter.send_private_notice = AsyncMock(return_value=MagicMock(success=True)) + runner = _make_runner_with_adapter(source, adapter) + runner.config.get_notice_delivery = MagicMock(return_value="private") + + line = render_notice_line( + AgentNotice(text="✓ Credit access restored", level="success") + ) + await runner._deliver_platform_notice(source, line) + + adapter.send_private_notice.assert_awaited_once() + adapter.send.assert_not_awaited() + + @pytest.mark.asyncio + async def test_no_adapter_is_a_noop(self): + source = _make_source() + runner = object.__new__(__import__("gateway.run", fromlist=["GatewayRunner"]).GatewayRunner) + runner.adapters = {} + # Must not raise when the platform has no registered adapter. + await runner._deliver_platform_notice(source, "• anything") + diff --git a/tests/gateway/test_usage_command.py b/tests/gateway/test_usage_command.py index e0297b3e6d5..2775e72f9d7 100644 --- a/tests/gateway/test_usage_command.py +++ b/tests/gateway/test_usage_command.py @@ -223,11 +223,14 @@ class TestUsageAccountSection: {"role": "user", "content": "earlier"}, ] - calls = {} + calls = [] async def _fake_to_thread(fn, *args, **kwargs): - calls["args"] = args - calls["kwargs"] = kwargs + # /usage dispatches BOTH the account fetch (fetch_account_usage, called + # with the provider positionally) and the Nous credits fetch + # (nous_credits_lines, markdown-only) through to_thread — record every + # call rather than last-wins so we can pick out the account fetch. + calls.append({"args": args, "kwargs": kwargs}) return fn(*args, **kwargs) monkeypatch.setattr("gateway.run.asyncio.to_thread", _fake_to_thread) @@ -242,11 +245,14 @@ class TestUsageAccountSection: "Provider: openai-codex (Pro)", ], ) + # The credits block routes through the shared nous_credits_lines() helper; + # stub it so this account-section test stays hermetic (no portal/auth lookup). + monkeypatch.setattr("agent.account_usage.nous_credits_lines", lambda markdown=False: []) event = MagicMock() result = await runner._handle_usage_command(event) - assert calls["args"] == ("openai-codex",) - assert calls["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex" + account_call = next(c for c in calls if c["args"] == ("openai-codex",)) + assert account_call["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex" assert "📊 **Session Info**" in result assert "📈 **Account limits**" in result diff --git a/tests/run_agent/test_notice_spine.py b/tests/run_agent/test_notice_spine.py new file mode 100644 index 00000000000..296ce199a71 --- /dev/null +++ b/tests/run_agent/test_notice_spine.py @@ -0,0 +1,205 @@ +"""Regression tests for the notice-spine (AgentNotice + emitter callbacks). + +Covers: + A. _emit_notice / _emit_notice_clear emitter behaviour (bare AIAgent via + object.__new__ — same pattern as test_steer.py and test_file_mutation_verifier.py). + B. Constructor / init_agent signature threading. + C. TUI _agent_cbs notice binding — mirrors the status_callback tests already + in tests/test_tui_gateway_server.py. +""" +from __future__ import annotations + +import inspect +from unittest.mock import patch + +import pytest + +from agent.credits_tracker import AgentNotice +from run_agent import AIAgent + + +# ── A. Emitter behaviour ───────────────────────────────────────────────────── + + +def _bare_agent() -> AIAgent: + """Build an AIAgent without running __init__ (no heavy init required). + + Only the two callback slots used by _emit_notice / _emit_notice_clear are + installed — mirrors the pattern in test_steer.py. + """ + agent = object.__new__(AIAgent) + agent.notice_callback = None + agent.notice_clear_callback = None + return agent + + +class TestEmitNotice: + def test_emit_notice_calls_callback_with_exact_notice(self): + agent = _bare_agent() + received = [] + notice = AgentNotice( + text="credits 90% used", + level="warn", + kind="sticky", + ttl_ms=None, + key="credits.warn90", + id="n1", + ) + agent.notice_callback = received.append + agent._emit_notice(notice) + assert received == [notice] + + def test_emit_notice_clear_calls_callback_with_exact_key(self): + agent = _bare_agent() + received = [] + agent.notice_clear_callback = received.append + agent._emit_notice_clear("credits.depleted") + assert received == ["credits.depleted"] + + def test_emit_notice_swallows_callback_exception(self): + agent = _bare_agent() + + def _boom(n): + raise RuntimeError("renderer exploded") + + agent.notice_callback = _boom + # Must not raise. + agent._emit_notice(AgentNotice(text="x")) + + def test_emit_notice_clear_swallows_callback_exception(self): + agent = _bare_agent() + + def _boom(key): + raise ValueError("clear renderer exploded") + + agent.notice_clear_callback = _boom + # Must not raise. + agent._emit_notice_clear("some.key") + + def test_emit_notice_no_op_when_callback_is_none(self): + agent = _bare_agent() + agent.notice_callback = None + # Should not raise AttributeError or anything else. + agent._emit_notice(AgentNotice(text="x")) + + def test_emit_notice_clear_no_op_when_callback_is_none(self): + agent = _bare_agent() + agent.notice_clear_callback = None + # Should not raise. + agent._emit_notice_clear("any.key") + + +# ── B. Constructor / init_agent signature threading ───────────────────────── + + +class TestSignatureThreading: + def test_agent_init_exposes_notice_callback(self): + sig = inspect.signature(AIAgent.__init__) + assert "notice_callback" in sig.parameters + + def test_agent_init_exposes_notice_clear_callback(self): + sig = inspect.signature(AIAgent.__init__) + assert "notice_clear_callback" in sig.parameters + + def test_init_agent_exposes_notice_callback(self): + from agent.agent_init import init_agent + sig = inspect.signature(init_agent) + assert "notice_callback" in sig.parameters + + def test_init_agent_exposes_notice_clear_callback(self): + from agent.agent_init import init_agent + sig = inspect.signature(init_agent) + assert "notice_clear_callback" in sig.parameters + + +# ── C. TUI _agent_cbs binding ──────────────────────────────────────────────── + + +class TestAgentCbsNoticeBinding: + """Mirror test_status_callback_emits_kind_and_text from test_tui_gateway_server.py.""" + + def test_notice_callback_emits_notification_show(self): + from tui_gateway import server + + with patch("tui_gateway.server._emit") as mock_emit: + cbs = server._agent_cbs("sid123") + notice = AgentNotice( + text="credits 90% used", + level="warn", + kind="sticky", + ttl_ms=None, + key="credits.warn90", + id="n1", + ) + cbs["notice_callback"](notice) + + mock_emit.assert_called_once_with( + "notification.show", + "sid123", + { + "text": "credits 90% used", + "level": "warn", + "kind": "sticky", + "ttl_ms": None, + "key": "credits.warn90", + "id": "n1", + }, + ) + + def test_notice_callback_payload_is_full_snake_case_dict(self): + """All six snake_case fields must be present in the payload — no extras, + no camelCase variants.""" + from tui_gateway import server + + captured = [] + with patch("tui_gateway.server._emit", side_effect=lambda *a: captured.append(a)): + cbs = server._agent_cbs("sid123") + cbs["notice_callback"]( + AgentNotice( + text="credits 90% used", + level="warn", + kind="sticky", + ttl_ms=None, + key="credits.warn90", + id="n1", + ) + ) + + assert len(captured) == 1 + _event_type, _sid, payload = captured[0] + assert set(payload.keys()) == {"text", "level", "kind", "ttl_ms", "key", "id"} + + def test_notice_clear_callback_emits_notification_clear(self): + from tui_gateway import server + + with patch("tui_gateway.server._emit") as mock_emit: + cbs = server._agent_cbs("sid123") + cbs["notice_clear_callback"]("credits.depleted") + + mock_emit.assert_called_once_with( + "notification.clear", + "sid123", + {"key": "credits.depleted"}, + ) + + def test_notice_callback_event_type_is_notification_show(self): + from tui_gateway import server + + captured = [] + with patch("tui_gateway.server._emit", side_effect=lambda *a: captured.append(a)): + cbs = server._agent_cbs("sid123") + cbs["notice_callback"](AgentNotice(text="any")) + + assert captured[0][0] == "notification.show" + + def test_notice_clear_callback_event_type_is_notification_clear(self): + from tui_gateway import server + + captured = [] + with patch("tui_gateway.server._emit", side_effect=lambda *a: captured.append(a)): + cbs = server._agent_cbs("sid123") + cbs["notice_clear_callback"]("some.key") + + assert captured[0][0] == "notification.clear" + assert captured[0][1] == "sid123" + assert captured[0][2] == {"key": "some.key"} diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 54d5a935fcd..48bdabcf748 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -724,6 +724,15 @@ def _start_agent_build(sid: str, session: dict) -> None: pass _wire_callbacks(sid) + # Hydrate credits notices at session OPEN (not just on the first + # message), so depletion / usage-band warnings show at "ready". Runs + # off the build thread, after the notice_callback is wired. Fail-open. + try: + from agent.credits_tracker import seed_credits_at_session_start + + seed_credits_at_session_start(agent) + except Exception: + pass with _sessions_lock: if sid in _sessions: _sessions[sid]["_notif_stop"] = _start_notification_poller(sid, _sessions[sid]) @@ -1679,6 +1688,15 @@ def _get_usage(agent) -> dict: usage["cost_usd"] = float(cost.amount_usd) except Exception: pass + # Dev-only live credits-spent readout (L0 usage-aware-credits). Gated on + # HERMES_DEV_CREDITS so the payload stays clean when the flag is off. + if is_truthy_value(os.environ.get("HERMES_DEV_CREDITS")): + try: + spent = agent.get_credits_spent_micros() + if spent is not None: + usage["dev_credits_spent_micros"] = int(spent) + except Exception: + pass return usage @@ -2155,6 +2173,24 @@ def _agent_cbs(sid: str) -> dict: "status_callback": lambda kind, text=None: _status_update( sid, str(kind), None if text is None else str(text) ), + # Credits/notice spine (L1): an AgentNotice fired by the agent becomes a + # notification.show WS event; a recovery clear becomes notification.clear. + # Snake_case payload to match the existing gateway-event convention. + "notice_callback": lambda n: _emit( + "notification.show", + sid, + { + "text": n.text, + "level": n.level, + "kind": n.kind, + "ttl_ms": n.ttl_ms, + "key": n.key, + "id": n.id, + }, + ), + "notice_clear_callback": lambda key: _emit( + "notification.clear", sid, {"key": key} + ), "clarify_callback": lambda q, c: _block( "clarify.request", sid, {"question": q, "choices": c} ), @@ -3592,14 +3628,24 @@ def _(rid, params: dict) -> dict: if err: return err agent = session.get("agent") - return _ok( - rid, - ( - _get_usage(agent) - if agent is not None - else {"calls": 0, "input": 0, "output": 0, "total": 0} - ), + usage: dict = ( + _get_usage(agent) + if agent is not None + else {"calls": 0, "input": 0, "output": 0, "total": 0} ) + # Nous credits block — agent-independent (a portal fetch), so it shows even + # with zero API calls or on a resumed session. The TUI /usage panel renders + # these lines regardless of `calls`. Fail-open: [] when not logged into Nous + # or on any portal hiccup. + try: + from agent.account_usage import nous_credits_lines + + credits = nous_credits_lines() + if credits: + usage["credits_lines"] = credits + except Exception: + pass + return _ok(rid, usage) @method("session.status") diff --git a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx index 4e468c200a2..3381b4b8e4e 100644 --- a/ui-tui/src/__tests__/appChromeStatusRule.test.tsx +++ b/ui-tui/src/__tests__/appChromeStatusRule.test.tsx @@ -54,6 +54,57 @@ const findClickableWithText = (node: ReactNodeLike, needle: string): React.React return findClickableWithText(node.props.children, needle) } +// Find the innermost element whose own (direct) text content includes the +// needle. Used to assert the colour the notice text is rendered with. +const findElementWithText = (node: ReactNodeLike, needle: string): React.ReactElement | null => { + if (node === null || node === undefined || typeof node === 'boolean') { + return null + } + + if (Array.isArray(node)) { + for (const child of node) { + const found = findElementWithText(child, needle) + + if (found) { + return found + } + } + + return null + } + + if (!React.isValidElement(node)) { + return null + } + + // Prefer the deepest matching element so we get the leaf that + // actually carries the colour, not an ancestor Box. + const deeper = findElementWithText(node.props.children, needle) + + if (deeper) { + return deeper + } + + return textContent(node).includes(needle) ? node : null +} + +const baseProps = { + bgCount: 0, + busy: false, + cols: 100, + cwdLabel: '~/repo', + liveSessionCount: 0, + model: 'opus-4.8', + sessionStartedAt: null, + showCost: false, + status: 'ready', + statusColor: DEFAULT_THEME.color.ok, + t: DEFAULT_THEME, + turnStartedAt: null, + usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 }, + voiceLabel: '' +} + describe('StatusRule session count click target', () => { it('makes the live session count itself clickable', () => { const openSwitcher = vi.fn() @@ -111,3 +162,101 @@ describe('StatusRule session count click target', () => { expect(rendered).not.toContain('$0.5000') }) }) + +describe('StatusRule credits notice render priority', () => { + it('replaces the idle status with the notice text and keeps model + context', () => { + const element = StatusRule({ + ...baseProps, + notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ credits exhausted' } + }) + + const rendered = textContent(element) + + // Notice replaces the status verb slot … + expect(rendered).toContain('✕ credits exhausted') + expect(rendered).not.toContain('ready') + // … but model + context stay visible. + expect(rendered).toContain('opus 4.8') + expect(rendered).toContain('50k') + }) + + it('busy wins: the FaceTicker shows, the notice is hidden mid-turn', () => { + const element = StatusRule({ + ...baseProps, + busy: true, + notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }, + turnStartedAt: Date.now() + }) + + const rendered = textContent(element) + + // Notice must NOT render while busy. + expect(rendered).not.toContain('⚠ 90% used') + // Model still visible. + expect(rendered).toContain('opus 4.8') + }) + + it('colours the notice by level (error → theme error, success → statusGood)', () => { + const errEl = StatusRule({ + ...baseProps, + notice: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' } + }) + const errText = findElementWithText(errEl, '✕ exhausted') + expect(errText?.props.color).toBe(DEFAULT_THEME.color.error) + + const okEl = StatusRule({ + ...baseProps, + notice: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 } + }) + const okText = findElementWithText(okEl, '✓ restored') + expect(okText?.props.color).toBe(DEFAULT_THEME.color.statusGood) + }) + + it('does NOT add a glyph — the notice text is rendered verbatim', () => { + const element = StatusRule({ + ...baseProps, + notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' } + }) + const noticeText = findElementWithText(element, '90% used') + + // The leaf carries exactly the policy text — no extra prepended glyph. + expect(noticeText?.props.children).toBe('⚠ 90% used') + }) + + it('the notice text is the shrinkable element (flexShrink=1 + truncate-end) so a long notice ellipsizes', () => { + const longText = '⚠ ' + 'x'.repeat(200) + const element = StatusRule({ + ...baseProps, + cols: 50, + notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: longText } + }) + + // The leaf truncates rather than wrapping/clipping the pinned tail. + const noticeText = findElementWithText(element, 'xxxxx') + expect(noticeText?.props.wrap).toBe('truncate-end') + + // Its container box yields first (flexShrink=1) so model stays visible. + const findShrinkBoxContaining = (node: ReactNodeLike): React.ReactElement | null => { + if (!React.isValidElement(node)) { + if (Array.isArray(node)) { + for (const c of node) { + const f = findShrinkBoxContaining(c) + if (f) return f + } + } + return null + } + if (node.props.flexShrink === 1 && textContent(node).includes('xxxxx') && node.type !== StatusRule) { + // Prefer the closest shrink box that wraps the notice text. + const deeper = findShrinkBoxContaining(node.props.children) + return deeper ?? node + } + return findShrinkBoxContaining(node.props.children) + } + const shrinkBox = findShrinkBoxContaining(element) + expect(shrinkBox).not.toBeNull() + + // Model survives on a narrow terminal because the notice yields. + expect(textContent(element)).toContain('opus 4.8') + }) +}) diff --git a/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx b/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx new file mode 100644 index 00000000000..514ff5f5c7c --- /dev/null +++ b/ui-tui/src/__tests__/appChromeStatusRuleDevCredits.test.tsx @@ -0,0 +1,73 @@ +import React from 'react' +import { describe, expect, it, vi } from 'vitest' + +import { StatusRule } from '../components/appChrome.js' +import { DEFAULT_THEME } from '../theme.js' + +// DEV_CREDITS_MODE is a module-load-time constant (config/env.ts reads +// process.env.HERMES_DEV_CREDITS exactly once, at import). Mutating process.env +// inside a test can't flip it after the module is loaded — so mock the module to +// the dev-on value for this file. vitest hoists vi.mock above the imports, so +// appChrome picks up the mocked flag. Lives in its own file so the override +// stays scoped (the other StatusRule tests run with the real, dev-off value). +vi.mock('../config/env.js', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, DEV_CREDITS_MODE: true } +}) + +type ReactNodeLike = React.ReactNode + +const textContent = (node: ReactNodeLike): string => { + if (node === null || node === undefined || typeof node === 'boolean') { + return '' + } + + if (typeof node === 'string' || typeof node === 'number') { + return String(node) + } + + if (Array.isArray(node)) { + return node.map(textContent).join('') + } + + if (React.isValidElement(node)) { + return textContent(node.props.children) + } + + return '' +} + +const baseProps = { + bgCount: 0, + busy: false, + cols: 100, + cwdLabel: '~/repo', + liveSessionCount: 0, + model: 'opus-4.8', + sessionStartedAt: null, + showCost: false, + status: 'ready', + statusColor: DEFAULT_THEME.color.ok, + t: DEFAULT_THEME, + turnStartedAt: null, + usage: { context_max: 200_000, context_percent: 25, context_used: 50_000, total: 50_000 }, + voiceLabel: '' +} + +describe('StatusRule dev-credits banner (HERMES_DEV_CREDITS on)', () => { + it('keeps the dev-credits banner visible alongside a notice', () => { + const element = StatusRule({ + ...baseProps, + notice: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }, + usage: { ...baseProps.usage, dev_credits_spent_micros: 12_345 } + }) + + const rendered = textContent(element) + + // The notice and the dev banner coexist … + expect(rendered).toContain('⚠ 90% used') + expect(rendered).toContain('(dev credits)') + // … and the Δ spend segment renders (12345 micros → 1.2¢). + expect(rendered).toContain('Δ') + }) +}) diff --git a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts index 08b5c4173c2..121b9011c7f 100644 --- a/ui-tui/src/__tests__/createGatewayEventHandler.test.ts +++ b/ui-tui/src/__tests__/createGatewayEventHandler.test.ts @@ -1214,4 +1214,316 @@ describe('createGatewayEventHandler', () => { expect(appended.some(msg => msg.role === 'system' && msg.text.startsWith('ask '))).toBe(false) }) + + // ── Credits notice (Strategy B) ────────────────────────────────────── + describe('credits notice', () => { + it('shows a notice immediately when idle (no turn in flight)', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ credits exhausted' }, + type: 'notification.show' + } as any) + + expect(getUiState().notice).toMatchObject({ + key: 'credits.depleted', + kind: 'sticky', + level: 'error', + text: '✕ credits exhausted' + }) + }) + + it('holds a notice arriving mid-turn (busy) and flushes it at message.complete', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ payload: {}, type: 'message.start' } as any) + expect(getUiState().busy).toBe(true) + + onEvent({ + payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }, + type: 'notification.show' + } as any) + + // Mid-turn: busy wins, notice is held, not visible yet. + expect(getUiState().notice).toBeNull() + + onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any) + + // Turn end flushes the held notice. + expect(getUiState().notice).toMatchObject({ key: 'credits.90', text: '⚠ 90% used' }) + }) + + it('flushes a held notice at interruptTurn (turn-end via ctrl-c)', () => { + vi.useFakeTimers() + + try { + const ctx = buildCtx([]) + ctx.gateway.gw.request = vi.fn(async () => ({ status: 'interrupted' })) + const onEvent = createGatewayEventHandler(ctx) + + patchUiState({ sid: 'sess-1' }) + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ + payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ out' }, + type: 'notification.show' + } as any) + expect(getUiState().notice).toBeNull() + + turnController.interruptTurn({ + appendMessage: vi.fn(), + gw: ctx.gateway.gw, + sid: 'sess-1', + sys: ctx.system.sys + }) + + expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ out' }) + } finally { + vi.runAllTimers() + vi.useRealTimers() + } + }) + + it('flushes a held notice at recordError (turn-end via error)', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ + payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }, + type: 'notification.show' + } as any) + expect(getUiState().notice).toBeNull() + + onEvent({ payload: { message: 'boom' }, type: 'error' } as any) + + expect(getUiState().notice).toMatchObject({ key: 'credits.90', text: '⚠ 90% used' }) + }) + + it('latest-wins: a second mid-turn notice replaces the first held one', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ + payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ 90% used' }, + type: 'notification.show' + } as any) + onEvent({ + payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' }, + type: 'notification.show' + } as any) + + onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any) + + // Only the latest held notice surfaces. + expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ exhausted' }) + }) + + it('clears a visible notice only when the clear key matches (no-op otherwise)', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { key: 'credits.grant_spent', kind: 'sticky', level: 'warn', text: '⚠ grant spent' }, + type: 'notification.show' + } as any) + expect(getUiState().notice).not.toBeNull() + + // Stale/late clear for a DIFFERENT key must not wipe the newer notice. + onEvent({ payload: { key: 'credits.something_else' }, type: 'notification.clear' } as any) + expect(getUiState().notice).toMatchObject({ key: 'credits.grant_spent' }) + + // Matching key clears. + onEvent({ payload: { key: 'credits.grant_spent' }, type: 'notification.clear' } as any) + expect(getUiState().notice).toBeNull() + }) + + it('drops a held pending notice on a matching clear before it can surface', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ + payload: { key: 'credits.grant_spent', kind: 'sticky', level: 'warn', text: '⚠ grant spent' }, + type: 'notification.show' + } as any) + // Clear arrives mid-turn before the held notice flushes. + onEvent({ payload: { key: 'credits.grant_spent' }, type: 'notification.clear' } as any) + + onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any) + + // Nothing surfaces — the pending notice was dropped by the matching clear. + expect(getUiState().notice).toBeNull() + }) + + it('a ttl notice self-expires after ttl_ms when applied while idle', () => { + vi.useFakeTimers() + + try { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ access restored', ttl_ms: 8000 }, + type: 'notification.show' + } as any) + expect(getUiState().notice).toMatchObject({ key: 'credits.restored' }) + + vi.advanceTimersByTime(7999) + expect(getUiState().notice).not.toBeNull() + + vi.advanceTimersByTime(2) + expect(getUiState().notice).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('R3-C2: a ttl notice self-expires even when statusTimer is also armed (timer isolation)', () => { + // Regression guard for the whole reason `noticeTimer` is a separate + // timer from `statusTimer`. A concurrent `status.update` (goal path) + // arms `statusTimer` via restoreStatusAfter; if the two timers shared + // a slot, clearing statusTimer would cancel the TTL and the notice + // would never self-expire. + vi.useFakeTimers() + + try { + const ctx = buildCtx([]) + const onEvent = createGatewayEventHandler(ctx) + + // 1. While idle, show a ttl notice → applies immediately, arms noticeTimer. + onEvent({ + payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 }, + type: 'notification.show' + } as any) + expect(getUiState().notice).toMatchObject({ key: 'credits.restored' }) + + // 2. A goal status.update arms turnController.statusTimer (via restoreStatusAfter). + onEvent({ + payload: { kind: 'goal', text: '✓ Goal achieved: some reason' }, + type: 'status.update' + } as any) + // statusTimer is now live; notice must still be visible. + expect(getUiState().notice).toMatchObject({ key: 'credits.restored' }) + + // 3. Advance past the TTL — the notice's own dedicated timer fires. + vi.advanceTimersByTime(8001) + + // 4. Notice self-expired: statusTimer did NOT cancel noticeTimer. + expect(getUiState().notice).toBeNull() + } finally { + vi.runAllTimers() + vi.useRealTimers() + } + }) + + it('starts the ttl clock when the notice becomes VISIBLE (at turn end), not on arrival', () => { + vi.useFakeTimers() + + try { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ + payload: { key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ restored', ttl_ms: 8000 }, + type: 'notification.show' + } as any) + + // Long busy turn: the TTL must NOT have started while held. + vi.advanceTimersByTime(10_000) + expect(getUiState().notice).toBeNull() + + onEvent({ payload: { text: 'done' }, type: 'message.complete' } as any) + expect(getUiState().notice).toMatchObject({ key: 'credits.restored' }) + + // Full 8s starts now (on apply), so it survives nearly that long. + vi.advanceTimersByTime(7999) + expect(getUiState().notice).not.toBeNull() + vi.advanceTimersByTime(2) + expect(getUiState().notice).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('latest-wins cancels a prior ttl timer so it cannot wipe the newer notice', () => { + vi.useFakeTimers() + + try { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ + payload: { id: 'a', key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ a', ttl_ms: 5000 }, + type: 'notification.show' + } as any) + + vi.advanceTimersByTime(4000) + + // A newer sticky arrives before the first's TTL fires. + onEvent({ + payload: { id: 'b', key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ b' }, + type: 'notification.show' + } as any) + expect(getUiState().notice).toMatchObject({ id: 'b' }) + + // The first notice's stale TTL must NOT clear the newer one. + vi.advanceTimersByTime(2000) + expect(getUiState().notice).toMatchObject({ id: 'b', text: '✕ b' }) + } finally { + vi.useRealTimers() + } + }) + + it('sticky survives a turn: applied with no pending notice does not clear it', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + // A standing sticky notice from a prior turn. + onEvent({ + payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ exhausted' }, + type: 'notification.show' + } as any) + expect(getUiState().notice).toMatchObject({ key: 'credits.depleted' }) + + // A new turn runs with NO new notice arriving. + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ payload: { text: 'reply' }, type: 'message.complete' } as any) + + // The standing sticky must REappear untouched at turn end. + expect(getUiState().notice).toMatchObject({ key: 'credits.depleted', text: '✕ exhausted' }) + }) + + it('reset()/fullReset() clears pending + timer + visible notice (no cross-session leak)', () => { + vi.useFakeTimers() + + try { + const onEvent = createGatewayEventHandler(buildCtx([])) + + // Session A: a visible sticky + a held pending notice mid-turn. + onEvent({ + payload: { key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ A cut' }, + type: 'notification.show' + } as any) + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ + payload: { key: 'credits.90', kind: 'sticky', level: 'warn', text: '⚠ A 90%' }, + type: 'notification.show' + } as any) + expect(getUiState().notice).toMatchObject({ key: 'credits.depleted' }) + + // Session boundary. + turnController.fullReset() + expect(getUiState().notice).toBeNull() + + // Session B: a turn ends with nothing held — A's notice must not bleed in. + onEvent({ payload: {}, type: 'message.start' } as any) + onEvent({ payload: { text: 'B reply' }, type: 'message.complete' } as any) + expect(getUiState().notice).toBeNull() + } finally { + vi.runAllTimers() + vi.useRealTimers() + } + }) + + it('ignores a notification.show with no text', () => { + const onEvent = createGatewayEventHandler(buildCtx([])) + + onEvent({ payload: { key: 'credits.90', level: 'warn' }, type: 'notification.show' } as any) + expect(getUiState().notice).toBeNull() + }) + }) }) diff --git a/ui-tui/src/__tests__/turnControllerNotice.test.ts b/ui-tui/src/__tests__/turnControllerNotice.test.ts new file mode 100644 index 00000000000..b25f860b626 --- /dev/null +++ b/ui-tui/src/__tests__/turnControllerNotice.test.ts @@ -0,0 +1,32 @@ +import { beforeEach, describe, expect, it } from 'vitest' + +import { turnController } from '../app/turnController.js' +import { resetTurnState } from '../app/turnStore.js' +import { getUiState, patchUiState, resetUiState } from '../app/uiStore.js' + +// turnController.startMessage() treats the usage-band notice (credits.usage) as +// "show until next prompt": a 50/75/90 heads-up flashes, then yields when the next +// turn starts. Depletion (and other notices) are sticky until the policy clears them. +describe('turnController.startMessage — usage-band notice clears on next prompt', () => { + beforeEach(() => { + resetUiState() + resetTurnState() + turnController.fullReset() + }) + + it('clears a standing credits.usage notice when a new turn starts', () => { + patchUiState({ + notice: { key: 'credits.usage', kind: 'sticky', level: 'warn', text: '⚠ Credits 90% used · $20.00 cap' } + }) + turnController.startMessage() + expect(getUiState().notice).toBeNull() + }) + + 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' } + }) + turnController.startMessage() + expect(getUiState().notice?.key).toBe('credits.depleted') + }) +}) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index 6e40e1c756c..7636eb6946f 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -503,6 +503,35 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } + case 'notification.show': { + // Credits/usage notice from the gateway. Payload is snake_case on the + // wire and stays snake_case in UiState.notice (no mapping layer). The + // text already carries its own glyph; turnController decides whether to + // show now or hold until turn end (FaceTicker wins while busy). + const p = ev.payload + + if (!p?.text) { + return + } + + turnController.showNotice({ + id: p.id, + key: p.key, + kind: p.kind ?? 'sticky', + level: p.level ?? 'info', + text: p.text, + ttl_ms: p.ttl_ms ?? null + }) + + return + } + + case 'notification.clear': + // Key-matched clear only — a stale/late clear must not wipe a newer + // notice (turnController guards the key match). + turnController.clearNotice(ev.payload?.key) + + return case 'gateway.stderr': { const line = String(ev.payload.line).slice(0, 120) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index bcb1578e4a3..499232924ce 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -30,6 +30,22 @@ export type StatusBarMode = 'bottom' | 'off' | 'top' export type BusyInputMode = 'interrupt' | 'queue' | 'steer' +export type NoticeLevel = 'error' | 'info' | 'success' | 'warn' + +// Credits/usage notice surfaced in the status bar. Shape is snake_case to +// match the gateway WS wire (`notification.show` payload) and the existing +// `Usage` type — no camelCase mapping layer. The `text` already carries its +// own leading glyph (⚠ • ✕ ✓) from the Python policy, so the renderer only +// colours it by `level` and never adds another glyph. +export interface Notice { + id?: string + key?: string + kind?: 'sticky' | 'ttl' + level?: NoticeLevel + text: string + ttl_ms?: null | number +} + // Single source of truth for indicator style names. Union type is // derived from this tuple so adding/removing a style only touches one // line — `useConfigSync` (validation) and `session.ts` (slash arg @@ -106,6 +122,7 @@ export interface UiState { liveSessionCount: number inlineDiffs: boolean mouseTracking: MouseTrackingMode + notice: Notice | null pasteCollapseLines: number pasteCollapseChars: number diff --git a/ui-tui/src/app/slash/commands/session.ts b/ui-tui/src/app/slash/commands/session.ts index bcc38d3f1ab..2456e718f68 100644 --- a/ui-tui/src/app/slash/commands/session.ts +++ b/ui-tui/src/app/slash/commands/session.ts @@ -518,7 +518,7 @@ export const sessionCommands: SlashCommand[] = [ }, { - help: 'session usage (live counts — worker sees zeros)', + help: 'session usage + Nous credits', name: 'usage', run: (_arg, ctx) => { ctx.gateway.rpc('session.usage', { session_id: ctx.sid }).then(r => { @@ -532,8 +532,19 @@ export const sessionCommands: SlashCommand[] = [ }) } + // Nous credits block is agent-independent (a portal fetch), so it shows + // even with zero API calls or on a resumed session. Render it whenever + // present, before the token panel. + const creditsLines = r?.credits_lines ?? [] + if (creditsLines.length) { + ctx.transcript.panel('Nous credits', [{ text: creditsLines.join('\n') }]) + } + if (!r?.calls) { - return ctx.transcript.sys('no API calls yet') + if (!creditsLines.length) { + ctx.transcript.sys('no API calls yet') + } + return } const f = (v: number | undefined) => (v ?? 0).toLocaleString() diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index df2c50ef0ea..eda0e485519 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -19,6 +19,7 @@ import { } from '../lib/text.js' import type { ActiveTool, ActivityItem, Msg, SubagentProgress, TodoItem } from '../types.js' +import type { Notice } from './interfaces.js' import { resetFlowOverlays } from './overlayStore.js' import { pushSnapshot } from './spawnHistoryStore.js' import { archiveDoneTodos, getTurnState, patchTurnState, resetTurnState } from './turnStore.js' @@ -132,6 +133,17 @@ class TurnController { private streamDelay = STREAM_IDLE_BATCH_MS private toolProgressTimer: Timer = null + // ── Credits notice machinery (Strategy B) ─────────────────────────── + // + // A notice arriving mid-turn must NOT show (FaceTicker wins while busy); + // it is held here, latest-wins, and applied at turn end via onTurnEnd(). + // The TTL clock starts only when the notice becomes VISIBLE (on apply), + // never on arrival, so an 8s "restored" notice shows for its full life. + // `noticeTimer` is DEDICATED — it is never the shared `statusTimer`. + private pendingNotice: Notice | null = null + private noticeTimer: Timer = null + private noticeIdSeq = 0 + boostStreamingForTyping() { this.streamDelay = STREAM_TYPING_BATCH_MS } @@ -157,6 +169,100 @@ class TurnController { this.statusTimer = clear(this.statusTimer) } + // ── Notice: arrival ────────────────────────────────────────────────── + // + // A `notification.show` arrived. If a turn is in flight (`busy`), the + // FaceTicker owns the verb slot, so we hold the notice (latest-wins) and + // let onTurnEnd() apply it when the turn finishes. If idle, apply it now. + // The Python side emits STABLE ids per notice kind (e.g. `credits.warn90`, + // `credits.restored`), NOT unique-per-emission ids. The id-guard in + // applyNotice() is a defensive backup; the primary latest-wins mechanism is + // that applyNotice/clearNotice always cancel the prior timer first. + showNotice(notice: Notice) { + const stamped: Notice = { ...notice, id: notice.id || `n${++this.noticeIdSeq}` } + + if (getUiState().busy) { + this.pendingNotice = stamped + + return + } + + this.applyNotice(stamped) + } + + // ── Notice: clear by key (R3-H3 / HIGH-3) ──────────────────────────── + // + // `notification.clear` only clears the visible notice when its key + // matches — a stale/late clear must not wipe a NEWER notice. Always drop + // a matching pending notice so it can't resurface at the next turn end. + clearNotice(key?: string) { + if (this.pendingNotice && this.pendingNotice.key === key) { + this.pendingNotice = null + } + + if (getUiState().notice?.key === key) { + this.clearNoticeTimer() + patchUiState({ notice: null }) + } + } + + // Apply a notice to the visible UI state and (re)arm its TTL clock. + // Latest-wins: clear any prior TTL timer FIRST so an older notice's + // expiry can't wipe this one. A 'ttl' notice with `ttl_ms` self-expires; + // 'sticky' (default) persists until an explicit clear. + private applyNotice(notice: Notice) { + this.clearNoticeTimer() + patchUiState({ notice }) + + if (notice.kind === 'ttl' && typeof notice.ttl_ms === 'number' && notice.ttl_ms > 0) { + const id = notice.id + + this.noticeTimer = setTimeout(() => { + this.noticeTimer = null + + // Defensive backup: the prior timer was already cancelled by + // clearNoticeTimer() when a newer notice was applied, so in + // practice this guard only fires for the notice that armed it. + if (getUiState().notice?.id === id) { + patchUiState({ notice: null }) + } + }, notice.ttl_ms) + } + } + + private clearNoticeTimer() { + this.noticeTimer = clear(this.noticeTimer) + } + + // ── Notice: turn-end flush (R3-C1 / R3-H4) ─────────────────────────── + // + // Invoked ONLY by the three real turn-end sites (recordMessageComplete, + // interruptTurn, recordError) — NEVER by idle()/reset(), which would leak + // session A's notice into session B. Applies a pending NEW notice so it + // appears now that FaceTicker has yielded; the TTL clock starts here, when + // the notice first becomes visible. With no pending notice this is a + // no-op, so a standing sticky notice REappears untouched after the turn. + private flushPendingNotice() { + if (!this.pendingNotice) { + return + } + + const notice = this.pendingNotice + this.pendingNotice = null + this.applyNotice(notice) + } + + // Drop all notice state — pending + timer + visible (R3-H5). Called by + // reset()/fullReset() so a session A notice can't bleed into session B. + private clearNoticeState() { + this.pendingNotice = null + this.clearNoticeTimer() + + if (getUiState().notice) { + patchUiState({ notice: null }) + } + } + endReasoningPhase() { this.reasoningStreamingTimer = clear(this.reasoningStreamingTimer) patchTurnState({ reasoningActive: false, reasoningStreaming: false }) @@ -238,6 +344,9 @@ class TurnController { this.statusTimer = null patchUiState({ status: 'ready' }) }, INTERRUPT_COOLDOWN_MS) + + // Real turn end: surface any notice held back while busy. + this.flushPendingNotice() } pruneTransient() { @@ -440,6 +549,9 @@ class TurnController { this.segmentMessages = [] this.turnTools = [] this.persistedToolLabels.clear() + + // Real turn end: surface any notice held back while busy. + this.flushPendingNotice() } recordMessageComplete(payload: { rendered?: string; reasoning?: string; text?: string }) { @@ -533,6 +645,10 @@ class TurnController { this.interrupted = false patchTurnState({ activity: [], outcome: '' }) + // Real turn end: surface any notice held back while busy. Done after + // idle() flips busy=false so applyNotice() reaches the visible slot. + this.flushPendingNotice() + return { finalMessages, finalText, wasInterrupted } } @@ -735,6 +851,9 @@ class TurnController { this.turnTools = [] this.toolTokenAcc = 0 this.persistedToolLabels.clear() + // Session boundary: drop notice state so session A's sticky can't bleed + // into session B (R3-H5). reset()/fullReset() CLEAR — they never flush. + this.clearNoticeState() patchTurnState({ activity: [], outcome: '' }) } @@ -788,6 +907,13 @@ class TurnController { this.toolTokenAcc = 0 this.interrupted = false this.persistedToolLabels.clear() + // Usage-band notices (credits.usage) are "show until next prompt": a 50/75/90 + // heads-up should flash and then yield, not camp the bar. Clear it as a new + // turn starts. Depletion (credits.depleted) and other notices stay — they're + // explicitly sticky until the policy clears them. + if (getUiState().notice?.key === 'credits.usage') { + this.clearNotice('credits.usage') + } patchUiState({ busy: true }) patchTurnState({ activity: [], outcome: '', subagents: [], toolTokens: 0, tools: [], turnTrail: [] }) } diff --git a/ui-tui/src/app/uiStore.ts b/ui-tui/src/app/uiStore.ts index b51001cb051..cacca23bdcc 100644 --- a/ui-tui/src/app/uiStore.ts +++ b/ui-tui/src/app/uiStore.ts @@ -18,6 +18,7 @@ const buildUiState = (): UiState => ({ liveSessionCount: 0, inlineDiffs: true, mouseTracking: MOUSE_TRACKING, + notice: null, pasteCollapseLines: 5, pasteCollapseChars: 2000, sections: {}, diff --git a/ui-tui/src/components/appChrome.tsx b/ui-tui/src/components/appChrome.tsx index 01f9595e62c..a420d815341 100644 --- a/ui-tui/src/components/appChrome.tsx +++ b/ui-tui/src/components/appChrome.tsx @@ -4,8 +4,9 @@ import { type ReactNode, type RefObject, useEffect, useMemo, useRef, useState } import unicodeSpinners from 'unicode-animations' import { $delegationState } from '../app/delegationStore.js' -import type { IndicatorStyle } from '../app/interfaces.js' +import type { IndicatorStyle, Notice } from '../app/interfaces.js' import { useTurnSelector } from '../app/turnStore.js' +import { DEV_CREDITS_MODE } from '../config/env.js' import { FACES } from '../content/faces.js' import { VERBS } from '../content/verbs.js' import { fmtDuration } from '../domain/messages.js' @@ -185,6 +186,26 @@ function statusSessionCountLabel(count: number) { return `${count} ${count === 1 ? 'session' : 'sessions'}` } +// Colour a credits notice by its level. The notice TEXT already carries its +// own glyph (⚠ • ✕ ✓) from the Python policy — we only tint it here, never +// prepend another glyph. `success` maps to the theme's green status colour. +function noticeColor(level: Notice['level'], t: Theme): string { + if (level === 'error') { + return t.color.error + } + + if (level === 'warn') { + return t.color.warn + } + + if (level === 'success') { + return t.color.statusGood + } + + // 'info' / undefined — keep it readable but understated. + return t.color.accent +} + function ctxBar(pct: number | undefined, w = 10) { const p = Math.max(0, Math.min(100, pct ?? 0)) const filled = Math.round((p / 100) * w) @@ -376,6 +397,7 @@ export function StatusRule({ modelFast, modelReasoningEffort, indicatorStyle = 'kaomoji', + notice, usage, bgCount, liveSessionCount, @@ -403,13 +425,32 @@ export function StatusRule({ const bar = !segs.compactCtx && usage.context_max ? ctxBar(pct) : '' const modelText = modelLabel(model, modelReasoningEffort, modelFast) + // A credits notice replaces the status/verb slot, but only when idle — + // while busy the FaceTicker always wins (R1 render priority). The notice + // text carries its own glyph; we only tint it (R1) and let it shrink (R3-M7). + const showNotice = !busy && !!notice?.text + // The notice slot is shrinkable (flexShrink={1}, truncate-end), so reserve + // only a small bounded width for it in the essentials budget — enough that + // a short notice never gets crushed, but a long one ellipsizes instead of + // shoving `model │ ctx` off-screen (R3-M7). Cap at the notice's own width + // so short notices reserve exactly what they need. + const NOTICE_RESERVE_MAX = 24 + const noticeReserve = showNotice ? Math.min(stringWidth(notice!.text), NOTICE_RESERVE_MAX) : 0 + // Width of the must-keep left segments (indicator + model + context). They // are pinned (never shrink) and reserved so the cwd/branch on the right // yields first. The busy face width depends on the active /indicator style - // (kaomoji is wide + verb; unicode is a bare 1-col spinner). + // (kaomoji is wide + verb; unicode is a bare 1-col spinner). When a notice + // occupies the slot it reserves only `noticeReserve` (it shrinks/truncates). + const slotWidth = busy + ? busyIndicatorWidth(indicatorStyle, turnStartedAt != null) + : showNotice + ? noticeReserve + : stringWidth(status) + const essentialWidth = stringWidth('─ ') + - (busy ? busyIndicatorWidth(indicatorStyle, turnStartedAt != null) : stringWidth(status)) + + slotWidth + stringWidth(' │ ') + stringWidth(modelText) + (ctxLabel ? stringWidth(' │ ') + stringWidth(ctxLabel) : 0) @@ -436,6 +477,14 @@ export function StatusRule({ const sessionCountText = liveSessionCount > 0 ? statusSessionCountLabel(liveSessionCount) : '' const compressions = typeof usage.compressions === 'number' ? usage.compressions : 0 const costText = typeof usage.cost_usd === 'number' ? `$${usage.cost_usd.toFixed(4)}` : '' + // Dev-only readout (HERMES_DEV_CREDITS). The server omits the key entirely unless the + // flag is on, so this segment self-hides for normal users. micros→cents is allowed money + // math (display formatting) — never parseFloat a *_usd. Signed: a mid-session top-up that + // raises remaining nets a negative Δ (honest). + const devCreditsText = + typeof usage.dev_credits_spent_micros === 'number' + ? `Δ ${(usage.dev_credits_spent_micros / 10000).toFixed(1)}¢` + : '' const showBar = !!bar && fits(SEP + stringWidth(`[${bar}] ${pct != null ? `${pct}%` : ''}`)) const showDuration = segs.duration && !!sessionStartedAt && fits(SEP + MAX_DURATION_WIDTH) @@ -444,6 +493,9 @@ export function StatusRule({ const showSessionCount = !!sessionCountText && fits(SEP + stringWidth(sessionCountText)) const showBg = segs.bg && bgCount > 0 && fits(SEP + stringWidth(`${bgCount} bg`)) const showCostSeg = segs.cost && showCost && !!costText && fits(SEP + stringWidth(costText)) + // No segs flag / no showCost coupling — it's a server-gated dev readout, lowest priority, + // so it consumes tail budget LAST and drops first on a narrow terminal. + const showDevCredits = !!devCreditsText && fits(SEP + stringWidth(devCreditsText)) const handleSessionCountClick = (event: { stopImmediatePropagation?: () => void }) => { event.stopImmediatePropagation?.() @@ -461,16 +513,37 @@ export function StatusRule({ return ( - {/* Pinned essentials — never shrink, always visible. */} + {/* Leading pinned chrome: border + busy face / idle status. When a + notice occupies the slot the status text is dropped — the notice + renders as a separate shrinkable box below so a long notice + ellipsizes instead of crushing model │ ctx (R3-M7). */} {'─ '} {busy ? ( - ) : ( + ) : showNotice ? null : ( {status} )} + + {/* Notice slot — the only shrinkable left element (R3-M7). Sits in a + flexShrink={1} box with truncate-end so it yields/ellipsizes + before the pinned model │ ctx box ever clips. */} + {showNotice ? ( + + + {notice!.text} + + + ) : null} + {/* Pinned essentials — model + context never shrink, always visible. */} + + {DEV_CREDITS_MODE ? ( + + {' (dev credits)'} + + ) : null} {' │ '} {modelText} @@ -526,6 +599,12 @@ export function StatusRule({ {costText} ) : null} + {showDevCredits ? ( + + {' │ '} + {devCreditsText} + + ) : null} {/* SpawnHud isn't part of the tail budget (its width is dynamic), so it renders last — any overflow truncates the HUD itself rather than the budgeted segments before it. It self-hides when no delegation runs. */} @@ -654,6 +733,7 @@ interface StatusRuleProps { modelFast?: boolean modelReasoningEffort?: string indicatorStyle?: IndicatorStyle + notice?: Notice | null sessionStartedAt?: null | number showCost: boolean status: string diff --git a/ui-tui/src/components/appLayout.tsx b/ui-tui/src/components/appLayout.tsx index 113735c491b..b93e2045c7e 100644 --- a/ui-tui/src/components/appLayout.tsx +++ b/ui-tui/src/components/appLayout.tsx @@ -370,6 +370,7 @@ const StatusRulePane = memo(function StatusRulePane({ model={ui.info?.model ?? ''} modelFast={ui.info?.fast || ui.info?.service_tier === 'priority'} modelReasoningEffort={ui.info?.reasoning_effort} + notice={ui.notice} onSessionCountClick={() => patchOverlayState({ sessions: true })} sessionStartedAt={status.sessionStartedAt} showCost={ui.showCost} diff --git a/ui-tui/src/config/env.ts b/ui-tui/src/config/env.ts index 88d1f4eb3a9..3b5b9bee4d4 100644 --- a/ui-tui/src/config/env.ts +++ b/ui-tui/src/config/env.ts @@ -49,6 +49,10 @@ export const MOUSE_TRACKING: MouseTrackingMode = resolvedBootMouseEnabled ? 'all export const NO_CONFIRM_DESTRUCTIVE = truthy(process.env.HERMES_TUI_NO_CONFIRM) +// HERMES_DEV_CREDITS — dev-only live-spend readout (Δ status segment + "(dev credits)" +// banner). Throwaway dev scaffolding; the whole readout gates on this one flag. +export const DEV_CREDITS_MODE = truthy(process.env.HERMES_DEV_CREDITS) + const inlineOverride = parseToggle(process.env.HERMES_TUI_INLINE) // Skip AlternateScreen — TUI renders into the primary buffer so the host diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 0a946d9b624..0f1e898415c 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -218,6 +218,7 @@ export interface SessionUsageResponse { context_used?: number cost_status?: 'estimated' | 'exact' cost_usd?: number + credits_lines?: string[] input?: number model?: string output?: number @@ -512,6 +513,19 @@ export type GatewayEvent = | { payload?: { text?: string }; session_id?: string; type: 'thinking.delta' } | { payload?: undefined; session_id?: string; type: 'message.start' } | { payload?: { kind?: string; text?: string }; session_id?: string; type: 'status.update' } + | { + payload?: { + id?: string + key?: string + kind?: 'sticky' | 'ttl' + level?: 'error' | 'info' | 'success' | 'warn' + text?: string + ttl_ms?: null | number + } + session_id?: string + type: 'notification.show' + } + | { payload?: { key?: string }; session_id?: string; type: 'notification.clear' } | { payload?: { state?: 'idle' | 'listening' | 'transcribing' }; session_id?: string; type: 'voice.status' } | { payload?: { no_speech_limit?: boolean; text?: string }; session_id?: string; type: 'voice.transcript' } | { payload: { line: string }; session_id?: string; type: 'gateway.stderr' } diff --git a/ui-tui/src/types.ts b/ui-tui/src/types.ts index 0bfab6c271d..60f0db48ef9 100644 --- a/ui-tui/src/types.ts +++ b/ui-tui/src/types.ts @@ -170,6 +170,7 @@ export interface Usage { context_used?: number cost_status?: string cost_usd?: number + dev_credits_spent_micros?: number input: number output: number reasoning?: number