Merge origin/main into feat/hermes-relay-shared-metrics

Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
Alex Fournier 2026-07-22 08:55:25 -07:00
commit 761fce7f79
480 changed files with 65880 additions and 3400 deletions

View file

@ -158,12 +158,13 @@ jobs:
review-labels:
name: Review label gate
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true')
needs: [detect, supply-chain]
if: always() && needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true' || needs.supply-chain.outputs.critical_findings == 'true')
uses: ./.github/workflows/review-labels.yml
with:
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
secrets: inherit
osv-scanner:

View file

@ -27,6 +27,10 @@ on:
description: Whether the MCP catalog / installer changed.
type: boolean
default: false
supply_chain:
description: Whether the critical supply-chain scan found a risk requiring review.
type: boolean
default: false
outputs:
ci_reviewed:
description: Whether the ci-reviewed label is present. Empty when neither input was true.
@ -42,7 +46,7 @@ permissions:
jobs:
check:
name: Review label gate
if: inputs.ci_review || inputs.mcp_catalog
if: inputs.ci_review || inputs.mcp_catalog || inputs.supply_chain
runs-on: ubuntu-latest
timeout-minutes: 2
outputs:
@ -75,15 +79,17 @@ jobs:
env:
CI_REVIEW: ${{ inputs.ci_review }}
MCP_CATALOG: ${{ inputs.mcp_catalog }}
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
run: |
set -euo pipefail
ARGS=""
if [ "$CI_REVIEW" = "true" ]; then ARGS="$ARGS --ci-review"; fi
if [ "$MCP_CATALOG" = "true" ]; then ARGS="$ARGS --mcp-catalog"; fi
if [ "$LABEL_PRESENT" = "true" ]; then ARGS="$ARGS --label-present"; fi
args=()
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
python3 scripts/ci/emit_review_status.py $ARGS --output "$GITHUB_OUTPUT"
python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT"
- name: Fail on missing label
if: steps.label-check.outputs.ci_reviewed != 'true'

View file

@ -16,11 +16,12 @@ name: Supply Chain Audit
# ``review-labels.yml`` so it can be rerun independently.
#
# Outputs:
# review_status — JSON array of status objects consumed by the review
# comment assembler (scripts/ci/assemble_review_comment.py).
# Each job (``scan``, ``dep-bounds``) emits its own
# array; an ``aggregate`` job merges them into the
# workflow-level output.
# review_status — JSON array of status objects consumed by the review
# comment assembler (scripts/ci/assemble_review_comment.py).
# critical_findings — "true" when the narrow critical-pattern scan found
# something. The review-label gate consumes this and
# owns the action-required result, so adding
# ``ci-reviewed`` can heal the run on rerun.
on:
workflow_call:
@ -41,6 +42,9 @@ on:
review_status:
description: JSON array of review status objects for the review comment assembler.
value: ${{ jobs.aggregate.outputs.review_status }}
critical_findings:
description: Whether the critical-pattern scan found a risk requiring maintainer review.
value: ${{ jobs.aggregate.outputs.critical_findings }}
permissions:
pull-requests: write
@ -54,6 +58,7 @@ jobs:
timeout-minutes: 15
outputs:
review_status: ${{ steps.emit-status.outputs.review_status }}
critical_findings: ${{ steps.scan.outputs.found }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -165,33 +170,15 @@ jobs:
python3 - <<'PYEOF'
import json, os
found = os.environ.get("FOUND", "") == "true"
if found:
with open("/tmp/findings.md", encoding="utf-8") as f:
detail = f.read()
status = [{
"source": "supply chain",
"results": [{
"kind": "error",
"title": "Critical supply chain risk",
"summary": "Critical supply chain risk patterns detected in this PR.",
"detail": detail,
"how_to_fix": "Review the flagged code carefully. If intentional, add the `ci-reviewed` label."
}]
}]
else:
status = []
# The review-label gate renders and blocks critical findings. Keep
# this scan a fact-finder so adding ci-reviewed can rerun the gate
# without requiring the scanner itself to fail again.
status = []
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(status)}\n")
PYEOF
- name: Fail on critical findings
if: steps.scan.outputs.found == 'true'
run: |
echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the review comment for details."
exit 1
dep-bounds:
name: Check PyPI dependency upper bounds
@ -279,12 +266,14 @@ jobs:
timeout-minutes: 15
outputs:
review_status: ${{ steps.merge.outputs.review_status }}
critical_findings: ${{ steps.merge.outputs.critical_findings }}
steps:
- name: Merge review statuses
id: merge
env:
SCAN_STATUS: ${{ needs.scan.outputs.review_status }}
DEP_STATUS: ${{ needs.dep-bounds.outputs.review_status }}
CRITICAL_FINDINGS: ${{ needs.scan.outputs.critical_findings }}
run: |
python3 - <<'PYEOF'
import json, os
@ -303,4 +292,5 @@ jobs:
with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f:
f.write(f"review_status={json.dumps(merged)}\n")
f.write("critical_findings=" + os.environ.get("CRITICAL_FINDINGS", "false") + "\n")
PYEOF

8
.gitignore vendored
View file

@ -44,7 +44,10 @@ run_datagen_sonnet.sh
source-data/*
run_datagen_megascience_glm4-6.sh
data/*
node_modules/
# No trailing slash: also matches node_modules SYMLINKS (worktrees often
# symlink node_modules to the main checkout; the dir-only pattern let one
# slip into a commit and break `npm ci` on CI with ENOTDIR).
node_modules
browser-use/
agent-browser/
# Private keys
@ -72,6 +75,8 @@ environments/benchmarks/evals/
# Web UI build output
hermes_cli/web_dist/
# Cross-process web UI build lock (flock target, always empty)
.web_ui_build.lock
apps/desktop/build/
apps/desktop/dist/
@ -164,3 +169,4 @@ apps/desktop/demo/
# PR body is the archive. See the hermes-agent-dev skill's
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
infographic/
native/fts5_cjk/*.so

View file

@ -998,7 +998,8 @@ Two shapes:
Roles:
- `role="leaf"` (default) — focused worker. Cannot call `delegate_task`,
`clarify`, `memory`, `send_message`, `execute_code`.
`clarify`, `memory`, `send_message`, `cronjob`. Retains `execute_code`
(programmatic tool calling).
- `role="orchestrator"` — retains `delegate_task` so it can spawn its
own workers. Gated by `delegation.orchestrator_enabled` (default true)
and bounded by `delegation.max_spawn_depth` (default 2).

View file

@ -1,6 +1,7 @@
graft skills
graft optional-skills
graft optional-mcps
graft hermes_cli/web_dist
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs

View file

@ -456,7 +456,7 @@ class HermesACPAgent(acp.Agent):
"tools": "List available tools",
"context": "Show conversation context info",
"reset": "Clear conversation history",
"compact": "Compress conversation context",
"compress": "Compress conversation context",
"steer": "Inject guidance into the currently running agent turn",
"queue": "Queue a prompt to run after the current turn finishes",
"version": "Show Hermes version",
@ -485,7 +485,7 @@ class HermesACPAgent(acp.Agent):
"description": "Clear conversation history",
},
{
"name": "compact",
"name": "compress",
"description": "Compress conversation context",
},
{
@ -1756,7 +1756,7 @@ class HermesACPAgent(acp.Agent):
"tools": self._cmd_tools,
"context": self._cmd_context,
"reset": self._cmd_reset,
"compact": self._cmd_compact,
"compress": self._cmd_compress,
"steer": self._cmd_steer,
"queue": self._cmd_queue,
"version": self._cmd_version,
@ -1898,7 +1898,7 @@ class HermesACPAgent(acp.Agent):
lines.append(
f"Compression: due now (threshold ~{threshold_tokens:,}"
+ (f", {threshold_pct:.0f}%" if threshold_pct else "")
+ "). Run /compact."
+ "). Run /compress."
)
else:
lines.append(
@ -1913,7 +1913,7 @@ class HermesACPAgent(acp.Agent):
if getattr(agent, "compression_enabled", True) is False:
lines.append("Compression is disabled for this agent.")
else:
lines.append("Tip: run /compact to compress manually before the threshold.")
lines.append("Tip: run /compress to compress manually before the threshold.")
return "\n".join(lines)
@ -1933,7 +1933,7 @@ class HermesACPAgent(acp.Agent):
return "Conversation history cleared. Agent session state reset failed; see logs."
return "Conversation history cleared."
def _cmd_compact(self, args: str, state: SessionState) -> str:
def _cmd_compress(self, args: str, state: SessionState) -> str:
if not state.history:
return "Nothing to compress — conversation is empty."
try:

View file

@ -28,7 +28,7 @@ import time
import uuid
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import urlparse, parse_qs, urlunparse
from urllib.parse import parse_qs, urlparse, urlunparse
from agent.context_compressor import ContextCompressor
from agent.iteration_budget import IterationBudget
@ -48,6 +48,7 @@ from agent.tool_guardrails import (
ToolGuardrailDecision,
)
from hermes_cli.config import cfg_get
from hermes_cli.route_identity import normalize_route_base_url
from hermes_cli.timeouts import get_provider_request_timeout
from hermes_constants import get_hermes_home
from utils import base_url_host_matches, is_truthy_value
@ -68,6 +69,129 @@ def _ra():
return run_agent
def _normalize_route_base_url(base_url: Any) -> str:
"""Canonicalize an endpoint URL for model-route identity comparisons."""
return normalize_route_base_url(base_url)
def _provider_default_routes(provider: str) -> set[str]:
"""Return known exact default routes for a canonical provider id."""
routes: set[str] = set()
try:
from hermes_cli.providers import HERMES_OVERLAYS, get_provider
overlay = HERMES_OVERLAYS.get(provider)
provider_def = get_provider(provider)
for value in (
getattr(overlay, "base_url_override", ""),
getattr(provider_def, "base_url", ""),
):
route = _normalize_route_base_url(value)
if route:
routes.add(route)
except Exception:
pass
try:
from providers import get_provider_profile
profile = get_provider_profile(provider)
route = _normalize_route_base_url(
getattr(profile, "base_url", "")
)
if route:
routes.add(route)
except Exception:
pass
try:
from hermes_cli.auth import PROVIDER_REGISTRY
from hermes_cli.models import normalize_provider as normalize_model_provider
from hermes_cli.providers import normalize_provider as normalize_registry_provider
for provider_id, config in PROVIDER_REGISTRY.items():
canonical_id = normalize_registry_provider(
normalize_model_provider(provider_id)
)
if canonical_id != provider:
continue
route = _normalize_route_base_url(
getattr(config, "inference_base_url", "")
)
if route:
routes.add(route)
except Exception:
pass
if provider == "gemini":
routes.update(
f"{route.rstrip('/')}/openai"
for route in list(routes)
)
return routes
def _context_route_mismatch(
configured_base_url: Any,
active_base_url: Any,
configured_provider: Any,
active_provider: Any,
*,
already_normalized: bool = False,
) -> bool:
"""Return whether a context pin's configured route differs from runtime."""
if already_normalized:
configured_route = str(configured_base_url or "")
active_route = str(active_base_url or "")
else:
configured_route = _normalize_route_base_url(configured_base_url)
active_route = _normalize_route_base_url(active_base_url)
if configured_route:
return configured_route != active_route
configured_provider = str(configured_provider or "").strip()
active_provider = str(active_provider or "").strip()
if not configured_provider:
return False
try:
from hermes_cli.models import normalize_provider as normalize_model_provider
configured_provider = normalize_model_provider(configured_provider)
active_provider = normalize_model_provider(active_provider)
except Exception:
configured_provider = configured_provider.lower()
active_provider = active_provider.lower()
try:
from hermes_cli.providers import normalize_provider as normalize_registry_provider
configured_provider = normalize_registry_provider(configured_provider)
active_provider = normalize_registry_provider(active_provider)
except Exception:
pass
if active_route:
configured_routes = _provider_default_routes(configured_provider)
return not configured_routes or active_route not in configured_routes
return bool(
configured_provider
and active_provider
and configured_provider != active_provider
)
def _normalize_custom_provider_name(value: Any) -> str:
"""Mirror runtime normalization for a requested custom-provider identity."""
return str(value or "").strip().lower().replace(" ", "-")
def _custom_provider_runtime_ids(value: Any) -> set[str]:
"""Return raw/menu identities that runtime accepts for a configured name."""
normalized = _normalize_custom_provider_name(value)
if not normalized:
return set()
return {normalized, f"custom:{normalized}"}
def _build_codex_gpt5_autoraise_notice(
autoraise: Dict[str, Any], context_length: Optional[int] = None
) -> str:
@ -1437,7 +1561,14 @@ def init_agent(
agent._memory_nudge_interval = 10
agent._turns_since_memory = 0
agent._iters_since_skill = 0
if not skip_memory:
# A flush/background agent may pass skip_memory=True to avoid spinning up an
# external memory *provider*, but if the caller also explicitly enables the
# "memory" toolset it still needs the built-in file-backed store — otherwise
# the memory tool dispatches with store=None and every call fails (#65429).
# So the built-in store is created unless memory is globally disabled, while
# the external-provider block below stays gated on skip_memory.
_memory_toolset_requested = "memory" in (agent.enabled_toolsets or [])
if not skip_memory or _memory_toolset_requested:
try:
mem_config = _agent_cfg.get("memory", {})
agent._memory_enabled = mem_config.get("memory_enabled", False)
@ -1657,6 +1788,34 @@ def init_agent(
compression_enabled = str(_compression_cfg.get("enabled", True)).lower() in {"true", "1", "yes"}
compression_target_ratio = float(_compression_cfg.get("target_ratio", 0.20))
compression_protect_last = int(_compression_cfg.get("protect_last_n", 20))
# Cap on compression retry rounds before a turn gives up with "max
# compression attempts reached" (compression.max_attempts). Hardcoding 3
# strands sessions that legitimately need more rounds — e.g. a restart
# history reload whose incompressible tool schemas keep the request
# estimate above the threshold even though the messages compress fine
# (the #62605 failure class). Default 3 preserves current behavior, so
# an unset key is behavior-neutral; validated >= 1, hard-capped at 10,
# and any non-int-like value falls back to 3. Booleans are rejected
# (bool subclasses int, so int(True) would silently become 1) and
# fractional floats are rejected rather than truncated — "4.7 attempts"
# is a config mistake, not a request for 4.
_raw_max_attempts = _compression_cfg.get("max_attempts", 3)
if isinstance(_raw_max_attempts, bool):
compression_max_attempts = 3
elif isinstance(_raw_max_attempts, int):
compression_max_attempts = _raw_max_attempts
elif isinstance(_raw_max_attempts, float):
compression_max_attempts = (
int(_raw_max_attempts) if _raw_max_attempts.is_integer() else 3
)
else:
try:
compression_max_attempts = int(str(_raw_max_attempts).strip())
except (TypeError, ValueError):
compression_max_attempts = 3
if compression_max_attempts < 1:
compression_max_attempts = 3
compression_max_attempts = min(compression_max_attempts, 10)
# protect_first_n is the number of non-system messages to protect at
# the head, in addition to the system prompt (which is always
# implicitly protected by the compressor). Floor at 0 — a value of
@ -1669,6 +1828,29 @@ def init_agent(
compression_abort_on_summary_failure = str(
_compression_cfg.get("abort_on_summary_failure", False)
).lower() in {"true", "1", "yes"}
# Per-model threshold overrides: keys are substring-matched against the
# model name (longest match wins). Empty dict = use the global threshold
# for all models (backward compatible).
_raw_model_thresholds = _compression_cfg.get("model_thresholds", {})
if isinstance(_raw_model_thresholds, dict):
compression_model_thresholds = {
str(k): float(v) for k, v in _raw_model_thresholds.items()
if isinstance(v, (int, float)) and not isinstance(v, bool)
}
else:
compression_model_thresholds = {}
# Absolute token cap: when set, compression triggers at the lower of
# the ratio-based threshold and this absolute count. Clamped to the
# model's context length at apply-time so a cap above the window is
# a no-op (ratio-based threshold wins).
compression_threshold_tokens = _compression_cfg.get("threshold_tokens")
if compression_threshold_tokens is not None:
try:
compression_threshold_tokens = int(compression_threshold_tokens)
if compression_threshold_tokens <= 0:
compression_threshold_tokens = None
except (TypeError, ValueError):
compression_threshold_tokens = None
# In-place compaction: when True, compress_context() rewrites the message
# list + rebuilds the system prompt WITHOUT rotating the session id (no
# parent_session_id chain, no `name #N` renumber). See #38763 and
@ -1757,8 +1939,9 @@ def init_agent(
)
_config_context_length = None
# Resolve custom_providers list once for reuse below (startup
# context-length override and plugin context-engine init).
# Resolve custom_providers once before route-scoping a global context pin:
# a named custom provider may keep its base URL only in this list rather
# than repeating it under ``model``.
try:
from hermes_cli.config import get_compatible_custom_providers
_custom_providers = get_compatible_custom_providers(_agent_cfg)
@ -1767,6 +1950,163 @@ def init_agent(
if not isinstance(_custom_providers, list):
_custom_providers = []
# ``model.context_length`` describes the configured default model. A
# process launched directly with ``--model`` / ``-m`` has already replaced
# ``agent.model`` before this initializer loads config, so carrying the
# default model's explicit window into that different runtime is stale. The
# live switch/fallback paths already clear this override; keep direct-start
# overrides consistent with them and let provider metadata resolve the
# active model's window instead.
if _config_context_length is not None and isinstance(_model_cfg, dict):
_configured_default_model = str(_model_cfg.get("default") or "").strip()
_configured_default_runtime_model = _configured_default_model
_active_runtime_model = agent.model
if _configured_default_model:
try:
from hermes_cli.model_normalize import normalize_model_for_provider
_configured_default_runtime_model = normalize_model_for_provider(
_configured_default_model, agent.provider
)
_active_runtime_model = normalize_model_for_provider(
agent.model, agent.provider
)
except Exception:
pass
_configured_provider = str(_model_cfg.get("provider") or "").strip()
_configured_base_url = _normalize_route_base_url(
_model_cfg.get("base_url")
)
_configured_provider_norm = _normalize_custom_provider_name(
_configured_provider
)
_custom_provider_candidate = bool(_configured_provider_norm)
_runtime_first_provider_ids = {
"auto",
"moa",
"vertex",
"google-vertex",
"vertex-ai",
"gcp-vertex",
"vertexai",
}
if _configured_provider_norm in _runtime_first_provider_ids:
_custom_provider_candidate = False
elif (
_custom_provider_candidate
and _configured_provider_norm != "custom"
and not _configured_provider_norm.startswith("custom:")
):
try:
from hermes_cli.auth import resolve_provider as resolve_auth_provider
_resolved_auth_provider = resolve_auth_provider(
_configured_provider_norm
)
_custom_provider_candidate = (
str(_resolved_auth_provider or "").strip().lower()
!= _configured_provider_norm
)
except Exception:
pass
if not _configured_base_url and _custom_provider_candidate:
_configured_custom_provider = _normalize_custom_provider_name(
_configured_provider
)
_user_providers = _agent_cfg.get("providers")
_disabled_custom_provider_ids: set[str] = set()
if isinstance(_user_providers, dict):
from hermes_cli.config import is_provider_enabled
for _provider_key, _provider_entry in _user_providers.items():
if not isinstance(_provider_entry, dict):
continue
_entry_name = str(
_provider_entry.get("name") or ""
).strip()
_entry_provider_ids = _custom_provider_runtime_ids(
_provider_key
) | _custom_provider_runtime_ids(_entry_name)
if not is_provider_enabled(_provider_entry):
_disabled_custom_provider_ids.update(
provider_id
for provider_id in _entry_provider_ids
if provider_id
)
continue
if _configured_custom_provider not in _entry_provider_ids:
continue
_configured_base_url = _normalize_route_base_url(
_provider_entry.get("api")
or _provider_entry.get("url")
or _provider_entry.get("base_url")
)
if _configured_base_url:
break
if not _configured_base_url:
for _provider_entry in _custom_providers:
if not isinstance(_provider_entry, dict):
continue
_entry_name = str(
_provider_entry.get("name") or ""
).strip()
_entry_provider_key = str(
_provider_entry.get("provider_key") or ""
).strip().lower()
_entry_provider_ids = _custom_provider_runtime_ids(
_entry_name
) | _custom_provider_runtime_ids(_entry_provider_key)
if (
_entry_provider_key
and _custom_provider_runtime_ids(_entry_provider_key)
& _disabled_custom_provider_ids
):
continue
if _configured_custom_provider not in _entry_provider_ids:
continue
_configured_base_url = _normalize_route_base_url(
_provider_entry.get("base_url")
)
if _configured_base_url:
break
_active_route_url = str(agent.base_url or "")
_requested_route_url = str(base_url or "")
if "?" in _requested_route_url.split("#", 1)[0]:
try:
_requested_parts = urlparse(_requested_route_url)
_requested_without_query = urlunparse(
_requested_parts._replace(query="")
)
if _normalize_route_base_url(
_requested_without_query
) == _normalize_route_base_url(_active_route_url):
_active_route_url = _requested_route_url
except (TypeError, ValueError):
pass
_active_base_url = _normalize_route_base_url(_active_route_url)
_route_mismatch = _context_route_mismatch(
_configured_base_url,
_active_base_url,
_configured_provider,
agent.provider,
already_normalized=True,
)
_model_mismatch = bool(
_configured_default_runtime_model
and _configured_default_runtime_model != _active_runtime_model
)
if _model_mismatch or _route_mismatch:
_ra().logger.debug(
"Ignoring model.context_length=%s for startup runtime %s at %s "
"(configured default is %s at %s)",
_config_context_length,
agent.model,
_active_base_url or agent.provider,
_configured_default_model,
_configured_base_url or _model_cfg.get("provider"),
)
_config_context_length = None
# Store for reuse by _check_compression_model_feasibility (auxiliary
# compression model context-length detection needs the same list).
agent._custom_providers = _custom_providers
@ -1789,11 +2129,11 @@ def init_agent(
# Surface a clear warning if the user set a context_length but it
# wasn't a valid positive int — the helper silently skips those.
if _config_context_length is None:
_target = agent.base_url.rstrip("/") if agent.base_url else ""
_target = _normalize_route_base_url(agent.base_url)
for _cp_entry in _custom_providers:
if not isinstance(_cp_entry, dict):
continue
_cp_url = (_cp_entry.get("base_url") or "").rstrip("/")
_cp_url = _normalize_route_base_url(_cp_entry.get("base_url"))
if _target and _cp_url == _target:
_cp_models = _cp_entry.get("models", {})
if isinstance(_cp_models, dict):
@ -1906,6 +2246,16 @@ def init_agent(
provider=agent.provider,
custom_providers=_custom_providers,
)
# Per-model threshold overrides are part of the explicit
# context-engine contract: assign them BEFORE the initial
# update_model() call so the first resolution (which derives
# threshold_percent/threshold_tokens for the initial model) already
# sees the overrides. Assigning after update_model() left the initial
# model on the engine's global threshold until the first /model
# switch. Engines that override update_model() own their own policy
# and may ignore the attribute.
if compression_model_thresholds:
agent.context_compressor.model_thresholds = compression_model_thresholds
agent.context_compressor.update_model(
model=agent.model,
context_length=_plugin_ctx_len,
@ -1932,6 +2282,8 @@ def init_agent(
api_mode=agent.api_mode,
abort_on_summary_failure=compression_abort_on_summary_failure,
max_tokens=agent.max_tokens,
model_thresholds=compression_model_thresholds,
threshold_tokens_cap=compression_threshold_tokens,
)
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
if callable(_bind_session_state):
@ -1942,6 +2294,7 @@ def init_agent(
agent.compression_enabled = compression_enabled
agent.compression_in_place = compression_in_place
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
agent.max_compression_attempts = compression_max_attempts
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@ -2142,7 +2495,11 @@ def init_agent(
_active_threshold_pct = getattr(
agent.context_compressor, "threshold_percent", compression_threshold
)
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
_cap_note = ""
_cap = getattr(agent.context_compressor, "threshold_tokens_cap", None)
if _cap and _cap > 0:
_cap_note = f" (capped at {_cap:,} tokens)"
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
# Notice with the exact opt-back-out command. Printed inline at startup

View file

@ -2412,6 +2412,24 @@ def _evict_old_screenshots(result: List[Dict[str, Any]]) -> None:
]
def _ensure_leading_user_turn(result: List[Dict[str, Any]]) -> None:
"""Anthropic requires messages[0] to have role=user.
After a second context compaction on the auto path the summary can be
emitted as role=assistant with nothing in front of it (the system prompt
lives outside messages[] or is extracted into the separate ``system``
param), so messages[0] ends up assistant and the Messages API rejects
the request with HTTP 400 often masked by a misleading
"tool_use ids were found without tool_result blocks" error (#52160).
Mirror the Bedrock Converse adapter, which unconditionally prepends a
minimal user turn when the first message is not user
(convert_messages_to_converse).
"""
if result and result[0].get("role") != "user":
result.insert(0, {"role": "user", "content": [{"type": "text", "text": " "}]})
def convert_messages_to_anthropic(
messages: List[Dict],
base_url: str | None = None,
@ -2470,6 +2488,7 @@ def convert_messages_to_anthropic(
_strip_orphaned_tool_blocks(result)
result = _merge_consecutive_roles(result)
_ensure_leading_user_turn(result)
_manage_thinking_signatures(result, base_url, model)
_evict_old_screenshots(result)

131
agent/battery.py Normal file
View file

@ -0,0 +1,131 @@
"""System-battery read-out for the CLI/TUI status bar.
Reads the host battery through ``psutil`` (already a Hermes dependency) and
exposes a compact, colour-coded label. Everything degrades to "unavailable"
when there is no battery (desktops, servers, VMs) or when the read fails, so
callers can render the result unconditionally and simply show nothing.
The status bar repaints often (every keystroke and on a ~1s idle refresh), so
:func:`read_battery` memoises the last reading for a few seconds instead of
hitting ``psutil`` on every frame.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from typing import Optional
@dataclass(frozen=True)
class BatteryStatus:
"""A single battery reading.
``available`` is False on machines without a battery (or when the read
failed). ``percent`` is clamped to 0-100. ``plugged`` is True when on AC
power, False on battery, and None when the platform can't tell.
"""
available: bool
percent: Optional[int] = None
plugged: Optional[bool] = None
@property
def charging(self) -> bool:
return bool(self.plugged)
UNAVAILABLE = BatteryStatus(available=False)
# Colour buckets, mirroring the status-bar context styles but inverted (a full
# battery is "good", an empty one is "critical").
CATEGORY_GOOD = "good"
CATEGORY_WARN = "warn"
CATEGORY_BAD = "bad"
CATEGORY_CRITICAL = "critical"
CATEGORY_DIM = "dim"
_CACHE_TTL_SECONDS = 8.0
_cache: Optional[tuple[float, BatteryStatus]] = None
def _read_battery_uncached() -> BatteryStatus:
try:
import psutil
except Exception:
return UNAVAILABLE
# ``sensors_battery`` is missing on some platforms/builds of psutil.
reader = getattr(psutil, "sensors_battery", None)
if reader is None:
return UNAVAILABLE
try:
batt = reader()
except Exception:
return UNAVAILABLE
if batt is None:
return UNAVAILABLE
percent: Optional[int] = None
raw_percent = getattr(batt, "percent", None)
if raw_percent is not None:
try:
percent = max(0, min(100, int(round(float(raw_percent)))))
except (TypeError, ValueError):
percent = None
plugged = getattr(batt, "power_plugged", None)
if plugged is not None:
plugged = bool(plugged)
return BatteryStatus(available=True, percent=percent, plugged=plugged)
def read_battery(use_cache: bool = True) -> BatteryStatus:
"""Return the current battery status (cached for a few seconds)."""
global _cache
if use_cache and _cache is not None:
ts, cached = _cache
if time.monotonic() - ts < _CACHE_TTL_SECONDS:
return cached
status = _read_battery_uncached()
_cache = (time.monotonic(), status)
return status
def clear_cache() -> None:
"""Drop the memoised reading (used by tests)."""
global _cache
_cache = None
def battery_category(status: BatteryStatus) -> str:
"""Bucket a reading into a colour category: good/warn/bad/critical/dim."""
if not status.available or status.percent is None:
return CATEGORY_DIM
# On AC power the level isn't a concern — always read as healthy.
if status.charging:
return CATEGORY_GOOD
pct = status.percent
if pct <= 10:
return CATEGORY_CRITICAL
if pct <= 20:
return CATEGORY_BAD
if pct <= 50:
return CATEGORY_WARN
return CATEGORY_GOOD
def battery_glyph(status: BatteryStatus) -> str:
"""Return the leading glyph: a bolt while charging, else a battery."""
return "\u26a1" if status.charging else "\U0001f50b" # ⚡ / 🔋
def format_battery(status: BatteryStatus) -> str:
"""Return a compact label like ``🔋 82%`` / ``⚡ 82%`` (empty if N/A)."""
if not status.available or status.percent is None:
return ""
return f"{battery_glyph(status)} {status.percent}%"

View file

@ -55,13 +55,12 @@ import json
import logging
import os
import re
import subprocess
import tempfile
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Optional
from hermes_cli._subprocess_compat import IS_WINDOWS, windows_hide_flags
from hermes_cli._subprocess_compat import bounded_git_probe
logger = logging.getLogger("hermes.coding_context")
@ -689,18 +688,14 @@ def _enabled_mcp_servers(config: Optional[dict[str, Any]]) -> list[str]:
def _git(cwd: Path, *args: str) -> str:
_popen_kwargs = {"creationflags": windows_hide_flags()} if IS_WINDOWS else {}
try:
out = subprocess.run(
["git", "-C", str(cwd), *args],
capture_output=True,
text=True,
timeout=_GIT_TIMEOUT,
**_popen_kwargs,
)
except (OSError, subprocess.SubprocessError):
return ""
return out.stdout.strip() if out.returncode == 0 else ""
"""``git -C <cwd> <args>`` → stripped stdout, or ``""`` on any failure.
Uses the shared :func:`bounded_git_probe` so the post-kill cleanup is bounded
on Windows a plain ``subprocess.run(timeout=...)`` here deadlocked the agent
turn inside ``build_coding_workspace_block`` when a killed git left a suspended
descendant holding the pipe handles (issue #66037).
"""
return bounded_git_probe(["git", "-C", str(cwd), *args], timeout=_GIT_TIMEOUT)
def _parse_status(porcelain: str) -> tuple[dict[str, str], dict[str, int]]:

File diff suppressed because it is too large Load diff

View file

@ -260,4 +260,19 @@ class ContextEngine(ABC):
(e.g. recalculate DAG budgets, switch summary models).
"""
self.context_length = context_length
# Apply per-model threshold overrides if set (longest substring match).
# Falls back to _config_threshold_percent (the raw config value) when
# no override matches. Plugin engines that override update_model() can
# call resolve_model_threshold() for the same logic.
from agent.context_compressor import resolve_model_threshold
if not hasattr(self, "_config_threshold_percent"):
# Snapshot the pre-override percent ONCE so repeated model
# switches fall back to the engine's configured value, not the
# previous model's override.
self._config_threshold_percent = self.threshold_percent
self._base_threshold_percent = resolve_model_threshold(
model, getattr(self, "model_thresholds", {}),
self._config_threshold_percent,
)
self.threshold_percent = self._base_threshold_percent
self.threshold_tokens = int(context_length * self.threshold_percent)

View file

@ -30,9 +30,12 @@ from __future__ import annotations
import copy
import inspect
import json
import logging
import math
import os
import tempfile
import time
import uuid
import threading
from datetime import datetime
@ -172,6 +175,43 @@ def _session_was_rotated_by_compression(session_db: Any, session_id: str) -> boo
)
def _emit_compression_attempt_telemetry(
agent: Any,
*,
started_at: float,
commit_status: str,
split_status: str,
failure_class: str | None = None,
) -> None:
"""Emit one content-free JSON log line for a compression attempt."""
try:
telemetry = getattr(agent.context_compressor, "_last_compression_telemetry", None)
if not isinstance(telemetry, dict):
telemetry = {}
payload = dict(telemetry)
payload.setdefault("event", "compression_attempt")
payload.setdefault("attempt_id", getattr(agent, "_compression_attempt_id", "") or uuid.uuid4().hex)
payload.setdefault("session_id", getattr(agent, "session_id", "") or "")
payload["total_duration_ms"] = int((time.monotonic() - started_at) * 1000)
payload["commit_status"] = commit_status
payload["split_status"] = split_status
if failure_class:
payload["failure_class"] = failure_class
payload.setdefault("chunking", False)
payload.setdefault("chunk_count", 0)
payload["fallback_used"] = bool(
payload.get("fallback_used")
or getattr(agent.context_compressor, "_last_summary_fallback_used", False)
or getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
)
logger.info(
"context compression attempt telemetry: %s",
json.dumps(payload, sort_keys=True, separators=(",", ":")),
)
except Exception as exc:
logger.debug("failed to emit compression attempt telemetry: %s", exc)
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@ -231,6 +271,51 @@ def _supported_compression_kwargs(
return {name: value for name, value in candidates.items() if name in parameters}
class _CompressionActivityHeartbeat:
"""Refresh the agent inactivity tracker while compression blocks in an aux call."""
def __init__(self, agent: Any, interval_seconds: float | None = None) -> None:
self._agent = agent
if interval_seconds is None:
interval_seconds = getattr(agent, "_compression_activity_heartbeat_interval", 60.0)
try:
interval_seconds = float(interval_seconds or 60.0)
except (TypeError, ValueError):
interval_seconds = 60.0
if not math.isfinite(interval_seconds):
interval_seconds = 60.0
self._interval_seconds = max(0.1, interval_seconds)
self._stop = threading.Event()
self._thread = threading.Thread(
target=self._run,
name="compression-activity-heartbeat",
daemon=True,
)
def start(self) -> "_CompressionActivityHeartbeat":
self._touch("context compression started")
self._thread.start()
return self
def stop(self, desc: str = "context compression completed") -> None:
self._stop.set()
if self._thread.is_alive() and threading.current_thread() is not self._thread:
self._thread.join(timeout=1.0)
self._touch(desc)
def _touch(self, desc: str) -> None:
try:
touch = getattr(self._agent, "_touch_activity", None)
if callable(touch):
touch(desc)
except Exception:
logger.debug("compression activity heartbeat touch failed", exc_info=True)
def _run(self) -> None:
while not self._stop.wait(self._interval_seconds):
self._touch("context compression in progress")
class _CompressionLockLeaseRefresher:
def __init__(
self,
@ -436,6 +521,19 @@ def check_compression_model_feasibility(agent: Any) -> None:
old_threshold = threshold
new_threshold = aux_context
agent.context_compressor.threshold_tokens = new_threshold
# ``tail_token_budget`` is derived from the trigger threshold, not
# directly from the model window. Keep it in lockstep with this
# just-in-time correction exactly as ContextCompressor.update_model()
# does. Leaving the old budget behind can make the tail's 1.5x soft
# ceiling wider than the lowered trigger, so compression preserves
# nearly the entire request and repeatedly re-fires.
summary_target_ratio = getattr(
agent.context_compressor, "summary_target_ratio", None
)
if isinstance(summary_target_ratio, (int, float)):
agent.context_compressor.tail_token_budget = int(
new_threshold * summary_target_ratio
)
# Keep threshold_percent in sync so future main-model
# context_length changes (update_model) re-derive from a
# sensible number rather than the original too-high value.
@ -445,6 +543,29 @@ def check_compression_model_feasibility(agent: Any) -> None:
new_threshold / main_ctx
)
safe_pct = int((aux_context / main_ctx) * 100) if main_ctx else 50
# The "lower the threshold" suggestion must survive the built-in
# trigger recomputation (#67422): _effective_threshold_percent()
# raises sub-75% values back up for main windows under 512K, and
# _compute_threshold_tokens() further applies the output-token
# reservation, the 64K floor, and the degenerate-window guard.
# Recommending a value those would override is silently ignored
# and this warning would reappear every session — so mirror the
# compressor's own math and only offer the option when the
# recomputed trigger actually fits the auxiliary model's context.
# External engines own compaction policy (#44439); the built-in
# floor doesn't apply to them, so keep the plain suggestion.
from agent.context_compressor import ContextCompressor as _CC
recomputed_threshold = None
if main_ctx and isinstance(agent.context_compressor, _CC):
recomputed_threshold = _CC._compute_threshold_tokens(
main_ctx,
_CC._effective_threshold_percent(main_ctx, safe_pct / 100),
getattr(agent.context_compressor, "max_tokens", None),
)
threshold_suggestion_viable = (
recomputed_threshold is None or recomputed_threshold <= aux_context
)
# Build human-readable "model (provider)" labels for both
# the main model and the compression model so users can
# tell at a glance which provider each side is actually
@ -478,15 +599,32 @@ def check_compression_model_feasibility(agent: Any) -> None:
f"{old_threshold:,} tokens. "
f"Auto-lowered this session's threshold to "
f"{new_threshold:,} tokens so compression can run.\n"
f" To make this permanent, edit config.yaml — either:\n"
f" 1. Use a larger compression model:\n"
f" auxiliary:\n"
f" compression:\n"
f" model: <model-with-{old_threshold:,}+-context>\n"
f" 2. Lower the compression threshold:\n"
f" compression:\n"
f" threshold: 0.{safe_pct:02d}"
)
if threshold_suggestion_viable:
msg += (
f" To make this permanent, edit config.yaml — either:\n"
f" 1. Use a larger compression model:\n"
f" auxiliary:\n"
f" compression:\n"
f" model: <model-with-{old_threshold:,}+-context>\n"
f" 2. Lower the compression threshold:\n"
f" compression:\n"
f" threshold: 0.{safe_pct:02d}"
)
else:
msg += (
f" To make this permanent, use a larger compression "
f"model in config.yaml:\n"
f" auxiliary:\n"
f" compression:\n"
f" model: <model-with-{old_threshold:,}+-context>\n"
f" (Lowering compression.threshold cannot help here — "
f"with {_main_label}'s {main_ctx:,}-token window, "
f"Hermes's small-context floor and output reservation "
f"would recompute the trigger to "
f"{recomputed_threshold:,} tokens, still above the "
f"compression model's {aux_context:,}.)"
)
agent._compression_warning = msg
agent._emit_status(msg)
logger.warning(
@ -600,7 +738,7 @@ def _is_real_user_message(message: Any) -> bool:
return False
from agent.context_compressor import ContextCompressor
return not ContextCompressor._is_context_summary_content(text)
return not ContextCompressor._is_synthetic_compression_user_turn(message)
def _merge_anchor_into_user_message(target: dict, anchor: dict) -> None:
@ -677,7 +815,10 @@ def _ensure_compressed_has_user_turn(original_messages: list, compressed: list)
"""Preserve human intent, not merely a synthetic user-role placeholder."""
if any(_is_real_user_message(message) for message in compressed):
return
from agent.context_compressor import _fresh_compaction_message_copy
from agent.context_compressor import (
COMPRESSION_CONTINUATION_USER_CONTENT,
_fresh_compaction_message_copy,
)
for message in reversed(original_messages):
if _is_real_user_message(message):
@ -688,13 +829,77 @@ def _ensure_compressed_has_user_turn(original_messages: list, compressed: list)
return
compressed.append({
"role": "user",
"content": (
"Continue from the compressed conversation context above. "
"This marker exists because no human user turn was available."
),
"content": COMPRESSION_CONTINUATION_USER_CONTENT,
})
_PENDING_CONTEXT_ENGINE_NOTIFICATION = (
"_pending_context_engine_compression_notification"
)
def _notify_context_engine_compression_complete(
agent: Any,
*,
new_session_id: str,
old_session_id: str,
) -> bool:
"""Notify the active context engine after a durable compression commit."""
callback = getattr(agent.context_compressor, "on_session_start", None)
if not callable(callback):
return False
try:
callback(
new_session_id,
boundary_reason="compression",
old_session_id=old_session_id,
platform=getattr(agent, "platform", None) or "cli",
conversation_id=getattr(agent, "_gateway_session_key", None),
)
except Exception:
# Context-engine hooks are observers. A callback failure must not undo
# history that the core or an outer host transaction already committed.
logger.debug(
"context engine on_session_start (compression) failed",
exc_info=True,
)
return False
return True
def _queue_context_engine_compression_notification(
agent: Any,
*,
new_session_id: str,
old_session_id: str,
) -> None:
"""Stage exactly one existing hook call for an outer host transaction."""
if callable(getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)):
raise RuntimeError("a compression notification is already pending")
def _notify() -> bool:
return _notify_context_engine_compression_complete(
agent,
new_session_id=new_session_id,
old_session_id=old_session_id,
)
setattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, _notify)
def finalize_context_engine_compression_notification(
agent: Any,
*,
committed: bool,
) -> bool:
"""Emit or discard a deferred notification; repeated calls are no-ops."""
pending = getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)
setattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None)
if not committed or not callable(pending):
return False
return bool(pending())
def compress_context(
agent: Any,
messages: list,
@ -704,6 +909,7 @@ def compress_context(
task_id: str = "default",
focus_topic: Optional[str] = None,
force: bool = False,
defer_context_engine_notification: bool = False,
) -> Tuple[list, str]:
"""Compress conversation context and split the session in SQLite.
@ -721,6 +927,8 @@ def compress_context(
by the manual ``/compress`` slash command so users can retry
immediately after an auto-compress abort. Auto-compress
callers use the default ``False``.
defer_context_engine_notification: Delay the existing context-engine
hook until a manual host commits its outer history transaction.
Returns:
``(compressed_messages, new_system_prompt)`` tuple. When
@ -729,6 +937,25 @@ def compress_context(
prompt the session is NOT rotated. Callers should detect the
no-op via ``len(returned) == len(input)`` and stop the retry loop.
"""
if (
defer_context_engine_notification
and callable(getattr(agent, _PENDING_CONTEXT_ENGINE_NOTIFICATION, None))
):
raise RuntimeError("a compression notification is already pending")
_attempt_started_at = time.monotonic()
_attempt_id = uuid.uuid4().hex
_trigger_source = "manual" if force else "auto"
try:
agent._compression_attempt_id = _attempt_id
setattr(agent.context_compressor, "_compression_telemetry_seed", {
"attempt_id": _attempt_id,
"session_id": agent.session_id or "",
"trigger_source": _trigger_source,
})
except Exception:
pass
# Codex app-server sessions: the codex agent owns the real thread context;
# Hermes' summarizer would only rewrite a local mirror without shrinking
# the actual thread (#36801). Route compaction to the app server's own
@ -935,6 +1162,18 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
try:
if hasattr(agent.context_compressor, "_begin_compression_telemetry"):
agent.context_compressor._begin_compression_telemetry(current_tokens=approx_tokens)
except Exception:
pass
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class="lock_contended",
)
return messages, _existing_sp
_lock_released = False
@ -1007,6 +1246,7 @@ def compress_context(
existing_prompt = agent._build_system_prompt(system_message)
return messages, existing_prompt
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
try:
if _lock_holder is not None:
_lock_refresher = _CompressionLockLeaseRefresher(
@ -1057,13 +1297,27 @@ def compress_context(
)
messages_before_compression = copy.deepcopy(messages)
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
compressed = compress_fn(messages, **compress_kwargs)
except BaseException:
except BaseException as _compress_exc:
# ANY exception after lock acquisition — memory hook, capability
# inspection, engine lookup, or compress() — must release the lock so
# the session isn't permanently blocked from future compression.
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression failed")
_activity_heartbeat = None
_release_lock()
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class=f"exception:{type(_compress_exc).__name__}",
)
raise
finally:
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression completed")
try:
# Capture boundary quality before session-rotation callbacks run. Built-in
@ -1095,6 +1349,16 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class=(
getattr(agent.context_compressor, "_last_summary_error", None)
and "summary_generation_aborted"
),
)
return messages, _existing_sp
finally:
_release_lock()
@ -1113,6 +1377,13 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class="no_progress",
)
_release_lock()
return messages, _existing_sp
@ -1193,7 +1464,10 @@ def compress_context(
new_system_prompt = agent._build_system_prompt(system_message)
agent._cached_system_prompt = new_system_prompt
_session_commit_succeeded = False
split_status = "not_applicable"
if agent._session_db:
split_status = "pending"
try:
# Trigger memory extraction on the current session before the
# transcript is rewritten (runs in BOTH modes — the logical
@ -1221,6 +1495,7 @@ def compress_context(
# WITHOUT destroying history, unlike a hard replace_messages).
# See #38763.
agent._session_db.archive_and_compact(agent.session_id, compressed)
split_status = "in_place_committed"
# Reset the flush identity set so the next turn's appends are
# diffed against the COMPACTED transcript: the compacted dicts
# are passed as conversation_history next turn and skipped by
@ -1337,6 +1612,7 @@ def compress_context(
agent._session_db_created = True
raise
agent._session_db_created = True
split_status = "rotated_committed"
# Carry a persistent /goal onto the continuation session.
# Compression mints a fresh child id; load_goal does a flat
# per-session lookup with no parent walk, so without this an
@ -1373,7 +1649,9 @@ def compress_context(
for message in compressed
if isinstance(message, dict)
}
_session_commit_succeeded = True
except Exception as e:
split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed"
# If the rotation rolled back to the parent (orphan-avoidance
# above), agent.session_id is the still-indexed parent and
# old_session_id was cleared — so this is recovery, not an
@ -1393,6 +1671,9 @@ def compress_context(
# id on rotation, the (unchanged) current id in-place.
_old_sid = locals().get("old_session_id")
_is_boundary = bool(_old_sid) or in_place
_context_engine_boundary_committed = _session_commit_succeeded and (
bool(_old_sid) or compacted_in_place
)
_boundary_parent = _old_sid or agent.session_id or ""
# Notify the context engine that a compaction boundary occurred. Plugin
@ -1401,17 +1682,19 @@ def compress_context(
# re-initializing fresh. See hermes-lcm#68. Built-in ContextCompressor
# ignores kwargs. Fires in BOTH modes: rotation passes old→new ids; in-place
# passes the SAME id (the boundary is real even though the id didn't move).
try:
if _is_boundary and hasattr(agent.context_compressor, "on_session_start"):
agent.context_compressor.on_session_start(
agent.session_id or "",
boundary_reason="compression",
if _context_engine_boundary_committed:
if defer_context_engine_notification:
_queue_context_engine_compression_notification(
agent,
new_session_id=agent.session_id or "",
old_session_id=_boundary_parent,
)
else:
_notify_context_engine_compression_complete(
agent,
new_session_id=agent.session_id or "",
old_session_id=_boundary_parent,
platform=getattr(agent, "platform", None) or "cli",
conversation_id=getattr(agent, "_gateway_session_key", None),
)
except Exception as _ce_err:
logger.debug("context engine on_session_start (compression): %s", _ce_err)
# Notify memory providers of the compaction boundary so provider-cached
# per-session state (Hindsight's _document_id, accumulated turn buffers,
@ -1511,6 +1794,18 @@ def compress_context(
agent.session_id or "none", _pre_msg_count, len(compressed),
f"{_compressed_est:,}",
)
_commit_status = "committed" if split_status in {"not_applicable", "in_place_committed", "rotated_committed"} else "aborted"
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status=_commit_status,
split_status=split_status,
failure_class=(
"session_split_failed"
if split_status in {"failed_not_indexed", "aborted"}
else None
),
)
return compressed, new_system_prompt
finally:
# Release the lock on the OLD session_id only AFTER rotation completed
@ -1581,7 +1876,20 @@ def _compress_context_via_codex_app_server(
except Exception:
pass
result = codex_session.compact_thread()
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
try:
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
result = codex_session.compact_thread()
except BaseException:
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression failed")
raise
if getattr(result, "interrupted", False) or getattr(result, "error", None):
_activity_heartbeat.stop("context compression failed")
else:
_activity_heartbeat.stop("context compression completed")
if getattr(result, "should_retire", False):
try:
codex_session.close()

View file

@ -33,6 +33,7 @@ from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
from agent.turn_context import (
_compression_warrants_another_preflight_pass,
build_turn_context,
compose_user_api_content,
reanchor_current_turn_user_idx,
@ -684,6 +685,15 @@ def run_conversation(
truncated_tool_call_retries = 0
truncated_response_parts: List[str] = []
compression_attempts = 0
# One resolved per-turn compression attempt cap, shared by every site that
# consumes ``compression_attempts``: the pre-API pressure gate, the
# overflow/413 retry handlers, and the post-tool compaction gate.
# Config-driven via compression.max_attempts (parsed + validated in
# agent_init); default 3 preserves the prior hardcoded behavior for
# objects without the attribute (older pickles / minimal stubs).
max_compression_attempts = getattr(agent, "max_compression_attempts", 3)
_last_preflight_pressure: Optional[int] = None
_preflight_compression_blocked = _ctx.preflight_compression_blocked
_turn_exit_reason = "unknown" # Diagnostic: why the loop ended
# Last composed answer intentionally held back by a verification gate. If
# that continuation consumes the remaining budget, this is the best
@ -696,6 +706,10 @@ def run_conversation(
# reused as the final response — not merely because any interim was
# streamed. (#65919 review: response-loss blocker)
_pending_verification_response_previewed = False
# If pre-API compression fires after MoA advisors have produced guidance,
# retain that ephemeral output and rebase it onto the compacted transcript
# on the next loop iteration. This prevents a second advisor fan-out.
pending_moa_prepared_request = None
# Per-turn tally of consecutive successful credential-pool token refreshes,
# keyed by (provider, pool-entry-id). A persistent upstream 401 lets
@ -1079,6 +1093,29 @@ def run_conversation(
# the OpenAI SDK. Sanitizing here prevents the 3-retry cycle.
_sanitize_messages_surrogates(api_messages)
# Build a persistent-MoA request before measuring compression pressure.
# MoA reference output is injected into the aggregator prompt, but it
# is deliberately ephemeral and therefore absent from ``messages``.
# Preparing here makes the pre-API guard measure the exact prompt the
# aggregator will receive; ``create()`` consumes this private prepared
# request later without running the advisors a second time.
_moa_prepared_request = None
if agent.provider == "moa":
_moa_completions = getattr(getattr(agent.client, "chat", None), "completions", None)
if pending_moa_prepared_request is not None:
_rebase_moa_request = getattr(_moa_completions, "rebase_prepared_request", None)
if callable(_rebase_moa_request):
_moa_prepared_request = _rebase_moa_request(
pending_moa_prepared_request, api_messages
)
pending_moa_prepared_request = None
if _moa_prepared_request is None:
_prepare_moa_request = getattr(_moa_completions, "prepare", None)
if callable(_prepare_moa_request):
_moa_prepared_request = _prepare_moa_request(api_messages)
if _moa_prepared_request is not None:
api_messages = _moa_prepared_request["messages"]
# One image-stripped message estimate feeds both figures. Was: a
# str(msg) char walk (re-serialized base64 every call) + a second
# messages walk inside estimate_request_tokens_rough. Tools added
@ -1125,6 +1162,37 @@ def run_conversation(
# LLM cooldown + anti-thrash guards (#11529). compression_attempts is a
# hard per-turn backstop shared with the overflow error handlers.
_compressor = agent.context_compressor
_preflight_threshold = int(
getattr(_compressor, "threshold_tokens", 0) or 0
)
# A previous mid-turn preflight pass deliberately continued the loop so
# API-only context and all sanitization could be rebuilt. Compare that
# fully assembled request with the fully assembled request that caused
# the pass. Raw ``messages`` are not equivalent here: they omit
# api_content/plugin injections, prefills, MoA context, and ephemeral
# system text.
_previous_preflight_pressure = _last_preflight_pressure
_last_preflight_pressure = None
if (
_previous_preflight_pressure is not None
and request_pressure_tokens >= _preflight_threshold
and not _compression_warrants_another_preflight_pass(
_previous_preflight_pressure,
request_pressure_tokens,
_preflight_threshold,
)
):
# Stop proactive retries for this turn without consuming the
# shared overflow-recovery budget. If the provider proves the
# request truly does not fit, its error handler may still compact
# with that stronger signal.
_preflight_compression_blocked = True
logger.warning(
"Pre-API compression made insufficient progress: ~%s -> "
"~%s request tokens; skipping additional preflight passes",
f"{_previous_preflight_pressure:,}",
f"{request_pressure_tokens:,}",
)
_defer_preflight = getattr(
_compressor, "should_defer_preflight_to_real_usage", lambda _t: False
)
@ -1134,25 +1202,30 @@ def run_conversation(
if (
agent.compression_enabled
and len(messages) > 1
and compression_attempts < 3
and compression_attempts < max_compression_attempts
and not _preflight_compression_blocked
and not _defer_preflight(request_pressure_tokens)
and not _compression_cooldown
and _compressor.should_compress(request_pressure_tokens)
):
if _moa_prepared_request is not None:
pending_moa_prepared_request = _moa_prepared_request
compression_attempts += 1
logger.info(
"Pre-API compression: ~%s request tokens >= %s threshold "
"(context=%s, attempt=%s/3)",
"(context=%s, attempt=%s/%s)",
f"{request_pressure_tokens:,}",
f"{int(getattr(_compressor, 'threshold_tokens', 0) or 0):,}",
f"{int(getattr(_compressor, 'context_length', 0) or 0):,}"
if getattr(_compressor, "context_length", 0) else "unknown",
compression_attempts,
max_compression_attempts,
)
agent._emit_status(
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
f"near the context/output limit. Compacting before the next model call."
)
_last_preflight_pressure = request_pressure_tokens
messages, active_system_prompt = agent._compress_context(
messages,
system_message,
@ -1216,7 +1289,6 @@ def run_conversation(
retry_count = 0
max_retries = agent._api_max_retries
_retry = TurnRetryState()
max_compression_attempts = 3
finish_reason = "stop"
response = None # Guard against UnboundLocalError if all retries fail
@ -1386,6 +1458,12 @@ def run_conversation(
if env_var_enabled("HERMES_DUMP_REQUESTS"):
agent._dump_api_request_debug(api_kwargs, reason="preflight")
# This object is private to the in-process MoA facade. Add it
# only after middleware, hooks, and debug dumps so none of them
# attempts to serialize it as part of the provider payload.
if _moa_prepared_request is not None and agent.provider == "moa":
api_kwargs["_moa_prepared_request"] = _moa_prepared_request
# Always prefer the streaming path — even without stream
# consumers. Streaming gives us fine-grained health
# checking (90s stale-stream detection, 60s read timeout)
@ -5167,7 +5245,12 @@ def run_conversation(
messages, tools=agent.tools or None
)
if agent.compression_enabled and _compressor.should_compress(_real_tokens):
if (
agent.compression_enabled
and compression_attempts < max_compression_attempts
and _compressor.should_compress(_real_tokens)
):
compression_attempts += 1
agent._safe_print(" ⟳ compacting context…")
messages, active_system_prompt = agent._compress_context(
messages, system_message,

View file

@ -2309,9 +2309,10 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
def _get_env_prefer_dotenv(key: str) -> str:
env_file = load_env()
raw = env_file.get(key, "").strip()
env_val = os.environ.get(key, "").strip()
scoped_value = (_get_secret(key, "") or "").strip()
# If .env contains an unresolved op:// reference, prefer the
# already-resolved value from os.environ (set by
# already-resolved value supplied by the active secret scope (or by
# os.environ in legacy single-profile mode), set by
# load_hermes_dotenv() -> apply_onepassword_secrets()). The raw
# "op://Vault/Item/field" string would otherwise win and every
# provider auth attempt would receive a URL instead of a key. This
@ -2319,9 +2320,9 @@ def _seed_from_env(provider: str, entries: List[PooledCredential]) -> Tuple[bool
# references straight into .env rather than the secrets.onepassword
# config block. For every non-op:// value the original
# .env-takes-precedence behaviour is preserved unchanged.
if raw.startswith("op://") and env_val:
return env_val
return raw or _get_secret(key, "") or env_val
if raw.startswith("op://") and scoped_value:
return scoped_value
return raw or scoped_value
# Honour user suppression — `hermes auth remove <provider> <N>` for an
# env-seeded credential marks the env:<VAR> source as suppressed so it

View file

@ -46,6 +46,9 @@ def build_write_denied_paths(home: str) -> set[str]:
# Top-level Anthropic PKCE credential store remains sensitive even
# when a profile is active; default/non-profile sessions still read it.
str(hermes_root / ".anthropic_oauth.json"),
# Bitwarden Secrets Manager encrypted disk cache.
str(hermes_home / "cache" / "bws_cache.enc.json"),
str(hermes_root / "cache" / "bws_cache.enc.json"),
os.path.join(home, ".netrc"),
os.path.join(home, ".pgpass"),
os.path.join(home, ".npmrc"),

View file

@ -905,7 +905,114 @@ class MoAChatCompletions:
except Exception as exc: # pragma: no cover - display must never break the turn
logger.debug("MoA reference_callback failed for %s: %s", event, exc)
def prepare(self, messages: list[dict[str, Any]]) -> dict[str, Any]:
"""Run the advisor fan-out and return the exact aggregator request.
The normal agent loop needs to measure this augmented prompt before its
compression gate. ``create()`` also uses this method for direct callers;
when the loop supplies the returned private object back to ``create()``,
the advisor fan-out is not repeated.
"""
return self.create(messages=messages, _moa_prepare_only=True)
def rebase_prepared_request(
self, prepared: dict[str, Any], messages: list[dict[str, Any]]
) -> dict[str, Any]:
"""Apply already-generated advisor guidance to a rebuilt API transcript.
Context compression changes the persisted transcript but not the
ephemeral advisor result. Reusing the guidance avoids a second costly
fan-out while keeping the aggregator request aligned with the compacted
history.
"""
guidance = prepared.get("guidance")
agg_messages = [dict(message) for message in messages]
if guidance:
_attach_reference_guidance(agg_messages, str(guidance))
return {**prepared, "messages": agg_messages}
def _call_prepared_aggregator(
self, prepared: dict[str, Any], api_kwargs: dict[str, Any]
) -> Any:
"""Send an already prepared MoA aggregator request exactly once."""
agg_messages = prepared["messages"]
aggregator = prepared["aggregator"]
aggregator_temperature = prepared["aggregator_temperature"]
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
max_tokens: Any = agg_kwargs.get("max_tokens")
tools: Any = agg_kwargs.get("tools")
extra_body: Any = agg_kwargs.get("extra_body")
# Record the exact aggregator INPUT (incl. the injected reference
# context) into the pending trace so a trace captures what the
# aggregator actually saw, not a reconstruction.
if self._pending_trace is not None:
self._pending_trace["aggregator_input_messages"] = agg_messages
self._pending_trace["aggregator_label"] = _slot_label(aggregator)
# The aggregator is the acting model. Resolve its slot to the provider's
# real runtime (base_url/api_key/api_mode) and call it through the same
# request-building path any model uses — so per-model wire-format
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
# temperature) applies identically to it. MoA imposes no output cap:
# max_tokens is passed through from the caller (normally None → omitted
# → the model's real maximum). The preset's old hardcoded 4096 default
# is gone — it truncated long syntheses.
# When the agent's streaming consumer calls us with stream=True, run the
# references first (above) and then return the aggregator's RAW token
# stream so the acting model's output reaches the user live. The consumer
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
# back to a non-streaming retry on error. The non-streaming path
# (stream=False) is unchanged — no stream/stream_options/timeout are
# forwarded, so its behavior is byte-for-byte identical to before.
stream = bool(api_kwargs.get("stream"))
stream_kwargs: dict[str, Any] = {}
if stream:
stream_kwargs["stream"] = True
stream_kwargs["stream_options"] = (
api_kwargs.get("stream_options") or {"include_usage": True}
)
# Forward the consumer's per-request (stream read) timeout so it
# actually governs the aggregator stream, not just call_llm's default.
if api_kwargs.get("timeout") is not None:
stream_kwargs["timeout"] = api_kwargs["timeout"]
_agg_response = call_llm(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=max_tokens,
tools=tools,
extra_body=extra_body,
# Prepared requests must retain the acting aggregator's reasoning
# policy exactly as the direct create() path does (#64187).
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)
# Non-streaming path (quiet mode / eval / subagents): the aggregator
# output is available inline, so capture it into the pending trace now.
# Streaming path: the aggregator's raw token stream is returned to the
# consumer live and its acting output lands as the turn's assistant
# message; the trace marks it streamed and points there.
if self._pending_trace is not None:
if stream:
self._pending_trace["aggregator_streamed"] = True
self._pending_trace["aggregator_output"] = None
else:
self._pending_trace["aggregator_streamed"] = False
try:
self._pending_trace["aggregator_output"] = _extract_text(_agg_response)
except Exception: # pragma: no cover - defensive
self._pending_trace["aggregator_output"] = None
return _agg_response
def create(self, **api_kwargs: Any) -> Any:
prepared_request = api_kwargs.pop("_moa_prepared_request", None)
if prepared_request is not None:
if not isinstance(prepared_request, dict):
raise TypeError("_moa_prepared_request must be a dict")
return self._call_prepared_aggregator(prepared_request, api_kwargs)
from hermes_cli.config import load_config
from hermes_cli.moa_config import resolve_moa_preset
@ -1065,6 +1172,7 @@ class MoAChatCompletions:
ref_count=_ref_count,
)
guidance: str | None = None
agg_messages = [dict(m) for m in messages]
if reference_outputs:
joined = "\n\n".join(
@ -1082,69 +1190,15 @@ class MoAChatCompletions:
)
_attach_reference_guidance(agg_messages, guidance)
if aggregator.get("provider") == "moa":
raise RuntimeError("MoA aggregator cannot be another MoA preset")
agg_kwargs = dict(api_kwargs)
agg_kwargs["messages"] = agg_messages
# Record the exact aggregator INPUT (incl. the injected reference
# context) into the pending trace so a trace captures what the
# aggregator actually saw, not a reconstruction.
if self._pending_trace is not None:
self._pending_trace["aggregator_input_messages"] = agg_messages
self._pending_trace["aggregator_label"] = _slot_label(aggregator)
# The aggregator is the acting model. Resolve its slot to the provider's
# real runtime (base_url/api_key/api_mode) and call it through the same
# request-building path any model uses — so per-model wire-format
# handling (anthropic_messages, max_completion_tokens, fixed/forbidden
# temperature) applies identically to it. MoA imposes no output cap:
# max_tokens is passed through from the caller (normally None → omitted
# → the model's real maximum). The preset's old hardcoded 4096 default
# is gone — it truncated long syntheses.
# When the agent's streaming consumer calls us with stream=True, run the
# references first (above) and then return the aggregator's RAW token
# stream so the acting model's output reaches the user live. The consumer
# reassembles chunks + tool_calls, runs stale-stream detection, and falls
# back to a non-streaming retry on error. The non-streaming path
# (stream=False) is unchanged — no stream/stream_options/timeout are
# forwarded, so its behavior is byte-for-byte identical to before.
stream = bool(api_kwargs.get("stream"))
stream_kwargs: dict[str, Any] = {}
if stream:
stream_kwargs["stream"] = True
stream_kwargs["stream_options"] = (
api_kwargs.get("stream_options") or {"include_usage": True}
)
# Forward the consumer's per-request (stream read) timeout so it
# actually governs the aggregator stream, not just call_llm's default.
if api_kwargs.get("timeout") is not None:
stream_kwargs["timeout"] = api_kwargs["timeout"]
_agg_response = call_llm(
task="moa_aggregator",
messages=agg_messages,
temperature=aggregator_temperature,
max_tokens=agg_kwargs.get("max_tokens"),
tools=agg_kwargs.get("tools"),
extra_body=agg_kwargs.get("extra_body"),
reasoning_config=_aggregator_reasoning_config(aggregator),
**stream_kwargs,
**_slot_runtime(aggregator),
)
# Non-streaming path (quiet mode / eval / subagents): the aggregator
# output is available inline, so capture it into the pending trace now.
# Streaming path: the aggregator's raw token stream is returned to the
# consumer live and its acting output lands as the turn's assistant
# message; the trace marks it streamed and points there.
if self._pending_trace is not None:
if stream:
self._pending_trace["aggregator_streamed"] = True
self._pending_trace["aggregator_output"] = None
else:
self._pending_trace["aggregator_streamed"] = False
try:
self._pending_trace["aggregator_output"] = _extract_text(_agg_response)
except Exception: # pragma: no cover - defensive
self._pending_trace["aggregator_output"] = None
return _agg_response
prepared_request = {
"messages": agg_messages,
"guidance": guidance,
"aggregator": aggregator,
"aggregator_temperature": aggregator_temperature,
}
if api_kwargs.pop("_moa_prepare_only", False):
return prepared_request
return self._call_prepared_aggregator(prepared_request, api_kwargs)
class MoAClient:

View file

@ -2666,16 +2666,61 @@ async def get_model_context_length_async(
)
def _is_cjk_token_dense_char(ch: str) -> bool:
code = ord(ch)
return (
0x1100 <= code <= 0x11FF # Hangul Jamo
or 0x2E80 <= code <= 0x9FFF # CJK radicals/ideographs
or 0xA960 <= code <= 0xA97F # Hangul Jamo Extended-A
or 0xAC00 <= code <= 0xD7AF # Hangul Syllables
or 0xF900 <= code <= 0xFAFF # CJK compatibility ideographs
or 0xFF00 <= code <= 0xFFEF # Fullwidth forms / halfwidth kana
)
# Same codepoint ranges as _is_cjk_token_dense_char, as a compiled character
# class so dense-char counting runs in C (``len(text) - len(re.sub(...))``)
# instead of a per-char Python loop. MUST stay in sync with
# _is_cjk_token_dense_char.
_CJK_DENSE_RE = re.compile(
"[\u1100-\u11ff" # Hangul Jamo
"\u2e80-\u9fff" # CJK radicals/ideographs
"\ua960-\ua97f" # Hangul Jamo Extended-A
"\uac00-\ud7af" # Hangul Syllables
"\uf900-\ufaff" # CJK compatibility ideographs
"\uff00-\uffef]" # Fullwidth forms / halfwidth kana
)
def estimate_tokens_rough(text: str) -> int:
"""Rough token estimate (~4 chars/token) for pre-flight checks.
"""Rough token estimate for pre-flight checks.
Uses ceiling division so short texts (1-3 chars) never estimate as
0 tokens, which would cause the compressor and pre-flight checks to
systematically undercount when many short tool results are present.
CJK/Hangul/Kana text is much denser than English under common LLM
tokenizers, so count those codepoints as roughly one token each instead
of applying the English-centric ~4 chars/token rule.
Perf: this runs on every message in every preflight/compaction walk,
including MB-scale tool outputs, so the common all-ASCII case must stay
O(1). ``str.isascii()`` is a flag check on CPython's compact unicode
representation (no scan), and the CJK counting itself is a single
C-level ``re.findall`` rather than a per-character Python loop.
"""
if not text:
return 0
return (len(text) + 3) // 4
text = str(text)
if text.isascii():
# O(1) fast path — ASCII text cannot contain token-dense CJK chars.
return (len(text) + 3) // 4
dense = len(text) - len(_CJK_DENSE_RE.sub("", text))
if not dense:
# Non-ASCII but no CJK (accents, Cyrillic, emoji, ...): keep the
# classic ~4 chars/token rule.
return (len(text) + 3) // 4
sparse = len(text) - dense
return dense + ((sparse + 3) // 4)
def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
@ -2687,12 +2732,12 @@ def estimate_messages_tokens_rough(messages: List[Dict[str, Any]]) -> int:
estimated at ~250K tokens and trigger premature context compression.
"""
_IMAGE_TOKEN_COST = 1500
total_chars = 0
text_tokens = 0
image_tokens = 0
for msg in messages:
total_chars += _estimate_message_chars(msg)
text_tokens += _estimate_message_tokens_without_images(msg)
image_tokens += _count_image_tokens(msg, _IMAGE_TOKEN_COST)
return ((total_chars + 3) // 4) + image_tokens
return text_tokens + image_tokens
def _count_image_tokens(msg: Dict[str, Any], cost_per_image: int) -> int:
@ -2754,6 +2799,35 @@ def _estimate_message_chars(msg: Dict[str, Any]) -> int:
return len(str(shadow))
def _estimate_message_tokens_without_images(msg: Dict[str, Any]) -> int:
"""Token estimate for a message shadow with image payloads stripped."""
if not isinstance(msg, dict):
return estimate_tokens_rough(str(msg))
shadow: Dict[str, Any] = {}
for k, v in msg.items():
if k == "_anthropic_content_blocks":
continue
if k == "content":
if isinstance(v, list):
cleaned = []
for part in v:
if isinstance(part, dict):
if part.get("type") in {"image", "image_url", "input_image"}:
cleaned.append({"type": part.get("type"), "image": "[stripped]"})
else:
cleaned.append(part)
else:
cleaned.append(part)
shadow[k] = cleaned
elif isinstance(v, dict) and v.get("_multimodal"):
shadow[k] = v.get("text_summary", "")
else:
shadow[k] = v
else:
shadow[k] = v
return estimate_tokens_rough(str(shadow))
def estimate_request_tokens_rough(
messages: List[Dict[str, Any]],
*,
@ -2770,7 +2844,7 @@ def estimate_request_tokens_rough(
"""
total = 0
if system_prompt:
total += (len(system_prompt) + 3) // 4
total += estimate_tokens_rough(system_prompt)
if messages:
total += estimate_messages_tokens_rough(messages)
if tools:

View file

@ -127,10 +127,16 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
1. Genuinely-global vars (``_is_global_env``) always read ``os.environ``
they are deployment settings, not profile secrets.
2. When a secret scope is installed (multiplexed turn), read from it; an
absent key returns ``default``. The scope is authoritative we do NOT
fall through to ``os.environ``, because in a multiplexer ``os.environ``
may hold another profile's value.
2. When a secret scope is installed (multiplexed turn), read from it. Under
multiplexing the scope is authoritative an absent key returns
``default`` and we do NOT fall through to ``os.environ``, because in a
multiplexer ``os.environ`` may hold another profile's value. When
multiplexing is OFF, a scope miss falls through to ``os.environ``:
single-profile deployments legitimately provide credentials via the
process environment (systemd ``Environment=``, secret-manager wrappers
like ``pass-cli run`` / ``op run``, plain shell exports) rather than
``<home>/.env``, and the scope installed unconditionally around e.g.
every cron job must stay a ``.env`` overlay, not a blindfold.
3. No scope installed:
- multiplex INACTIVE (default deployment): read ``os.environ``
identical to the legacy ``os.getenv`` behavior every caller had before.
@ -144,6 +150,17 @@ def get_secret(name: str, default: Optional[str] = None) -> Optional[str]:
scope = _SECRET_SCOPE.get()
if scope is not None:
val = scope.get(name)
if val is not None:
return val
if _MULTIPLEX_ACTIVE:
return default
# Multiplex off: the scope is an overlay over the process environment,
# not an isolation boundary — there is no other profile to leak from.
# Without this fallthrough, credentials injected only into the process
# environment vanish inside any set_secret_scope(...) block (the cron
# scheduler installs one around every job), so cron jobs send a
# placeholder API key and 401 while interactive turns keep working.
val = os.environ.get(name)
return val if val is not None else default
if _MULTIPLEX_ACTIVE:
@ -201,5 +218,18 @@ def build_profile_secret_scope(hermes_home: Path) -> Dict[str, str]:
global vars are intentionally NOT copied in ``get_secret`` reads those
from ``os.environ`` directly, so the scope holds only profile secrets.
"""
return load_env_file(Path(hermes_home) / ".env")
home = Path(hermes_home)
secrets = load_env_file(home / ".env")
try:
from hermes_cli.env_loader import get_secret_source_values
external_secrets = get_secret_source_values(home)
except Exception:
external_secrets = {}
for key, value in external_secrets.items():
if _is_global_env(key):
continue
secrets[key] = value
return secrets

View file

@ -29,6 +29,7 @@ is easier to lazy-install than a wheels-with-Rust-extension dependency.
from __future__ import annotations
import base64
import hashlib
import json
import logging
@ -46,6 +47,10 @@ import zipfile
from pathlib import Path
from typing import Dict, List, Optional, Tuple
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives.kdf.hkdf import HKDF
from agent.secret_sources._cache import (
CachedFetch as _CachedFetch,
DiskCache,
@ -92,6 +97,9 @@ _CACHE: Dict[_CacheKey, _CachedFetch] = {}
# accidentally commit BSM-sourced secrets. The atomic-write/0600/TTL mechanics
# live in agent.secret_sources._cache.DiskCache, shared with the other backends.
_DISK_CACHE_BASENAME = "bws_cache.json"
_ENCRYPTED_CACHE_BASENAME = "bws_cache.enc.json"
_ENCRYPTED_CACHE_VERSION = 1
_ENCRYPTED_CACHE_INFO = b"hermes-bws-encrypted-cache-v1"
def _cache_key_str(cache_key: _CacheKey) -> str:
@ -114,6 +122,13 @@ def _disk_cache_path(home_path: Optional[Path] = None) -> Path:
return _DISK_CACHE.path(home_path)
def _encrypted_disk_cache_path(home_path: Optional[Path] = None) -> Path:
"""Return the encrypted disk cache path under hermes_home/cache/."""
from agent.secret_sources._cache import resolve_cache_home
return resolve_cache_home(home_path) / "cache" / _ENCRYPTED_CACHE_BASENAME
# ---------------------------------------------------------------------------
# Binary discovery + lazy install
# ---------------------------------------------------------------------------
@ -349,6 +364,134 @@ def _token_fingerprint(token: str) -> str:
return hashlib.sha256(token.encode("utf-8")).hexdigest()[:16]
def _b64e(raw: bytes) -> str:
return base64.b64encode(raw).decode("ascii")
def _b64d(text: str) -> bytes:
return base64.b64decode(text.encode("ascii"), validate=True)
def _derive_encrypted_cache_key(access_token: str, salt: bytes) -> bytes:
"""Derive the local cache encryption key from the bootstrap BWS token."""
return HKDF(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
info=_ENCRYPTED_CACHE_INFO,
).derive(access_token.encode("utf-8"))
def _write_encrypted_disk_cache(
*,
cache_key: _CacheKey,
access_token: str,
entry: _CachedFetch,
home_path: Optional[Path] = None,
) -> None:
"""Persist an encrypted last-good cache entry atomically.
Best-effort by design: cache write failure must never block a fresh BWS
fetch. The raw BWS access token is not stored; it only derives the AES key.
"""
path = _encrypted_disk_cache_path(home_path)
try:
cache_dir = path.parent
cache_dir.mkdir(parents=True, exist_ok=True)
try:
os.chmod(cache_dir, 0o700)
except OSError:
pass
salt = os.urandom(16)
nonce = os.urandom(12)
serialized_key = _cache_key_str(cache_key)
key = _derive_encrypted_cache_key(access_token, salt)
plaintext = json.dumps(
{"secrets": entry.secrets, "fetched_at": entry.fetched_at},
separators=(",", ":"),
).encode("utf-8")
ciphertext = AESGCM(key).encrypt(
nonce, plaintext, serialized_key.encode("utf-8")
)
payload = {
"version": _ENCRYPTED_CACHE_VERSION,
"key": serialized_key,
"salt": _b64e(salt),
"nonce": _b64e(nonce),
"ciphertext": _b64e(ciphertext),
}
fd, tmp = tempfile.mkstemp(
prefix=".bws_cache_enc_", suffix=".tmp", dir=str(cache_dir)
)
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(payload, f)
os.chmod(tmp, 0o600)
os.replace(tmp, path)
# A successful encrypted write completes migration; remove the
# legacy plaintext cache so stale secrets cannot remain on disk.
try:
_disk_cache_path(home_path).unlink()
except FileNotFoundError:
pass
except OSError:
pass
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception: # noqa: BLE001 — best-effort cache only
return
def _read_encrypted_disk_cache(
*,
cache_key: _CacheKey,
access_token: str,
max_age_seconds: float,
home_path: Optional[Path] = None,
) -> Optional[_CachedFetch]:
"""Return a decrypted encrypted-cache entry if it matches and is in-window."""
if max_age_seconds <= 0:
return None
path = _encrypted_disk_cache_path(home_path)
try:
payload = json.loads(path.read_text(encoding="utf-8"))
if not isinstance(payload, dict):
return None
serialized_key = _cache_key_str(cache_key)
if payload.get("version") != _ENCRYPTED_CACHE_VERSION:
return None
if payload.get("key") != serialized_key:
return None
salt = _b64d(str(payload.get("salt", "")))
nonce = _b64d(str(payload.get("nonce", "")))
ciphertext = _b64d(str(payload.get("ciphertext", "")))
key = _derive_encrypted_cache_key(access_token, salt)
raw = AESGCM(key).decrypt(
nonce, ciphertext, serialized_key.encode("utf-8")
)
inner = json.loads(raw.decode("utf-8"))
if not isinstance(inner, dict):
return None
secrets = inner.get("secrets")
inner_fetched_at = inner.get("fetched_at")
if not isinstance(secrets, dict) or not isinstance(inner_fetched_at, (int, float)):
return None
entry_age = time.time() - float(inner_fetched_at)
if entry_age < 0 or entry_age > max_age_seconds:
return None
typed = {
k: v for k, v in secrets.items()
if isinstance(k, str) and isinstance(v, str)
}
return _CachedFetch(secrets=typed, fetched_at=float(inner_fetched_at))
except Exception: # noqa: BLE001 — cache miss on parse/decrypt/I/O errors
return None
def fetch_bitwarden_secrets(
*,
access_token: str,
@ -358,6 +501,8 @@ def fetch_bitwarden_secrets(
use_cache: bool = True,
server_url: str = "",
home_path: Optional[Path] = None,
encrypted_cache_enabled: bool = False,
encrypted_cache_max_stale_seconds: float = 0,
) -> Tuple[Dict[str, str], List[str]]:
"""Pull the secrets for ``project_id`` from Bitwarden Secrets Manager.
@ -369,12 +514,13 @@ def fetch_bitwarden_secrets(
(``https://vault.bitwarden.com``, US Cloud). This is plumbed into
the subprocess as ``BWS_SERVER_URL``.
Caching is a two-layer LRU: an in-process dict (for hot-reload paths
inside one process) and a disk-persisted JSON file under
``<hermes_home>/cache/bws_cache.json`` (for back-to-back CLI invocations).
Both share the same TTL. Pass ``home_path`` so disk cache lookups find
the right directory in tests / non-standard installs; otherwise we fall
back to ``$HERMES_HOME`` / ``~/.hermes``.
``cache_ttl_seconds`` controls the normal fresh cache. When
``encrypted_cache_enabled`` is true, fresh cache entries are written as
AES-GCM encrypted JSON instead of plaintext, and a last-good encrypted
entry may be used after NETWORK/TIMEOUT failures for up to
``encrypted_cache_max_stale_seconds``. This stale fallback is separate
from the fresh-cache TTL so operators can set ``cache_ttl_seconds: 0``
while still keeping an encrypted break-glass cache for offline startup.
Raises :class:`RuntimeError` for fatal conditions (missing binary,
auth failure, unparseable output). Callers in the env_loader path
@ -387,12 +533,20 @@ def fetch_bitwarden_secrets(
raise RuntimeError("Bitwarden project_id is empty")
cache_key = (_token_fingerprint(access_token), project_id, server_url or "")
if use_cache:
if use_cache and cache_ttl_seconds > 0:
cached = _CACHE.get(cache_key)
if cached and cached.is_fresh(cache_ttl_seconds):
return cached.secrets, []
# L2: disk cache. ~5ms on cache hit vs ~380ms for `bws secret list`.
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if encrypted_cache_enabled:
disk_cached = _read_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
max_age_seconds=cache_ttl_seconds,
home_path=home_path,
)
else:
disk_cached = _DISK_CACHE.read(cache_key, cache_ttl_seconds, home_path)
if disk_cached is not None:
# Promote into in-process cache so subsequent fetches in the
# same process skip the disk read too.
@ -408,11 +562,71 @@ def fetch_bitwarden_secrets(
"`hermes secrets bitwarden setup`."
)
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
try:
secrets, warnings = _run_bws_list(bws, access_token, project_id, server_url)
except RuntimeError as exc:
# Live fetch failed. Fall back to a stale disk cache ONLY for
# transport-level failures (network down, DNS error, transient BWS
# outage / timeout) — never for AUTH_FAILED or a malformed-output
# INTERNAL error, where serving old secrets would mask a real
# config/credential problem the caller needs to see. Without this
# fallback a fleet of bots sharing one BWS project all stop working
# on a single network blip.
#
# Two fallback tiers share the transport-only gate:
# * encrypted cache (opt-in) — AES-GCM payload keyed off the
# bootstrap token, with its own max_stale_seconds window. When
# enabled it is the ONLY fallback consulted: the whole point is
# that the at-rest payload is never plaintext, so we don't
# quietly serve the plaintext file alongside it.
# * plaintext disk cache (default) — the ordinary DiskCache file.
# `cache_ttl_seconds <= 0` means the caller opted out of caching
# entirely (DiskCache.read/write both short-circuit on it) —
# honor that on the fallback path too. `ttl_seconds=inf` on the
# read bypasses freshness (we explicitly want a stale hit); the
# caller's real TTL gates whether we even attempt the read.
kind = _classify_bws_error(str(exc))
if use_cache and kind in (ErrorKind.NETWORK, ErrorKind.TIMEOUT):
if encrypted_cache_enabled:
stale = _read_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
max_age_seconds=encrypted_cache_max_stale_seconds,
home_path=home_path,
)
if stale is not None:
age = max(0.0, time.time() - stale.fetched_at)
_CACHE[cache_key] = stale
return stale.secrets, [
f"bws live fetch failed ({exc}); falling back to "
f"stale ENCRYPTED disk cache ({int(age)}s old)"
]
elif cache_ttl_seconds > 0:
stale = _DISK_CACHE.read(cache_key, float("inf"), home_path)
if stale is not None:
age = max(0.0, time.time() - stale.fetched_at)
_CACHE[cache_key] = stale
return stale.secrets, [
f"bws live fetch failed ({exc}); "
f"falling back to stale disk cache ({int(age)}s old)"
]
raise
entry = _CachedFetch(secrets=secrets, fetched_at=time.time())
_CACHE[cache_key] = entry
if use_cache:
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
if cache_ttl_seconds > 0:
_CACHE[cache_key] = entry
if encrypted_cache_enabled:
# Encryption is the storage policy; max_stale_seconds only controls
# whether an outage may consume the last-good entry. Never fall
# back to the plaintext cache just because stale fallback is off.
_write_encrypted_disk_cache(
cache_key=cache_key,
access_token=access_token,
entry=entry,
home_path=home_path,
)
elif cache_ttl_seconds > 0:
_DISK_CACHE.write(cache_key, entry, cache_ttl_seconds, home_path)
return secrets, warnings
@ -538,6 +752,8 @@ def apply_bitwarden_secrets(
auto_install: bool = True,
server_url: str = "",
home_path: Optional[Path] = None,
encrypted_cache_enabled: bool = False,
encrypted_cache_max_stale_seconds: float = 0,
) -> FetchResult:
"""Pull secrets from BSM and set them on ``os.environ``.
@ -589,6 +805,8 @@ def apply_bitwarden_secrets(
cache_ttl_seconds=cache_ttl_seconds,
server_url=server_url,
home_path=home_path,
encrypted_cache_enabled=encrypted_cache_enabled,
encrypted_cache_max_stale_seconds=encrypted_cache_max_stale_seconds,
)
except RuntimeError as exc:
result.error = str(exc)
@ -658,9 +876,16 @@ class BitwardenSource(SecretSource):
},
"project_id": {"description": "BSM project UUID", "default": ""},
"cache_ttl_seconds": {
"description": "Disk+memory cache TTL; 0 disables",
"description": "Fresh disk+memory cache TTL; 0 disables fresh-cache reuse",
"default": 300,
},
"encrypted_cache": {
"description": "Encrypted last-good cache for network/timeout fallback",
"default": {
"enabled": False,
"max_stale_seconds": 0,
},
},
"override_existing": {
"description": "BSM values overwrite .env/shell values",
"default": True,
@ -714,6 +939,14 @@ class BitwardenSource(SecretSource):
except (TypeError, ValueError):
ttl = 300.0
encrypted_cfg = cfg.get("encrypted_cache")
encrypted_cfg = encrypted_cfg if isinstance(encrypted_cfg, dict) else {}
encrypted_enabled = bool(encrypted_cfg.get("enabled", False))
try:
encrypted_max_stale = float(encrypted_cfg.get("max_stale_seconds", 0))
except (TypeError, ValueError):
encrypted_max_stale = 0.0
try:
secrets, warnings = fetch_bitwarden_secrets(
access_token=access_token,
@ -722,6 +955,8 @@ class BitwardenSource(SecretSource):
cache_ttl_seconds=ttl,
server_url=str(cfg.get("server_url", "") or "").strip(),
home_path=home_path,
encrypted_cache_enabled=encrypted_enabled,
encrypted_cache_max_stale_seconds=encrypted_max_stale,
)
except RuntimeError as exc:
result.error = str(exc)
@ -779,14 +1014,19 @@ def _classify_bws_error(message: str) -> ErrorKind:
def clear_caches(home_path: Optional[Path] = None) -> None:
"""Drop in-process AND disk caches.
"""Drop in-process AND disk caches (plaintext and encrypted).
Used after a token rotation (`hermes secrets bitwarden token`) so the
next startup fetches fresh with the new credential instead of serving
a pull cached under the old token's fingerprint.
a pull cached under the old token's fingerprint. The encrypted cache
is keyed off the old token too, so it must go as well.
"""
_CACHE.clear()
_DISK_CACHE.clear(home_path)
try:
_encrypted_disk_cache_path(home_path).unlink()
except (FileNotFoundError, OSError):
pass
def _reset_cache_for_tests(home_path: Optional[Path] = None) -> None:

View file

@ -0,0 +1,488 @@
"""``command`` secret source — resolve secrets via a user-configured helper.
Ports the security semantics of the desktop app's TypeScript
``CommandSecretsProvider`` (hermes-desktop ``src/main/secrets/commandProvider.ts``)
to the Python agent. The helper command (e.g. ``keepassxc-cli``,
``secret-tool``, or a script that cats a tmpfs env file) comes from
``secrets.command`` in ``config.yaml`` NEVER from ``.env``, which holds
only secret values.
Security model (mirrors the TS provider line-for-line where it matters):
* The command string is the USER'S OWN configuration (same trust level as
the ``.env`` file they control), so it is run via ``/bin/sh -c <command>``.
* The requested key is passed to the child ONLY via the ``HERMES_SECRET_KEY``
environment variable it is NEVER interpolated into the shell string, so
a hostile key name (e.g. ``"; rm -rf ~``) is inert data, not code.
* Hard timeout (default 3s) + output cap (default 1 MiB); any failure
(non-zero exit, timeout, spawn failure, oversized output) degrades to
"no value" rather than raising.
* Failures log ONLY structured fields (exit code / signal / errno) to
stderr never the command string, the helper's stderr, or any secret
value. The helper's stderr is captured via a pipe and DISCARDED so its
diagnostics (which can carry secret material) never reach our stderr.
* The startup/apply path runs the helper exactly ONCE (with an empty
``HERMES_SECRET_KEY``) it is never called per-key in a loop, so a
helper that blocks (e.g. on a vault unlock prompt) can't be spawned
dozens of times.
* PLATFORM: the provider is POSIX-only (needs ``/bin/sh``). On Windows it
degrades to an empty result with a warning; Windows users stay on the
default ``env`` provider.
"""
from __future__ import annotations
import os
import platform
import re
import signal as _signal
import subprocess
import sys
from pathlib import Path
from typing import Dict, Optional
# Reuse the exact result shape the bitwarden source returns so
# hermes_cli.env_loader can consume both providers identically.
from agent.secret_sources.base import ErrorKind, SecretSource
from agent.secret_sources.bitwarden import FetchResult
__all__ = [
"FetchResult",
"apply_command_secrets",
"get_command_secret",
"list_command_secrets",
"parse_secret_output",
"unquote_dotenv_value",
]
# Hard cap so a hung helper can never wedge startup. Kept deliberately
# TIGHT (3s) — a configured helper MUST be fast and NON-INTERACTIVE
# (e.g. `keepassxc-cli` against an already-unlocked DB, `secret-tool
# lookup`, or `cat`-ing a tmpfs env file), NOT something that prompts
# for a touch/PIN.
_COMMAND_TIMEOUT_SECONDS = 3.0
# Defensive cap on helper output (1 MiB) — a misbehaving command can't OOM us.
_MAX_OUTPUT_BYTES = 1024 * 1024
# A line is treated as a KEY=VALUE pair only when it matches an env-key
# shape before the '='. Anchored; `.` does not cross newlines, so a
# multi-line blob never matches as a single "env-shaped" value.
_ENV_LINE = re.compile(r"^([A-Za-z_][A-Za-z0-9_]*)=(.*)$")
def _is_windows() -> bool:
return os.name == "nt" or platform.system() == "Windows"
def unquote_dotenv_value(raw: str) -> str:
"""Strip a single layer of matching surrounding quotes from a dotenv value.
Requires length >= 2 so a lone quote (``"``) is left intact rather than
collapsing to empty, and ``""``/``''`` correctly yield an empty string.
Shared by the single-key parser and the list path so both unquote
identically.
"""
t = raw.strip()
if len(t) >= 2 and (
(t.startswith('"') and t.endswith('"'))
or (t.startswith("'") and t.endswith("'"))
):
return t[1:-1]
return t
def parse_secret_output(stdout: str, wanted_key: str) -> Optional[str]:
"""Parse a secret-fetch helper's stdout. Supports BOTH shapes:
* a bare value (single secret): the whole trimmed stdout is the value.
* a dotenv blob (KEY=VALUE lines): parse them and return the entry for
``wanted_key``.
Mirrors the TS ``parseSecretOutput`` exactly, including the cross-key
misroute guard and the base64-padding disambiguation.
"""
text = stdout.replace("\r\n", "\n")
lines = text.split("\n")
# 1. Exact dotenv match wins: scan for a `wanted_key=...` line. This
# is deterministic and never returns another key's value.
dotenv_lines = [
line
for line in (raw.strip() for raw in lines)
if line and not line.startswith("#") and _ENV_LINE.match(line)
]
for line in dotenv_lines:
m = _ENV_LINE.match(line)
assert m is not None # filtered above
if m.group(1) == wanted_key:
value = unquote_dotenv_value(m.group(2))
# Whitespace-only (e.g. a quoted `K=" "` placeholder) is "no
# value": it would otherwise flow into an Authorization header
# → guaranteed 401.
return value if value.strip() != "" else None
# 2. The output is a multi-key dotenv dump that does NOT contain the
# wanted key → None, rather than mis-returning an unrelated line as
# a bare value. Only >=2 env-shaped lines count as a dump: a SINGLE
# non-matching env-shaped line falls through to the bare-value
# branch, because a bare secret can itself match the KEY=VALUE shape
# (e.g. base64 with '=' padding, "dGVzdA==") and must not be
# misclassified as a dump.
if len(dotenv_lines) > 1:
return None
# 3. Otherwise treat the whole output as a single bare value (a per-key
# helper that printed just the secret). Trim first so whitespace-only
# output (a ' '/'\t' placeholder entry) resolves to None, never a "key".
value = text.strip()
if value == "":
return None
# SECURITY (S2): a single env-shaped line for a DIFFERENT key must not
# be returned as the wanted secret. A sloppy helper (e.g. `head -1
# env-file`, or a grep that matched the wrong line) emitting
# `OTHER_KEY=realvalue` would otherwise flow — key name, '=' and the
# OTHER key's value — into an Authorization header sent to the WANTED
# key's endpoint: cross-provider credential leakage, not just a 401.
# Disambiguation from a bare base64 secret: base64 padding only ever
# produces an env-shaped line whose "value" part is empty or all '='
# (`dGVzdA==` → key `dGVzdA`, value `=`), so a non-trivial value part
# after a non-matching key means a misrouted dotenv entry → None.
env_shaped = _ENV_LINE.match(value)
if (
env_shaped
and env_shaped.group(1) != wanted_key
and re.fullmatch(r"=*", env_shaped.group(2).strip()) is None
):
return None
return value
def _run_helper(
command: str,
secret_key: str,
timeout_seconds: float,
max_output_bytes: int,
) -> Optional[str]:
"""Run the helper via ``/bin/sh -c`` and return its stdout, or None.
The key is passed as DATA via ``HERMES_SECRET_KEY`` never interpolated
into the command string. Both stdout and stderr are captured via pipes
(never inherited); stderr is discarded. Any failure logs structured
fields only and returns None never raises.
"""
if _is_windows():
print(
"[secrets:command] the 'command' provider is POSIX-only "
"(needs /bin/sh); resolving no value on Windows",
file=sys.stderr,
)
return None
env = os.environ.copy()
env["HERMES_SECRET_KEY"] = secret_key
try:
proc = subprocess.Popen( # noqa: S602 — command is the user's own config
["/bin/sh", "-c", command],
env=env,
stdin=subprocess.DEVNULL,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE, # captured and DISCARDED — never inherited
start_new_session=True, # so the hard timeout can kill the whole group
)
except OSError as exc:
print(
f"[secrets:command] helper failed to spawn; resolving no value: "
f"errno={exc.errno}",
file=sys.stderr,
)
return None
try:
stdout_bytes, _stderr_discarded = proc.communicate(timeout=timeout_seconds)
except subprocess.TimeoutExpired:
# Hard timeout: kill the whole process group (a helper script may
# have forked children that would otherwise keep the pipe open).
# POSIX-only by construction: _run_helper early-returns on Windows
# before ever spawning, so this line can't execute there.
try:
os.killpg(os.getpgid(proc.pid), _signal.SIGKILL) # windows-footgun: ok
except (ProcessLookupError, PermissionError, OSError):
proc.kill()
try:
proc.communicate(timeout=1.0)
except (subprocess.TimeoutExpired, ValueError, OSError):
pass
print(
f"[secrets:command] helper timed out after {timeout_seconds:g}s; "
f"resolving no value",
file=sys.stderr,
)
return None
if proc.returncode != 0:
# Structured fields ONLY — never the command string or the helper's
# stderr (either can carry secret material).
if proc.returncode < 0:
try:
sig = _signal.Signals(-proc.returncode).name
except ValueError:
sig = str(-proc.returncode)
code, signame = "?", sig
else:
code, signame = str(proc.returncode), "none"
print(
f"[secrets:command] helper failed; resolving no value: "
f"code={code} signal={signame}",
file=sys.stderr,
)
return None
if len(stdout_bytes) > max_output_bytes:
print(
f"[secrets:command] helper output exceeded the "
f"{max_output_bytes}-byte cap; resolving no value",
file=sys.stderr,
)
return None
return stdout_bytes.decode("utf-8", errors="replace")
def _parse_dotenv_map(stdout: str) -> Dict[str, str]:
"""Parse a KEY=VALUE blob into a map (the list/enumerate path).
Mirrors the TS ``list()``: only env-shaped lines contribute; comments
and non-matching lines are skipped. A bare-value helper yields ``{}``
per-key resolution via :func:`get_command_secret` still works.
"""
out: Dict[str, str] = {}
for raw in stdout.replace("\r\n", "\n").split("\n"):
line = raw.strip()
if not line or line.startswith("#"):
continue
m = _ENV_LINE.match(line)
if not m:
continue
out[m.group(1)] = unquote_dotenv_value(m.group(2))
return out
def get_command_secret(
*,
command: str,
key: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Optional[str]:
"""Resolve a single secret by running the helper with the key in
``HERMES_SECRET_KEY``. Returns None on any failure never raises."""
command = (command or "").strip()
if not command:
return None
stdout = _run_helper(command, key, timeout_seconds, max_output_bytes)
if stdout is None:
return None
return parse_secret_output(stdout, key)
def list_command_secrets(
*,
command: str,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
) -> Dict[str, str]:
"""Enumerate secrets by running the helper ONCE with an empty key.
Returns the dotenv map ONLY when the helper emits a KEY=VALUE blob;
a bare-value helper returns ``{}``. Never raises.
"""
command = (command or "").strip()
if not command:
return {}
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
return {}
return _parse_dotenv_map(stdout)
# ---------------------------------------------------------------------------
# Public entry point — called from hermes_cli.env_loader
# ---------------------------------------------------------------------------
def apply_command_secrets(
*,
command: str,
override_existing: bool = False,
timeout_seconds: float = _COMMAND_TIMEOUT_SECONDS,
max_output_bytes: int = _MAX_OUTPUT_BYTES,
home_path: Optional[Path] = None,
) -> FetchResult:
"""Run the helper once at startup and set its KEY=VALUE output on
``os.environ``.
LEGACY shim retained for API symmetry with ``apply_bitwarden_secrets``;
the startup path goes through :class:`CommandSource` + the registry
orchestrator instead (which owns precedence and the environ writes).
"""
result = FetchResult()
command = (command or "").strip()
if not command:
result.error = (
"secrets.command.enabled is true but secrets.command.command is "
"empty. Set the helper command in config.yaml."
)
return result
if _is_windows():
result.warnings.append(
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
return result
# The list/enumerate path: run the helper exactly ONCE with an empty
# HERMES_SECRET_KEY and parse its stdout as a dotenv blob.
stdout = _run_helper(command, "", timeout_seconds, max_output_bytes)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.warnings.append(
"helper command failed at startup; no secrets applied "
"(process env / .env values remain in effect)"
)
return result
secrets = _parse_dotenv_map(stdout)
result.secrets = secrets
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing applied at "
"startup (a bare-value helper still resolves single keys on demand)"
)
return result
for key, value in secrets.items():
if value.strip() == "":
# Whitespace-only placeholder entries are "no value" — applying
# them would flow into an Authorization header → guaranteed 401.
result.skipped.append(key)
continue
if not override_existing and os.environ.get(key):
# Process env / .env win — same precedence as bitwarden.
result.skipped.append(key)
continue
os.environ[key] = value
result.applied.append(key)
return result
# ---------------------------------------------------------------------------
# SecretSource adapter — the registry-facing wrapper around this module.
# ---------------------------------------------------------------------------
class CommandSource(SecretSource):
"""User-configured helper command as a registered secret source.
Composes with the other sources (Bitwarden, 1Password, plugins) through
the ``apply_all()`` orchestrator enable any combination simultaneously;
there is deliberately NO single-provider selector. ``fetch()`` only
fetches: precedence, ``override_existing`` semantics, conflict warnings,
and the ``os.environ`` writes are the orchestrator's job.
Bulk shape: the helper enumerates a KEY=VALUE blob in one run. Config::
secrets:
command:
enabled: true
command: "cat /run/user/1000/hermes-secrets.env"
# or per-vault CLIs: keepassxc-cli / secret-tool / pass / gpg —
# anything fast and NON-interactive.
"""
name = "command"
label = "Command helper"
shape = "bulk"
def config_schema(self) -> dict:
return {
"enabled": {"description": "Master switch", "default": False},
"command": {
"description": "Helper run via /bin/sh -c; must print a "
"KEY=VALUE blob on stdout",
"default": "",
},
"helper_timeout_seconds": {
"description": "Hard timeout for one helper run",
"default": _COMMAND_TIMEOUT_SECONDS,
},
"override_existing": {
"description": "Helper values overwrite .env/shell values",
"default": False,
},
}
def fetch(self, cfg: dict, home_path: Path) -> FetchResult:
cfg = cfg if isinstance(cfg, dict) else {}
result = FetchResult()
command = str(cfg.get("command") or "").strip()
if not command:
result.error = (
"secrets.command.enabled is true but secrets.command.command "
"is empty. Set the helper command in config.yaml."
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
if _is_windows():
result.error = (
"the 'command' secret source is POSIX-only (needs /bin/sh); "
"skipping on Windows"
)
result.error_kind = ErrorKind.NOT_CONFIGURED
return result
try:
timeout = float(cfg.get("helper_timeout_seconds",
_COMMAND_TIMEOUT_SECONDS))
except (TypeError, ValueError):
timeout = _COMMAND_TIMEOUT_SECONDS
stdout = _run_helper(command, "", timeout, _MAX_OUTPUT_BYTES)
if stdout is None:
# _run_helper already logged structured fields to stderr.
result.error = (
"helper command failed (see structured fields above); "
"no secrets applied"
)
result.error_kind = ErrorKind.INTERNAL
return result
secrets = _parse_dotenv_map(stdout)
if not secrets:
result.warnings.append(
"helper output was not a KEY=VALUE map; nothing to apply"
)
return result
result.secrets = secrets
return result
def remediation(self, kind, cfg: dict) -> str:
if kind == ErrorKind.NOT_CONFIGURED:
return (
"Set secrets.command.command in config.yaml to a fast, "
"non-interactive helper that prints KEY=VALUE lines."
)
if kind == ErrorKind.INTERNAL:
return (
"Run the helper manually in a shell to see its real error — "
"Hermes discards helper stderr so diagnostics can't leak "
"secret material."
)
return super().remediation(kind, cfg)

View file

@ -98,6 +98,9 @@ _OP_ENV_ALLOWLIST = (
"OP_ACCOUNT",
"OP_CONNECT_HOST",
"OP_CONNECT_TOKEN",
# Lets a user skip op's desktop-app integration probe (which can hang with
# no timeout on a wedged desktop container) and go straight to token auth.
"OP_LOAD_DESKTOP_APP_SETTINGS",
)
@ -172,16 +175,19 @@ def _validate_references(
def _auth_fingerprint(token_env: str) -> str:
"""SHA-256 prefix over the auth material `op` would use.
Folds in the service-account token, ``OP_ACCOUNT``, and *all*
``OP_SESSION_*`` vars (the names `op` actually exports for interactive
sessions ``OP_SESSION_<account_shorthand>``). Signing out and into a
different identity therefore changes the cache key, so a value cached under
a previous identity is never served under a new one. Never logged or
Folds in the service-account token, ``OP_ACCOUNT``, the 1Password Connect
``OP_CONNECT_HOST``/``OP_CONNECT_TOKEN``, and *all* ``OP_SESSION_*`` vars
(the names `op` actually exports for interactive sessions
``OP_SESSION_<account_shorthand>``). Signing out and into a different
identity therefore changes the cache key, so a value cached under a
previous identity is never served under a new one. Never logged or
displayed; the raw token never leaves this hash.
"""
parts: List[str] = [
f"token={os.environ.get(token_env, '')}",
f"account={os.environ.get('OP_ACCOUNT', '')}",
f"connect_host={os.environ.get('OP_CONNECT_HOST', '')}",
f"connect_token={os.environ.get('OP_CONNECT_TOKEN', '')}",
]
for key in sorted(os.environ):
if key.startswith("OP_SESSION_"):

View file

@ -29,6 +29,7 @@ from __future__ import annotations
import concurrent.futures
import logging
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List, Optional
@ -174,6 +175,13 @@ def _ensure_builtin_sources() -> None:
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled 1Password secret source",
exc_info=True)
try:
from agent.secret_sources.command import CommandSource
register_source(CommandSource())
except Exception: # noqa: BLE001 — never block startup
logger.warning("Failed to register bundled command secret source",
exc_info=True)
def _reset_registry_for_tests() -> None:
@ -275,6 +283,43 @@ def _ordered_enabled_sources(secrets_cfg: dict) -> List[SecretSource]:
return enabled
def _active_profile_name(home_path: Optional[Path]) -> str:
"""Best-effort active profile name for profile-scoped secret aliases.
A named profile's HERMES_HOME is ``~/.hermes/profiles/<name>``; the
default profile (``~/.hermes``) returns "".
"""
if home_path is not None:
resolved = Path(home_path)
if resolved.parent.name == "profiles" and resolved.name:
return resolved.name
for env_name in ("HERMES_PROFILE_NAME", "HERMES_PROFILE"):
value = os.environ.get(env_name, "").strip()
if value and value != "default":
return value
return ""
# Only credential-shaped names get auto-aliased — a random profile-suffixed
# var should not silently hydrate an unsuffixed name.
_ALIAS_SUFFIXES = ("_API_KEY", "_TOKEN", "_SECRET", "_KEY", "_PASSWORD")
def _profile_alias_target(var: str, profile: str) -> Optional[str]:
"""Map ``FOO_<PROFILE>`` to ``FOO`` for the active profile when safe."""
if not profile:
return None
suffix = "_" + profile.replace("-", "_").upper()
if not var.endswith(suffix):
return None
alias = var[: -len(suffix)]
if not alias or not is_valid_env_name(alias):
return None
if not any(alias.endswith(s) for s in _ALIAS_SUFFIXES):
return None
return alias
def apply_all(secrets_cfg: dict, home_path: Path,
environ: Optional[Dict[str, str]] = None) -> ApplyReport:
"""Fetch from every enabled source and apply the merged result to env.
@ -283,14 +328,24 @@ def apply_all(secrets_cfg: dict, home_path: Path,
Precedence per env var (most-specific intent wins):
1. Pre-existing env (.env / shell) unless the winning source has
1. ``secrets.preserve_existing`` names a pre-existing env value always
wins for these, even against a source with ``override_existing: true``
(escape hatch for profile-local platform secrets, #58073).
2. Pre-existing env (.env / shell) unless the winning source has
``override_existing: true``.
2. Mapped sources, in configured order.
3. Bulk sources, in configured order.
3. Mapped sources, in configured order.
4. Bulk sources, in configured order.
First claim wins. A later source that also carries the var gets a
``skipped_claimed`` entry and a conflict warning never a silent
clobber, and ``override_existing`` never applies across sources.
Profile aliasing (#51447): when running under a named profile, an applied
var ``FOO_<PROFILE>`` (credential-shaped suffixes only) also hydrates the
canonical ``FOO`` so platform adapters and plugins that read fixed env
names see the profile's value. The alias obeys the same protected /
preserve / claimed / override guards and is disabled with
``secrets.profile_alias: false``.
"""
import os as _os
@ -302,6 +357,14 @@ def apply_all(secrets_cfg: dict, home_path: Path,
if not enabled:
return report
preserve_raw = secrets_cfg.get("preserve_existing")
preserve: frozenset = frozenset(
n.strip() for n in preserve_raw if isinstance(n, str) and n.strip()
) if isinstance(preserve_raw, list) else frozenset()
alias_enabled = bool(secrets_cfg.get("profile_alias", True))
profile = _active_profile_name(home_path) if alias_enabled else ""
# Mapped sources outrank bulk sources regardless of list order:
# an explicit VAR→ref binding is stronger intent than a project dump.
ordered = ([s for s in enabled if s.shape == "mapped"]
@ -321,6 +384,15 @@ def apply_all(secrets_cfg: dict, home_path: Path,
except Exception: # noqa: BLE001
pass
# Every var any source supplies directly — an alias never shadows a
# var that some source will (or tried to) claim by its real name.
supplied_directly: set = set()
for _, _, result in fetches:
if result.ok:
supplied_directly.update(
v for v in result.secrets if isinstance(v, str)
)
# Apply phase — sequential, first-wins, fully attributed.
claimed: Dict[str, str] = {} # var → source name that won it
for source, cfg, result in fetches:
@ -336,15 +408,14 @@ def apply_all(secrets_cfg: dict, home_path: Path,
except Exception: # noqa: BLE001
override = False
for var, value in result.secrets.items():
if not isinstance(var, str) or not isinstance(value, str):
continue
def _try_apply(var: str, value: str, *, is_alias: bool = False) -> bool:
"""Apply one var through the shared guard chain. True = applied."""
if not is_valid_env_name(var):
sr.skipped_invalid.append(var)
continue
return False
if var in protected:
sr.skipped_protected.append(var)
continue
return False
if var in claimed:
sr.skipped_claimed.append(var)
report.conflicts.append(
@ -352,11 +423,14 @@ def apply_all(secrets_cfg: dict, home_path: Path,
f"{source.name} also supplies it (first source wins — "
"remove one binding or reorder secrets.sources)"
)
continue
return False
existed = bool(env.get(var))
if existed and var in preserve:
sr.skipped_existing.append(var)
return False
if existed and not override:
sr.skipped_existing.append(var)
continue
return False
env[var] = value
claimed[var] = source.name
sr.applied.append(var)
@ -366,5 +440,21 @@ def apply_all(secrets_cfg: dict, home_path: Path,
shape=source.shape,
overrode_env=existed,
)
return True
for var, value in result.secrets.items():
if not isinstance(var, str) or not isinstance(value, str):
continue
applied = _try_apply(var, value)
if not applied or not profile:
continue
alias = _profile_alias_target(var, profile)
if alias and alias not in supplied_directly and alias not in claimed:
if _try_apply(alias, value, is_alias=True):
result.warnings.append(
f"applied profile-scoped {var} as {alias} "
f"(active profile {profile!r})"
)
return report

View file

@ -300,14 +300,22 @@ def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
return subscription_state_from_payload(payload, portal_url=portal_url)
def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>`` from a state.
def subscription_manage_url(
state: SubscriptionState, tier_id: Optional[str] = None
) -> Optional[str]:
"""Build ``{portal_origin}/manage-subscription?org_id=<id>[&plan=<tier_id>]``.
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
Portal decided Jun 23), which routes upgradeCheckout / downgradescheduled
internally. ``org_id`` pins the page to the right account in multi-org
situations. Returns ``None`` when no portal URL is resolvable.
``tier_id`` (the stable ``tiers[]`` id, never a name/slug) is appended as
``plan=`` so the portal preselects the picked plan only for a NEW
subscription / upgrade the user chose. The portal validates it and simply
ignores an unknown tier, so the CLI appends unconditionally when a tier was
picked (parity with the TUI's ``?plan=``).
"""
from urllib.parse import urlencode, urlsplit, urlunsplit
@ -319,13 +327,91 @@ def subscription_manage_url(state: SubscriptionState) -> Optional[str]:
except Exception:
return None
if not parts.scheme or not parts.netloc:
if parts.scheme not in ("http", "https") or not parts.netloc:
return None
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
from urllib.parse import parse_qsl
# Preserve unrelated portal query params; org_id / plan are contract-owned
# (org_id before plan — insertion order is the emitted query order).
params = dict(parse_qsl(parts.query, keep_blank_values=True))
params.pop("org_id", None)
params.pop("plan", None)
if state.org_id:
params["org_id"] = state.org_id
if tier_id:
params["plan"] = tier_id
query = urlencode(params)
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
# =============================================================================
# Shared plan-catalog helpers (consumed by the CLI Free catalog + paid picker)
# =============================================================================
def _format_dollars_grouped(value: Optional[Decimal]) -> str:
"""``$1,000`` / ``$1,234.50`` — the whole-vs-fractional rule of
``billing_view.format_money`` but thousands-grouped, matching the TUI's
``toLocaleString('en-US')``.
The shared ``format_money`` is intentionally ungrouped (and asserted so across
other surfaces), so plan-catalog rows group locally to mirror the TUI.
"""
if value is None:
return ""
if value == value.to_integral_value():
return f"${format(value.to_integral_value(), ',f')}"
return f"${format(value.quantize(Decimal('0.01')), ',f')}"
def selectable_tiers(state: SubscriptionState) -> list[SubscriptionTier]:
"""Enabled paid tiers other than the current plan, cheapest first.
One derivation shared by the CLI Free catalog and the paid change picker:
``is_enabled and not is_current and tier_order > 0`` (free / no-sub excluded
dropping to free is a cancellation), sorted by ``tier_order``.
"""
return sorted(
(
t
for t in (state.tiers or ())
if t.is_enabled and not t.is_current and (t.tier_order or 0) > 0
),
key=lambda t: t.tier_order or 0,
)
def format_tier_row(tier: SubscriptionTier) -> str:
"""``name · $X/mo[ · $Y credits/mo]`` — the shared plan-catalog row.
Mirrors the TUI Free rows (``subscriptionOverlay.tsx``): thousands-grouped
money, and the ``$Y credits/mo`` suffix ONLY when monthly credits are present
and > 0 (a ``None`` / zero-credits tier hides it never ``· credits/mo`` or
``· $0 credits/mo``).
"""
row = f"{tier.name} · {_format_dollars_grouped(tier.dollars_per_month)}/mo"
mc = tier.monthly_credits
if mc is not None and mc > 0:
row += f" · {_format_dollars_grouped(mc)} credits/mo"
return row
def is_upgrade(state: SubscriptionState, tier_id: str) -> bool:
"""True when ``tier_id`` ranks above the current plan by ``tier_order``.
Prefers the active subscription's tier; falls back to the ``tiers[]``
``is_current`` marker (what the picker derives from), else 0 (free).
"""
orders = {t.tier_id: (t.tier_order or 0) for t in (state.tiers or ())}
cur_id = state.current.tier_id if state.current else None
if cur_id is not None and cur_id in orders:
cur_order = orders[cur_id]
else:
cur_order = next((t.tier_order or 0 for t in (state.tiers or ()) if t.is_current), 0)
return orders.get(tier_id, 0) > cur_order
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================

View file

@ -92,6 +92,79 @@ class TurnResult:
_TURN_ABORTED_MARKERS = ("<turn_aborted>", "<turn_aborted/>")
def _notification_scope_ids(
note: dict,
) -> tuple[Optional[str], Optional[str]]:
"""Extract the thread/turn identity carried by a notification."""
if not isinstance(note, dict):
return None, None
params = note.get("params") or {}
if not isinstance(params, dict):
return None, None
nested_turn = params.get("turn") or {}
nested_item = params.get("item") or {}
observed_thread_id = params.get("threadId") or params.get("thread_id")
if observed_thread_id is None and isinstance(nested_turn, dict):
observed_thread_id = (
nested_turn.get("threadId")
or nested_turn.get("thread_id")
)
if observed_thread_id is None and isinstance(nested_item, dict):
observed_thread_id = (
nested_item.get("threadId")
or nested_item.get("thread_id")
)
observed_turn_id = params.get("turnId") or params.get("turn_id")
if observed_turn_id is None and isinstance(nested_turn, dict):
observed_turn_id = nested_turn.get("id") or nested_turn.get("turnId")
if observed_turn_id is None and isinstance(nested_item, dict):
observed_turn_id = (
nested_item.get("turnId")
or nested_item.get("turn_id")
)
return observed_thread_id, observed_turn_id
def _notification_belongs_to_turn(
note: dict,
*,
thread_id: Optional[str],
turn_id: Optional[str],
) -> bool:
"""Return whether a multiplexed notification belongs to this turn.
Codex app-server can carry parent and hosted subagent threads over one
JSON-RPC connection. An explicitly foreign child or
stale-turn event must not mutate the active parent's transcript or mark
its turn complete. Unscoped notifications remain accepted for protocol
compatibility.
"""
if not isinstance(note, dict):
return False
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
if (
thread_id is not None
and observed_thread_id is not None
and str(observed_thread_id) != str(thread_id)
):
return False
if (
turn_id is not None
and observed_turn_id is not None
and str(observed_turn_id) != str(turn_id)
):
return False
return True
def _coerce_turn_input_text(user_input: Any) -> str:
"""Collapse Hermes/OpenAI rich content into app-server text input.
@ -505,6 +578,17 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
if not _notification_belongs_to_turn(
pending,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification while draining "
"server request: method=%s",
pending.get("method"),
)
continue
# Mirror the main notification-handling block below so
# display events surface and stay in step with projector
# state. Without this, item/started / item/completed
@ -550,6 +634,16 @@ class CodexAppServerSession:
continue
method = note.get("method", "")
if not _notification_belongs_to_turn(
note,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification: method=%s", method
)
continue
if self._on_event is not None:
try:
self._on_event(note)
@ -737,6 +831,48 @@ class CodexAppServerSession:
continue
method = note.get("method", "")
observed_thread_id, observed_turn_id = _notification_scope_ids(note)
if result.turn_id is None:
if method == "turn/started":
if (
observed_thread_id is not None
and str(observed_thread_id) != str(self._thread_id)
):
logger.debug(
"ignoring foreign compact turn/started: thread=%s",
observed_thread_id,
)
continue
if observed_turn_id is None:
logger.debug(
"ignoring compact turn/started without a turn id"
)
continue
result.turn_id = str(observed_turn_id)
elif observed_turn_id is not None or method in {
"item/completed",
"turn/completed",
}:
# thread/compact/start does not return a turn id. Until the
# new turn/started arrives, any terminal/projectable event
# is stale or cannot be safely attributed to this compaction.
logger.debug(
"ignoring codex notification before compact turn start: "
"method=%s",
method,
)
continue
if not _notification_belongs_to_turn(
note,
thread_id=self._thread_id,
turn_id=result.turn_id,
):
logger.debug(
"ignoring foreign codex notification: method=%s", method
)
continue
if self._on_event is not None:
try:
self._on_event(note)

View file

@ -210,6 +210,23 @@ def _compression_made_progress(
return orig_tokens > 0 and new_tokens < orig_tokens * 0.95
def _compression_warrants_another_preflight_pass(
orig_tokens: int, new_tokens: int, threshold_tokens: int
) -> bool:
"""Whether an over-threshold request merits another immediate summary.
Row-count progress is enough to prove that a compression boundary was real,
but not enough to justify another expensive pass before trying the provider.
Continue only when the request remains over threshold *and* the previous pass
materially reduced its estimated token pressure (>5%).
"""
return (
new_tokens >= threshold_tokens
and orig_tokens > 0
and new_tokens < orig_tokens * 0.95
)
def _should_run_preflight_estimate(
messages: List[Dict[str, Any]],
protect_first_n: int,
@ -263,6 +280,8 @@ class TurnContext:
plugin_user_context: str = ""
# External-memory prefetch result, reused across loop iterations.
ext_prefetch_cache: str = ""
# Turn-start preflight already proved an immediate retry ineffective.
preflight_compression_blocked: bool = False
def build_turn_context(
@ -570,6 +589,7 @@ def build_turn_context(
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
# issue #27405 (a few very large messages slipping past the count gate).
_preflight_compressed = False
_preflight_compression_blocked = False
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
@ -650,7 +670,13 @@ def build_turn_context(
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
for _pass in range(3):
# Preflight passes honor the same configured per-turn cap
# (compression.max_attempts) as the loop's compression sites;
# default 3 preserves the prior hardcoded behavior.
_max_preflight_passes = max(
1, int(getattr(agent, "max_compression_attempts", 3) or 3)
)
for _pass in range(_max_preflight_passes):
_orig_len = len(messages)
_orig_tokens = _preflight_tokens
messages, active_system_prompt = agent._compress_context(
@ -669,6 +695,7 @@ def build_turn_context(
if not _compression_made_progress(
_orig_len, len(messages), _orig_tokens, _preflight_tokens
):
_preflight_compression_blocked = True
break # Cannot compress further: neither rows nor tokens moved
conversation_history = conversation_history_after_compression(
agent, messages
@ -680,6 +707,19 @@ def build_turn_context(
agent._mute_post_response = False
if not _compressor.should_compress(_preflight_tokens):
break
if not _compression_warrants_another_preflight_pass(
_orig_tokens,
_preflight_tokens,
_compressor.threshold_tokens,
):
_preflight_compression_blocked = True
logger.warning(
"Preflight compression made insufficient progress: "
"~%s -> ~%s request tokens; skipping additional passes",
f"{_orig_tokens:,}",
f"{_preflight_tokens:,}",
)
break
if _preflight_compressed:
# Compression rebuilt the list (tail messages are fresh compaction
@ -905,4 +945,5 @@ def build_turn_context(
should_review_memory=should_review_memory,
plugin_user_context=plugin_user_context,
ext_prefetch_cache=ext_prefetch_cache,
preflight_compression_blocked=_preflight_compression_blocked,
)

View file

@ -9,7 +9,7 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { test } from '@playwright/test'
import { allowErrorBanners, test } from './test'
import {
type DeadBackendFixture,
@ -26,6 +26,12 @@ test.afterAll(async () => {
})
test.describe('boot failure with dead backend', () => {
test.beforeEach(() => {
// These tests deliberately trigger boot errors — error banners
// (notifyError → [role="alert"]) are expected, not failures.
allowErrorBanners()
})
test('app shows error state', async () => {
// Inject a fake boot error so the backend resolution "fails" with a
// controlled error message. This is the only reliable way to trigger

View file

@ -11,7 +11,7 @@
* Run from the nix devshell:
* npm exec playwright test e2e/boot.spec.ts --reporter=list
*/
import { expect, test } from '@playwright/test'
import { expect, test } from './test'
import {
type MockBackendFixture,

View file

@ -8,7 +8,7 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { test } from '@playwright/test'
import { test } from './test'
import {
type MockBackendFixture,

View file

@ -28,6 +28,7 @@ import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer } from './mock-server'
import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
@ -96,7 +97,7 @@ export interface Sandbox {
cleanup: () => void
}
function createSandbox(prefix: string): Sandbox {
export function createSandbox(prefix: string): Sandbox {
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
const hermesHome = path.join(root, 'hermes-home')
const userDataDir = path.join(root, 'electron-user-data')
@ -140,7 +141,7 @@ function createSandbox(prefix: string): Sandbox {
* mock inference server. The provider is set as the active model provider so
* the desktop app skips onboarding and boots straight to the chat UI.
*/
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
const config = `# Auto-generated by E2E test fixtures
@ -165,7 +166,7 @@ providers:
* Write a minimal .env with the mock API key. The key_env in config.yaml
* references MOCK_API_KEY, so the backend resolves credentials from here.
*/
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
export function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
const envPath = path.join(hermesHome, '.env')
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
}
@ -193,7 +194,7 @@ function writeEmptyConfig(hermesHome: string): void {
* - HERMES_DESKTOP_APP_NAME unique-ish per test (avoids single-instance lock)
* - XDG_RUNTIME_DIR ensure Electron has a writable runtime dir on Linux
*/
function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
export function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
const clean = stripCredentials(process.env)
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
@ -252,7 +253,7 @@ function assertDistBuilt(): void {
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
* As a fallback, use the node_modules/.bin/electron from the desktop package.
*/
function findElectron(): string {
export function findElectron(): string {
// In dev mode, we use the `electron` binary directly (not the packaged app).
// The dev:electron script in package.json does exactly this: `electron .`
// after building. We replicate that here.
@ -283,7 +284,7 @@ function findElectron(): string {
* @param env - the process environment (already has HERMES_HOME etc.)
* @returns the ElectronApplication + first Page
*/
async function launchDesktop(
export async function launchDesktop(
env: Record<string, string>,
): Promise<{ app: ElectronApplication; page: Page }> {
assertDistBuilt()
@ -305,6 +306,10 @@ async function launchDesktop(
const page = await app.firstWindow()
// Install the error-banner guard so any [role="alert"] that appears
// during a test is collected and surfaced in afterEach.
installErrorBannerGuard(page)
return { app, page }
}
@ -514,6 +519,7 @@ export async function setupPackagedApp(): Promise<PackagedAppFixture> {
})
const page = await app.firstWindow()
installErrorBannerGuard(page)
return {
app,

View file

@ -1,4 +1,4 @@
import { expect, test } from '@playwright/test'
import { expect, test } from './test'
import {
PACKAGED_BINARY_PATH,

View file

@ -15,7 +15,7 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import { expect, test } from './test'
import {
type MockBackendFixture,

View file

@ -9,7 +9,7 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import { expect, test } from './test'
import {
type NoProviderFixture,

View file

@ -0,0 +1,56 @@
#!/usr/bin/env python3
"""Seed a Hermes state.db with a session exported from a real conversation.
Usage: seed_session_db.py <state_db_path> <fixture_json_path>
Creates the database with the full SessionDB schema (if it doesn't exist)
and imports the session from the JSON fixture. Uses the real
SessionDB.import_sessions() so the data shape matches what the desktop
backend expects.
"""
import json
import sys
from pathlib import Path
# Add the repo root to sys.path so we can import hermes_state.
# The script is invoked from apps/desktop/e2e/ — repo root is ../../..
repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(repo_root))
from hermes_state import SessionDB # noqa: E402
def main():
if len(sys.argv) != 3:
print(f"Usage: {sys.argv[0]} <state_db_path> <fixture_json_path>", file=sys.stderr)
sys.exit(1)
db_path = Path(sys.argv[1])
fixture_path = Path(sys.argv[2])
db_path.parent.mkdir(parents=True, exist_ok=True)
with open(fixture_path, "r", encoding="utf-8") as f:
session_data = json.load(f)
db = SessionDB(db_path=db_path)
result = db.import_sessions([session_data])
if not result.get("ok"):
print(f"Import failed: {result}", file=sys.stderr)
sys.exit(1)
imported = result.get("imported", 0)
skipped = result.get("skipped", 0)
errors = result.get("errors", [])
if errors:
print(f"Import had errors: {errors}", file=sys.stderr)
sys.exit(1)
print(f"Seeded {imported} session(s), skipped {skipped}{db_path}")
db.close()
if __name__ == "__main__":
main()

166
apps/desktop/e2e/test.ts Normal file
View file

@ -0,0 +1,166 @@
/**
* Extended Playwright test fixture that auto-fails any test if an error
* banner (notification toast with role="alert") appears in the DOM.
*
* The desktop app surfaces errors as `[data-slot="alert"][role="alert"]`
* elements (see components/notifications.tsx). When one appears during a
* test, it means something went wrong (resume failed, boot error, etc.)
* the test should fail with the error message, not silently pass while
* an error toast is visible on screen.
*
* Usage: import { test, expect } from './test' instead of
* '@playwright/test'. The guard is auto-installed on every page no
* per-spec setup needed.
*/
import { test as base, expect, type Page, type ElectronApplication, _electron } from '@playwright/test'
// Track error messages per test so afterEach can assert + report.
const seenErrors: string[] = []
let activePage: Page | null = null
// When true, the afterEach guard skips the error-banner check.
// Set by tests that deliberately trigger error states (e.g. boot-failure).
let errorBannersAllowed = false
/**
* Opt out of the error-banner guard for the current test. Call in
* test.beforeEach or at the top of a test body when error banners are
* expected (e.g. boot-failure tests that deliberately trigger errors).
*/
export function allowErrorBanners(): void {
errorBannersAllowed = true
}
/**
* Install the error-banner guard on a page. Watches for `[role="alert"]`
* elements appearing in the DOM. When one is found, records its text
* content for the afterEach assertion.
*
* Exported so e2e fixture functions (which create pages via _electron.launch)
* can install the guard on their custom pages the default Playwright `page`
* fixture override only catches pages created by Playwright itself, not
* pages created by the test's own Electron launch.
*/
export function installErrorBannerGuard(page: Page): void {
activePage = page
// Clear any errors from a previous test when a new page is created.
seenErrors.length = 0
// Use a MutationObserver to catch error banners as they appear.
// We inject this via addInitScript so it runs before any app code.
page.addInitScript(() => {
const seen: string[] = []
;(window as unknown as { __ERROR_BANNER_GUARD__?: string[] }).__ERROR_BANNER_GUARD__ = seen
const observer = new MutationObserver(() => {
const alerts = document.querySelectorAll('[role="alert"]')
for (const alert of alerts) {
const text = (alert.textContent ?? '').trim()
if (text && !seen.includes(text)) {
seen.push(text)
}
}
})
// Start observing once the DOM is ready.
if (document.body) {
observer.observe(document.body, { childList: true, subtree: true })
} else {
document.addEventListener('DOMContentLoaded', () => {
observer.observe(document.body, { childList: true, subtree: true })
})
}
})
// Also poll via evaluate — MutationObserver via addInitScript can miss
// elements that appear during the Electron renderer's initial mount
// (before the observer is installed). A periodic poll catches those.
page.on('console', () => {
// Console messages are not errors — but we keep the listener to
// ensure the page context is active for our evaluate calls.
})
}
/**
* Check for error banners that appeared during the test. Called in
* afterEach via the custom fixture below. Also exported so specs that
* manage their own page lifecycle can call it directly.
*/
export async function collectErrorBanners(page: Page | null): Promise<string[]> {
if (!page) {
return []
}
try {
// Read errors collected by the MutationObserver in the page context.
const pageErrors = await page.evaluate(() => {
const w = window as unknown as { __ERROR_BANNER_GUARD__?: string[] }
return [...(w.__ERROR_BANNER_GUARD__ ?? [])]
})
// Also do a final DOM scan for any alert elements still visible.
const domAlerts = await page
.locator('[role="alert"]')
.allTextContents()
.catch(() => [] as string[])
const all = [...new Set([...pageErrors, ...domAlerts.map(t => t.trim()).filter(Boolean)])]
seenErrors.push(...all)
return [...new Set(seenErrors)]
} catch {
// Page might be closed — return whatever we have.
return [...new Set(seenErrors)]
}
}
// Extended test fixture: wraps the default page with the error guard.
export const test = base.extend({
// Override the page fixture to auto-install the guard.
page: async ({ page }, use) => {
installErrorBannerGuard(page)
await use(page)
},
})
// afterEach: fail the test if any error banners appeared.
// Always fires — even if the test already failed for another reason.
// An error banner often IS the root cause (e.g. "resume failed" from a
// backend bug), and suppressing it when the test also fails on an
// assertion hides the real problem.
//
// Uses `activePage` (set by installErrorBannerGuard) instead of the
// default `page` fixture — Electron tests create their own page via
// app.firstWindow(), so the default `page` fixture is undefined.
base.afterEach(async ({}, testInfo) => {
const wasAllowed = errorBannersAllowed
// Reset for the next test.
errorBannersAllowed = false
if (wasAllowed) {
// Test opted out — clear any collected errors without asserting.
seenErrors.length = 0
return
}
const errors = await collectErrorBanners(activePage)
if (errors.length > 0) {
throw new Error(
`Error banner(s) appeared during test "${testInfo.title}":\n` +
errors.map(e => `${e}`).join('\n'),
)
}
})
// Reset for the next test file.
base.afterAll(async () => {
seenErrors.length = 0
activePage = null
})
export { expect, type Page, type ElectronApplication, _electron }

View file

@ -80,6 +80,19 @@ import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { runNativeLogin } from './native-oauth-login'
import {
nativeRefreshUrl,
parseTokenResponse,
resolveLoginStrategy,
tokenNeedsRefresh,
type NativeTokenSet
} from './native-oauth'
import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth
} from './native-auth-decisions'
import { scanGitRepos } from './git-repo-scan'
import {
fileDiffVsHead,
@ -3840,6 +3853,12 @@ function fetchJson(url, token, options: any = {}) {
headers: {
'Content-Type': contentType,
'X-Hermes-Session-Token': token,
// RFC 8252 native flow authenticates the gated gateway with a bearer
// token instead of the loopback session-token header. When
// ``options.bearer`` is set we send Authorization: Bearer <token>;
// the gateway's OAuth gate verifies it via the provider stack with
// no cookie involved.
...(options.bearer ? { Authorization: `Bearer ${options.bearer}` } : {}),
...(body ? { 'Content-Length': String(body.length) } : {})
}
},
@ -5693,10 +5712,186 @@ function fetchJsonViaOauthSession(url, options: any = {}) {
})
}
// ---------------------------------------------------------------------------
// RFC 8252 native-app tokens (system-browser + loopback + PKCE).
//
// Unlike the cookie flow, the native flow hands the desktop opaque bearer
// tokens it holds itself: the access token authenticates REST via
// ``Authorization: Bearer`` (which the gateway gate now accepts) and mints WS
// tickets the same way, so NO browser session cookie or embedded webview is
// involved. Tokens are persisted encrypted at rest via Electron ``safeStorage``
// (OS keychain) keyed by gateway base URL, and refreshed via
// ``/auth/native/refresh`` before expiry. This is the desktop half of the
// feature; the server half lives in hermes_cli/dashboard_auth/native_flow.py.
// ---------------------------------------------------------------------------
// In-memory cache of decrypted native tokens, keyed by normalized base URL.
// Backed by the encrypted on-disk store so it survives restarts.
const _nativeTokens = new Map<string, NativeTokenSet>()
function _nativeTokenStorePath() {
// Co-located with the connection config under userData; one JSON file mapping
// baseUrl → { encoding, value } safeStorage payloads.
return path.join(app.getPath('userData'), 'native-oauth-tokens.json')
}
function _readNativeTokenStore(): Record<string, any> {
try {
const raw = fs.readFileSync(_nativeTokenStorePath(), 'utf8')
const parsed = JSON.parse(raw)
return parsed && typeof parsed === 'object' ? parsed : {}
} catch {
return {}
}
}
function _persistNativeTokens(baseUrl: string, tokens: NativeTokenSet | null) {
const store = _readNativeTokenStore()
if (tokens) {
// Encrypt the whole token set as one blob so the refresh token never
// lands in plaintext on disk. Reuse the hardened encrypt helper.
const secret = encryptDesktopSecret(JSON.stringify(tokens))
store[baseUrl] = secret
} else {
delete store[baseUrl]
}
try {
fs.mkdirSync(path.dirname(_nativeTokenStorePath()), { recursive: true })
fs.writeFileSync(_nativeTokenStorePath(), JSON.stringify(store), { mode: 0o600 })
} catch (error) {
rememberLog(`[native-oauth] failed to persist tokens: ${(error as Error).message}`)
}
}
function _loadNativeTokens(baseUrl: string): NativeTokenSet | null {
const cached = _nativeTokens.get(baseUrl)
if (cached) {
return cached
}
const store = _readNativeTokenStore()
const secret = store[baseUrl]
if (!secret) {
return null
}
try {
const plaintext = decryptDesktopSecret(secret)
if (!plaintext) {
return null
}
const tokens = parseTokenResponse(JSON.parse(plaintext))
_nativeTokens.set(baseUrl, tokens)
return tokens
} catch {
return null
}
}
function _storeNativeTokens(baseUrl: string, tokens: NativeTokenSet) {
_nativeTokens.set(baseUrl, tokens)
_persistNativeTokens(baseUrl, tokens)
}
function _clearNativeTokens(baseUrl: string) {
_nativeTokens.delete(baseUrl)
_persistNativeTokens(baseUrl, null)
}
// True when we hold native bearer tokens for this gateway (the native-flow
// analogue of hasLiveOauthSession's cookie check).
function hasNativeSession(baseUrl: string): boolean {
return _loadNativeTokens(baseUrl) !== null
}
// POST JSON WITHOUT the OAuth cookie partition — used for the native token +
// refresh exchanges, which are cookieless by design. Thin wrapper over
// fetchJson (no token) so it shares timeout/JSON handling.
function postJsonNoAuth(url: string, body: unknown, opts: any = {}) {
// resolveJsonBody passes the object through UNCHANGED — fetchJson owns
// JSON.stringify. Pre-stringifying here double-encodes the body (a JSON
// string inside a JSON string), which the gateway's Pydantic model rejects
// with a 422 "Input should be a valid dictionary" (the native
// /auth/native/token + /auth/native/refresh legs both go through here).
return fetchJson(url, null, { method: 'POST', body: resolveJsonBody(body), ...opts })
}
// Return a valid native access token for baseUrl, refreshing via
// /auth/native/refresh if the stored one is at/near expiry. Returns null when
// there are no tokens or the refresh is terminally rejected (caller re-logins).
async function ensureNativeAccessToken(baseUrl: string): Promise<string | null> {
const tokens = _loadNativeTokens(baseUrl)
if (!tokens) {
return null
}
if (!tokenNeedsRefresh(tokens, Math.floor(Date.now() / 1000))) {
return tokens.accessToken
}
if (!tokens.refreshToken) {
// Access token expired and no RT to rotate — force re-login.
_clearNativeTokens(baseUrl)
return null
}
try {
const body = await postJsonNoAuth(
nativeRefreshUrl(baseUrl),
{ refresh_token: tokens.refreshToken, provider: tokens.provider },
{ timeoutMs: 10_000 }
)
const rotated = parseTokenResponse(body)
_storeNativeTokens(baseUrl, rotated)
return rotated.accessToken
} catch (error: any) {
// A 401 means the RT is dead (session_expired) — drop tokens so the UI
// prompts a fresh native login. A 503/transient keeps them for a retry.
if (error && error.statusCode === 401) {
_clearNativeTokens(baseUrl)
return null
}
throw error
}
}
// Mint a single-use WS ticket for a gated gateway. Returns the ticket string.
// Prefers a native bearer token (cookieless RFC 8252 flow) when present,
// falling back to the OAuth cookie partition otherwise.
// Throws (with statusCode 401) if the session cookie is missing/expired —
// callers treat that as "needs re-login".
async function mintGatewayWsTicket(baseUrl) {
// Native flow: mint the ticket with the bearer token, no cookie involved.
const nativeAt = await ensureNativeAccessToken(baseUrl).catch(() => null)
if (nativeAt) {
const body = (await fetchJson(`${baseUrl}/api/auth/ws-ticket`, null, {
method: 'POST',
timeoutMs: 8_000,
bearer: nativeAt
})) as any
const ticket = body?.ticket
if (!ticket || typeof ticket !== 'string') {
throw new Error('Gateway did not return a WS ticket.')
}
return ticket
}
const body = (await fetchJsonViaOauthSession(`${baseUrl}/api/auth/ws-ticket`, {
method: 'POST',
timeoutMs: 8_000
@ -6260,9 +6455,14 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
try {
// Display signal: treat a live RT cookie as "connected" even if the AT
// cookie has lapsed — the gateway refreshes the AT on the next request,
// so the session is still usable. The authoritative liveness check is
// the ws-ticket mint in resolveRemoteBackend at actual connect time.
remoteOauthConnected = await hasLiveOauthSession(remoteUrl)
// so the session is still usable. A stored native bearer token (cookieless
// RFC 8252 flow) counts as connected too — otherwise a completed native
// sign-in shows "not connected" in Settings. The authoritative liveness
// check is the ws-ticket mint in resolveRemoteBackend at actual connect time.
remoteOauthConnected = oauthSessionIsLive(
hasNativeSession(remoteUrl),
await hasLiveOauthSession(remoteUrl)
)
} catch {
remoteOauthConnected = false
}
@ -6454,15 +6654,22 @@ async function buildRemoteConnection(
const host = remoteHost || hostLabelFromBaseUrl(baseUrl)
if (authMode === 'oauth') {
// OAuth gateway: auth comes from the session cookies in the OAuth
// partition. Liveness is NOT "is the access-token cookie present?" —
// Portal issues a 24h rotating refresh token (hermes #37247), and the
// gateway middleware transparently rotates a fresh ~15-min access token
// from it on the next authenticated request. So a session with an expired
// AT cookie but a live RT cookie is still perfectly connectable. We
// early-out only when neither cookie is present, then mint a ws-ticket as
// the authoritative liveness check.
if (!(await hasLiveOauthSession(baseUrl))) {
// OAuth gateway: auth comes from EITHER a native bearer token (cookieless
// RFC 8252 flow) OR the session cookies in the OAuth partition. Liveness is
// NOT "is the access-token cookie present?" — Portal issues a 24h rotating
// refresh token (hermes #37247), and the gateway middleware transparently
// rotates a fresh ~15-min access token from it on the next authenticated
// request. So a session with an expired AT cookie but a live RT cookie is
// still perfectly connectable. We early-out only when NEITHER a native
// token NOR any cookie is present, then mint a ws-ticket (which itself
// prefers the native bearer) as the authoritative liveness check.
//
// The native-token check is essential: the native login stores bearer
// tokens (no cookie is ever set), so gating solely on hasLiveOauthSession
// here would reject a freshly-completed native sign-in and loop the UI back
// into "not signed in" even though mintGatewayWsTicket would succeed with
// the stored bearer.
if (!oauthSessionIsLive(hasNativeSession(baseUrl), await hasLiveOauthSession(baseUrl))) {
const err = new Error(
'Remote Hermes gateway uses OAuth, but you are not signed in. ' +
'Open Settings → Gateway and click "Sign in", or switch back to Local.'
@ -6923,7 +7130,19 @@ async function requestJsonForProfile(profile: string, path: string, method: stri
const url = `${conn.baseUrl}${path}`
const opts = { method, body, timeoutMs: DEFAULT_FETCH_TIMEOUT_MS }
return conn.authMode === 'oauth' ? fetchJsonViaOauthSession(url, opts) : fetchJson(url, conn.token, opts)
if (conn.authMode === 'oauth') {
// Native RFC 8252 flow: authenticate with the bearer token (cookieless)
// when we hold one for this gateway; otherwise use the cookie partition.
const nativeAt = await ensureNativeAccessToken(conn.baseUrl).catch(() => null)
if (nativeAt) {
return fetchJson(url, null, { ...opts, bearer: nativeAt })
}
return fetchJsonViaOauthSession(url, opts)
}
return fetchJson(url, conn.token, opts)
}
async function probeRemoteAuthMode(rawUrl) {
@ -8708,11 +8927,49 @@ ipcMain.handle('hermes:ssh-config:resolve', async (_event, host) => {
ipcMain.handle('hermes:connection-config:test', async (_event, payload) => testDesktopConnectionConfig(payload))
ipcMain.handle('hermes:connection-config:probe', async (_event, rawUrl) => probeRemoteAuthMode(rawUrl))
ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => {
// Open the gateway's OAuth login window and wait for the session cookie to
// land in the OAuth partition. The caller (settings UI) typically saves the
// remote config with authMode='oauth' first, then calls this. We normalize
// the URL defensively so a login can be driven from a raw URL too.
// Capability-gated login (RFC 8252). Probe the gateway's public /api/status:
// - advertises "native_pkce" in auth_flows → run the system-browser +
// loopback + PKCE flow. No embedded webview, tokens held by the app
// (encrypted keychain), REST/WS authenticated by bearer — no cookies.
// - older gateway without native_pkce → fall back to the legacy embedded
// BrowserWindow cookie flow, preserving compatibility.
// This is the "observable ladder + compatibility fallback tied to an
// identified older runtime" the desktop guide requires.
const baseUrl = normalizeRemoteBaseUrl(rawUrl)
let statusBody: any = null
try {
statusBody = await fetchPublicJson(`${baseUrl}/api/status`, { timeoutMs: 8_000 })
} catch {
// Can't read status — fall through to the embedded flow, which has its
// own error handling and works against any gated gateway.
}
const strategy = resolveLoginStrategy(statusBody)
if (strategy === 'native') {
try {
const tokens = await runNativeLogin(baseUrl, {
openExternal: url => shell.openExternal(url),
postJson: (url, body, opts) => postJsonNoAuth(url, body, opts),
rememberLog
})
_storeNativeTokens(baseUrl, tokens)
return { ok: true, baseUrl, connected: true }
} catch (error) {
rememberLog(
`[native-oauth] native login failed (${
error instanceof Error ? error.message : String(error)
}); falling back to embedded flow`
)
// Fall through to the embedded flow so a native-flow hiccup (blocked
// loopback, user closed the browser) still lets the user sign in.
}
}
// Legacy embedded-webview cookie flow.
await openOauthLoginWindow(baseUrl)
return { ok: true, baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
@ -8720,11 +8977,20 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
await clearOauthSession(baseUrl || undefined)
// Also drop any native (RFC 8252) bearer tokens for this gateway so a
// logout clears BOTH auth shapes.
if (baseUrl) {
_clearNativeTokens(baseUrl)
}
// Report against the SAME liveness notion the Settings indicator uses
// (AT-or-RT) so a logout that left any session cookie behind is reflected
// as still-connected rather than silently signed-out.
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
// (AT-or-RT cookie, or a native token) so a logout that left any session
// behind is reflected as still-connected rather than silently signed-out.
const connected = baseUrl
? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl)
: false
return { ok: true, connected }
})
// --- Hermes Cloud (cloud-auto-discovery Phase 3) ---
@ -9081,10 +9347,14 @@ ipcMain.handle('hermes:api', async (_event, request) => {
const url = `${connection.baseUrl}${requestPath}`
// OAuth gateways authenticate REST via the HttpOnly session cookie held in
// the OAuth partition — route through Electron's net stack bound to that
// session so the cookie attaches automatically. Token/local modes keep using
// the static session-token header.
// OAuth gateways authenticate REST via EITHER a native bearer token
// (cookieless RFC 8252 flow) OR the HttpOnly session cookie held in the OAuth
// partition. Prefer the native bearer when present (mirroring
// mintGatewayWsTicket): the native flow never sets a cookie, so routing an
// oauth-mode REST call through the cookie-only path returns 401 no_cookie even
// though a valid bearer is held. Cookie mode rides Electron's net stack bound
// to the OAuth partition so the cookie attaches automatically. Token/local
// modes keep using the static session-token header.
if (connection.authMode === 'oauth') {
// The OAuth path rides electron.net with JSON headers; multipart isn't
// wired there. Fail loudly rather than corrupting the upload.
@ -9092,6 +9362,21 @@ ipcMain.handle('hermes:api', async (_event, request) => {
throw new Error('File uploads are not supported against OAuth-gated remote backends yet.')
}
// Native bearer first (cookieless). ensureNativeAccessToken transparently
// refreshes a near-expiry AT via /auth/native/refresh; a null return means
// no native session (resolveOauthRestAuth then selects the cookie path).
const nativeAt = await ensureNativeAccessToken(connection.baseUrl).catch(() => null)
const restAuth = resolveOauthRestAuth(nativeAt)
if (restAuth.kind === 'bearer') {
return fetchJson(url, null, {
method: request?.method,
body: request?.body,
timeoutMs,
bearer: restAuth.token
})
}
return fetchJsonViaOauthSession(url, {
method: request?.method,
body: request?.body,

View file

@ -0,0 +1,72 @@
/**
* Regression tests for electron/native-auth-decisions.ts the three pure
* decision seams behind the RFC 8252 native-app auth flow, each of which was a
* real runtime bug that the mocked flow tests could not catch.
*
* Run via the vitest `electron` project (electron/**\/*.test.ts).
*/
import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth
} from './native-auth-decisions'
// --- 1. body encoding (guards the double-JSON.stringify 422) ---
test('resolveJsonBody returns the object unchanged (no pre-stringify)', () => {
const body = { code: 'abc', code_verifier: 'xyz' }
const out = resolveJsonBody(body)
// Must be the SAME object reference / shape — NOT a JSON string. Pre-
// stringifying here is what produced the gateway 422 "Input should be a
// valid dictionary" at /auth/native/token.
assert.equal(typeof out, 'object')
assert.deepEqual(out, body)
})
test('resolveJsonBody does not stringify — a string stays a string, an object stays an object', () => {
assert.equal(typeof resolveJsonBody({ a: 1 }), 'object')
// If a caller ever passes an already-encoded string (the bug), we return it
// as-is rather than re-wrapping — the contract is "fetchJson owns encoding".
assert.equal(typeof resolveJsonBody('{"a":1}'), 'string')
})
// --- 2. oauth liveness (guards the needsOauthLogin loop) ---
test('oauthSessionIsLive is true when a native bearer token exists, even with no cookie', () => {
// The exact bug: native login stores a bearer, sets no cookie. Gating on the
// cookie alone looped the UI into "not signed in".
assert.equal(oauthSessionIsLive(true, false), true)
})
test('oauthSessionIsLive is true when a live cookie exists with no native token', () => {
assert.equal(oauthSessionIsLive(false, true), true)
})
test('oauthSessionIsLive is true when both are present', () => {
assert.equal(oauthSessionIsLive(true, true), true)
})
test('oauthSessionIsLive is false only when neither is present', () => {
assert.equal(oauthSessionIsLive(false, false), false)
})
// --- 3. REST auth selection (guards the 401 no_cookie) ---
test('resolveOauthRestAuth prefers the native bearer when a token is present', () => {
const auth = resolveOauthRestAuth('bearer-token-123')
assert.deepEqual(auth, { kind: 'bearer', token: 'bearer-token-123' })
})
test('resolveOauthRestAuth falls back to cookie when there is no native token', () => {
assert.deepEqual(resolveOauthRestAuth(null), { kind: 'cookie' })
assert.deepEqual(resolveOauthRestAuth(undefined), { kind: 'cookie' })
// Empty string is not a usable bearer — must fall back, not send "Bearer ".
assert.deepEqual(resolveOauthRestAuth(''), { kind: 'cookie' })
})

View file

@ -0,0 +1,64 @@
/**
* native-auth-decisions.ts
*
* Pure decision helpers extracted from main.ts for the RFC 8252 native-app
* auth flow. These encode three choices that were each the site of a real
* runtime bug invisible to the mocked flow tests because the tests never
* exercised the real main.ts internals. Keeping them pure + unit-tested here
* prevents silent regressions:
*
* 1. resolveJsonBody the token/refresh POST body must be the raw
* object (fetchJson owns JSON.stringify). Pre-stringifying double-encodes
* it into a JSON string, which the gateway's Pydantic model rejects with
* 422 "Input should be a valid dictionary".
*
* 2. oauthSessionIsLive an OAuth gateway is "signed in" when EITHER a
* native bearer token OR a live cookie session exists. Gating on the
* cookie alone rejects a completed native login and loops the UI into
* "not signed in".
*
* 3. resolveOauthRestAuth an oauth-mode REST call authenticates with the
* native bearer when present, else the cookie partition. Cookie-only
* routing returns 401 no_cookie for a cookieless native session.
*
* All three are trivial once named; the value is the test that pins the
* contract so the god-file call sites can't drift back to the buggy shape.
*/
/**
* Decide the request body to hand to fetchJson (which JSON.stringifies it).
* Returns the object UNCHANGED callers must NOT pre-stringify. A string here
* would be double-encoded downstream; this function exists to document and
* pin that contract at the one seam that got it wrong.
*/
export function resolveJsonBody<T>(body: T): T {
return body
}
/**
* True when an oauth gateway should be treated as signed-in. `hasNativeToken`
* is whether a native bearer token is stored; `hasCookieSession` is whether a
* live AT-or-RT cookie exists in the OAuth partition. Either suffices.
*/
export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: boolean): boolean {
return hasNativeToken || hasCookieSession
}
export type OauthRestAuth =
| { kind: 'bearer'; token: string }
| { kind: 'cookie' }
/**
* Decide how an oauth-mode REST request authenticates: prefer the native
* bearer (cookieless RFC 8252 flow) when a non-empty access token is present,
* otherwise fall back to the cookie partition. `nativeAccessToken` is the
* result of ensureNativeAccessToken (null/empty when there is no native
* session or the refresh terminally failed).
*/
export function resolveOauthRestAuth(nativeAccessToken: string | null | undefined): OauthRestAuth {
if (nativeAccessToken) {
return { kind: 'bearer', token: nativeAccessToken }
}
return { kind: 'cookie' }
}

View file

@ -0,0 +1,169 @@
/**
* Tests for electron/native-oauth-login.ts the loopback-listener
* orchestration of the RFC 8252 native login, with all I/O injected (fake
* http server, fake openExternal, fake token POST) so no real socket or
* browser is needed.
*
* Run with: node --test electron/native-oauth-login.test.ts
*/
import assert from 'node:assert/strict'
import { EventEmitter } from 'node:events'
import { test } from 'vitest'
import { runNativeLogin } from './native-oauth-login'
// A fake http.Server: captures the request handler, lets the test drive a
// synthetic browser callback, and records listen/close lifecycle.
function makeFakeServerFactory(port = 51234) {
const state: any = { handler: null, listening: false, closed: false, openedUrl: null }
const createServer: any = (handler: any) => {
state.handler = handler
const server: any = new EventEmitter()
server.listen = (_port: number, _host: string, cb: () => void) => {
state.listening = true
cb()
}
server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port })
server.close = () => {
state.closed = true
}
state.server = server
return server
}
// Drive a synthetic browser hit to the loopback callback.
state.hitCallback = (query: string) => {
const res: any = { writeHead: () => undefined, end: () => undefined }
state.handler({ url: `/callback?${query}` }, res)
}
return { createServer, state }
}
test('runNativeLogin completes the loopback round trip and returns tokens', async () => {
const { createServer, state } = makeFakeServerFactory()
let capturedAuthorizeUrl = ''
let tokenPostBody: any = null
const promise = runNativeLogin(
'https://gw.example.com',
{
openExternal: async url => {
capturedAuthorizeUrl = url
},
postJson: async (_url, body) => {
tokenPostBody = body
return {
access_token: 'AT-native',
refresh_token: 'RT-native',
token_type: 'Bearer',
expires_at: 1893456000,
provider: 'nous',
user_id: 'u-9'
}
},
createServer,
timeoutMs: 5_000
},
{ provider: 'nous' }
)
// Give the listen callback a tick to open the browser + capture the URL.
await new Promise(r => setTimeout(r, 5))
// The authorize URL must carry OUR challenge + loopback redirect + state.
const authorize = new URL(capturedAuthorizeUrl)
assert.equal(authorize.pathname, '/auth/native/authorize')
const challenge = authorize.searchParams.get('code_challenge')
const stateParam = authorize.searchParams.get('state')
assert.ok(challenge && challenge.length > 0)
assert.match(authorize.searchParams.get('redirect_uri') || '', /^http:\/\/127\.0\.0\.1:\d+\/callback$/)
// Synthetic browser redirect back with the matching state + a code.
state.hitCallback(`code=gw-code-1&state=${encodeURIComponent(stateParam!)}`)
const tokens = await promise
assert.equal(tokens.accessToken, 'AT-native')
assert.equal(tokens.refreshToken, 'RT-native')
assert.equal(tokens.userId, 'u-9')
// The token POST carried the code + a verifier whose hash is the challenge.
assert.equal(tokenPostBody.code, 'gw-code-1')
assert.ok(tokenPostBody.code_verifier && tokenPostBody.code_verifier.length >= 43)
// Listener was cleaned up.
assert.equal(state.closed, true)
})
test('runNativeLogin rejects on a state mismatch (CSRF) without redeeming', async () => {
const { createServer, state } = makeFakeServerFactory()
let tokenPostCalled = false
const promise = runNativeLogin('https://gw.example.com', {
openExternal: async () => undefined,
postJson: async () => {
tokenPostCalled = true
return {}
},
createServer,
timeoutMs: 5_000
})
await new Promise(r => setTimeout(r, 5))
// Wrong state — must not redeem the code.
state.hitCallback('code=evil&state=not-the-real-state')
await assert.rejects(promise, /state mismatch/i)
assert.equal(tokenPostCalled, false)
assert.equal(state.closed, true)
})
test('runNativeLogin surfaces a gateway error param', async () => {
const { createServer, state } = makeFakeServerFactory()
const promise = runNativeLogin('https://gw.example.com', {
openExternal: async () => undefined,
postJson: async () => ({}),
createServer,
timeoutMs: 5_000
})
await new Promise(r => setTimeout(r, 5))
state.hitCallback('error=access_denied&error_description=user_declined')
await assert.rejects(promise, /access_denied/i)
})
test('runNativeLogin times out when no callback arrives', async () => {
const { createServer } = makeFakeServerFactory()
await assert.rejects(
runNativeLogin('https://gw.example.com', {
openExternal: async () => undefined,
postJson: async () => ({}),
createServer,
timeoutMs: 20
}),
/timed out/i
)
})
test('runNativeLogin fails if the browser cannot be opened', async () => {
const { createServer } = makeFakeServerFactory()
await assert.rejects(
runNativeLogin('https://gw.example.com', {
openExternal: async () => {
throw new Error('no browser')
},
postJson: async () => ({}),
createServer,
timeoutMs: 5_000
}),
/could not open the system browser/i
)
})

View file

@ -0,0 +1,213 @@
/**
* native-oauth-login.ts
*
* Electron-coupled driver for the RFC 8252 native-app login: it runs the
* loopback HTTP listener that catches the gateway's browser redirect, opens
* the system browser, redeems the one-time code for tokens, and hands them
* back. The PURE logic (PKCE, URL building, callback parsing, token-response
* normalization) lives in native-oauth.ts and is unit-tested separately; this
* module is the thin I/O shell around it.
*
* Dependencies are INJECTED (openExternal, a JSON-POST fn, an http-server
* factory, a clock) so the orchestration is testable without booting Electron
* or opening real sockets mirroring how connection-config.ts injects
* `mintTicket`. main.ts supplies the real electron shell.openExternal,
* electron.net POST, and node:http server.
*
* Security posture (see native-oauth.ts for the flow-level rationale):
* - the loopback server binds 127.0.0.1 on an EPHEMERAL port and shuts down
* the instant it receives the callback (or times out) no long-lived
* local listener;
* - the `state` is verified before the code is redeemed (CSRF);
* - the PKCE verifier never leaves this process until the token POST, and
* the gateway enforces SHA256(verifier)==challenge server-side;
* - the browser sees only a minimal "you can close this window" HTML page,
* never the tokens.
*/
import http from 'node:http'
import type { AddressInfo } from 'node:net'
import {
buildNativeAuthorizeUrl,
generatePkcePair,
generateState,
nativeTokenUrl,
parseLoopbackCallback,
parseTokenResponse,
type NativeTokenSet
} from './native-oauth'
// Loopback login must complete inside this window (user opens browser,
// authenticates, gets redirected back). Matches the server-side pending TTL.
const DEFAULT_LOGIN_TIMEOUT_MS = 5 * 60 * 1000
// The minimal page the browser lands on after the gateway redirect. No tokens,
// no secrets — just a close affordance. Served for any loopback request so a
// favicon probe doesn't look like a failure.
const DONE_HTML =
'<!doctype html><meta charset="utf-8"><title>Signed in</title>' +
'<body style="font:15px system-ui;margin:3rem;text-align:center">' +
'<h2>&#10003; Signed in to Hermes</h2>' +
'<p>You can close this window and return to the app.</p>' +
'<script>setTimeout(()=>window.close(),800)</script>'
export interface NativeLoginDeps {
/** Open a URL in the user's system browser (shell.openExternal). */
openExternal: (url: string) => Promise<void>
/** POST JSON and resolve the parsed body (electron.net-backed in prod). */
postJson: (url: string, body: unknown, opts?: { timeoutMs?: number }) => Promise<any>
/** http.createServer, injectable for tests. */
createServer?: typeof http.createServer
/** Clock + timeout, injectable for tests. */
now?: () => number
timeoutMs?: number
/** Optional logger for boot diagnostics. */
rememberLog?: (line: string) => void
}
/**
* Drive a full native login against `baseUrl` and return the token set.
*
* Steps: bind a loopback listener open the system browser at the gateway's
* /auth/native/authorize with our PKCE challenge + loopback redirect_uri
* await the ?code= redirect verify state POST /auth/native/token with the
* verifier return tokens. Rejects on timeout, state mismatch, a gateway
* error param, or a token-exchange failure. Always tears the listener down.
*/
export async function runNativeLogin(
baseUrl: string,
deps: NativeLoginDeps,
opts: { provider?: string } = {}
): Promise<NativeTokenSet> {
const createServer = deps.createServer || http.createServer
const timeoutMs = deps.timeoutMs ?? DEFAULT_LOGIN_TIMEOUT_MS
const log = deps.rememberLog || (() => undefined)
const { verifier, challenge } = generatePkcePair()
const state = generateState()
return new Promise<NativeTokenSet>((resolve, reject) => {
let settled = false
let timer: NodeJS.Timeout | null = null
const server = createServer((req, res) => {
// Only the callback path carries the code; any other path (favicon,
// etc.) still gets the friendly page so the browser tab looks sane.
const url = req.url || '/'
// Always answer the browser with the close page — we never surface the
// outcome to the browser, only to the app.
res.writeHead(200, { 'content-type': 'text/html; charset=utf-8' })
res.end(DONE_HTML)
if (settled) {
return
}
// Ignore non-callback noise (e.g. /favicon.ico) — wait for the ?code=.
if (!/[?&](code|error)=/.test(url)) {
return
}
try {
const { code } = parseLoopbackCallback(url, state)
finishWith(async () => {
const tokenBody = await deps.postJson(
nativeTokenUrl(baseUrl),
{ code, code_verifier: verifier },
{ timeoutMs: 15_000 }
)
return parseTokenResponse(tokenBody)
})
} catch (error) {
fail(error instanceof Error ? error : new Error(String(error)))
}
})
const cleanup = () => {
if (timer) {
clearTimeout(timer)
}
try {
server.close()
} catch {
// already closed
}
}
const fail = (error: Error) => {
if (settled) {
return
}
settled = true
cleanup()
reject(error)
}
const finishWith = (produce: () => Promise<NativeTokenSet>) => {
if (settled) {
return
}
settled = true
// Keep the listener up just long enough to have answered the browser,
// then redeem the code out-of-band.
produce()
.then(tokens => {
cleanup()
resolve(tokens)
})
.catch(error => {
cleanup()
reject(error instanceof Error ? error : new Error(String(error)))
})
}
server.on('error', err => fail(err instanceof Error ? err : new Error(String(err))))
// Bind an ephemeral loopback port, then open the browser.
server.listen(0, '127.0.0.1', () => {
const addr = server.address() as AddressInfo | null
if (!addr || typeof addr === 'string') {
fail(new Error('Failed to bind loopback listener for native login'))
return
}
const redirectUri = `http://127.0.0.1:${addr.port}/callback`
const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, {
challenge,
redirectUri,
state,
provider: opts.provider
})
timer = setTimeout(() => {
fail(
new Error(
'Native sign-in timed out. The browser window may not have completed ' +
'sign-in; open Settings → Gateway and try again.'
)
)
}, timeoutMs)
log(`[native-oauth] loopback listening on 127.0.0.1:${addr.port}; opening system browser`)
deps.openExternal(authorizeUrl).catch(error => {
fail(
new Error(
`Could not open the system browser for native sign-in: ${
error instanceof Error ? error.message : String(error)
}`
)
)
})
})
})
}
export { DEFAULT_LOGIN_TIMEOUT_MS }

View file

@ -0,0 +1,190 @@
/**
* Tests for electron/native-oauth.ts the pure RFC 8252 native-app login
* helpers (PKCE, capability detection, URL building, loopback callback
* parsing, token-response normalization, refresh-timing).
*
* Run with: node --test electron/native-oauth.test.ts
* (Wired into the vitest `electron` project via electron/**\/*.test.ts.)
*/
import assert from 'node:assert/strict'
import { createHash } from 'node:crypto'
import { test } from 'vitest'
import {
buildNativeAuthorizeUrl,
generatePkcePair,
generateState,
NATIVE_FLOW_ID,
nativeRefreshUrl,
nativeTokenUrl,
parseLoopbackCallback,
parseTokenResponse,
resolveLoginStrategy,
statusSupportsNativeFlow,
tokenNeedsRefresh
} from './native-oauth'
// --- PKCE ---
test('generatePkcePair produces a valid S256 verifier/challenge', () => {
const pair = generatePkcePair()
assert.equal(pair.method, 'S256')
// Verifier length within RFC 7636 range (43128).
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
// Challenge must be the base64url SHA-256 of the verifier.
const expected = createHash('sha256')
.update(pair.verifier, 'ascii')
.digest('base64')
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
assert.equal(pair.challenge, expected)
// No padding / URL-unsafe chars.
assert.doesNotMatch(pair.verifier, /[+/=]/)
assert.doesNotMatch(pair.challenge, /[+/=]/)
})
test('generatePkcePair is unique per call', () => {
assert.notEqual(generatePkcePair().verifier, generatePkcePair().verifier)
})
test('generateState is non-empty and URL-safe', () => {
const s = generateState()
assert.ok(s.length > 0)
assert.doesNotMatch(s, /[+/=]/)
})
// --- capability detection ---
test('statusSupportsNativeFlow reads the auth_flows array', () => {
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie', NATIVE_FLOW_ID] }), true)
assert.equal(statusSupportsNativeFlow({ auth_flows: ['cookie'] }), false)
// Older gateway: no auth_flows field at all ⇒ not supported.
assert.equal(statusSupportsNativeFlow({ auth_required: true }), false)
assert.equal(statusSupportsNativeFlow({}), false)
assert.equal(statusSupportsNativeFlow(null), false)
// Malformed field shapes never throw.
assert.equal(statusSupportsNativeFlow({ auth_flows: 'native_pkce' }), false)
})
test('resolveLoginStrategy picks native only when advertised and not forced', () => {
const gated = { auth_required: true, auth_flows: ['cookie', 'native_pkce'] }
const legacy = { auth_required: true, auth_flows: ['cookie'] }
assert.equal(resolveLoginStrategy(gated), 'native')
// Compatibility fallback: an older gateway lacking native_pkce ⇒ embedded.
assert.equal(resolveLoginStrategy(legacy), 'embedded')
// A user/env override can pin the legacy flow even on a capable gateway.
assert.equal(resolveLoginStrategy(gated, { forceEmbedded: true }), 'embedded')
})
// --- URL building ---
test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
const url = buildNativeAuthorizeUrl('https://gw.example.com', {
challenge: 'CHAL',
redirectUri: 'http://127.0.0.1:51000/callback',
state: 'STATE',
provider: 'nous'
})
const parsed = new URL(url)
assert.equal(parsed.origin, 'https://gw.example.com')
assert.equal(parsed.pathname, '/auth/native/authorize')
assert.equal(parsed.searchParams.get('code_challenge'), 'CHAL')
assert.equal(parsed.searchParams.get('code_challenge_method'), 'S256')
assert.equal(parsed.searchParams.get('redirect_uri'), 'http://127.0.0.1:51000/callback')
assert.equal(parsed.searchParams.get('state'), 'STATE')
assert.equal(parsed.searchParams.get('provider'), 'nous')
})
test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix', () => {
const url = buildNativeAuthorizeUrl('https://gw.example.com/hermes', {
challenge: 'C',
redirectUri: 'http://127.0.0.1:1/cb',
state: 'S'
})
const parsed = new URL(url)
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
assert.equal(parsed.searchParams.get('provider'), null)
})
test('nativeTokenUrl / nativeRefreshUrl build the right endpoints', () => {
assert.equal(nativeTokenUrl('https://gw.example.com'), 'https://gw.example.com/auth/native/token')
assert.equal(nativeRefreshUrl('https://gw.example.com/hermes'), 'https://gw.example.com/hermes/auth/native/refresh')
})
// --- loopback callback parsing ---
test('parseLoopbackCallback returns the code on a state match', () => {
const { code } = parseLoopbackCallback('/callback?code=abc123&state=xyz', 'xyz')
assert.equal(code, 'abc123')
})
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
assert.throws(
() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'),
/state mismatch/i
)
})
test('parseLoopbackCallback surfaces a gateway error param', () => {
assert.throws(
() => parseLoopbackCallback('/callback?error=access_denied&error_description=nope', 'xyz'),
/access_denied.*nope/i
)
})
test('parseLoopbackCallback throws when the code is absent', () => {
assert.throws(() => parseLoopbackCallback('/callback?state=xyz', 'xyz'), /missing authorization code/i)
})
// --- token response normalization ---
test('parseTokenResponse maps a well-formed body', () => {
const t = parseTokenResponse({
access_token: 'AT',
refresh_token: 'RT',
token_type: 'Bearer',
expires_at: 1893456000,
provider: 'nous',
user_id: 'u-1'
})
assert.equal(t.accessToken, 'AT')
assert.equal(t.refreshToken, 'RT')
assert.equal(t.expiresAt, 1893456000)
assert.equal(t.provider, 'nous')
assert.equal(t.userId, 'u-1')
})
test('parseTokenResponse throws on a missing access token', () => {
assert.throws(() => parseTokenResponse({ refresh_token: 'RT' }), /missing access_token/i)
})
test('parseTokenResponse tolerates an absent refresh token / expiry', () => {
const t = parseTokenResponse({ access_token: 'AT' })
assert.equal(t.refreshToken, '')
assert.equal(t.expiresAt, 0)
})
// --- refresh timing ---
test('tokenNeedsRefresh respects the skew window', () => {
const now = 1_000_000
// Expires comfortably in the future ⇒ no refresh.
assert.equal(tokenNeedsRefresh({ expiresAt: now + 3600 }, now), false)
// Within the 60s skew ⇒ refresh early.
assert.equal(tokenNeedsRefresh({ expiresAt: now + 30 }, now), true)
// Already expired ⇒ refresh.
assert.equal(tokenNeedsRefresh({ expiresAt: now - 10 }, now), true)
// Unknown expiry ⇒ refresh (validate before use).
assert.equal(tokenNeedsRefresh({ expiresAt: 0 }, now), true)
})

View file

@ -0,0 +1,215 @@
/**
* native-oauth.ts
*
* Pure, electron-free helpers for the desktop's RFC 8252 (OAuth 2.0 for Native
* Apps) login to a gated Hermes gateway: system-browser + loopback redirect +
* PKCE, with tokens returned to the app (never browser session cookies).
*
* Kept standalone (no `import 'electron'`) so it unit-tests with `node --test`
* same pattern as connection-config.ts. main.ts owns the electron-coupled
* parts (the actual http.Server loopback listener, shell.openExternal, and
* safeStorage keychain writes) and calls these helpers for the pure logic.
*
* Why the gateway brokers the flow (not a direct desktopIDP client): the
* upstream IDP (Nous Portal) issues a per-gateway-instance client_id and only
* accepts a redirect_uri on the gateway's own origin, so a desktop loopback
* redirect can't be a direct Portal client. Instead the gateway exposes
* /auth/native/{authorize,token,refresh}: it is the authorization server to
* the desktop and an OAuth client to Portal. The desktop still gets the full
* RFC 8252 experience its own PKCE pair, its own loopback redirect, tokens
* it stores itself.
*
* Capability detection: the gateway advertises supported flows on the public
* /api/status `auth_flows` array. `native_pkce` present use this flow;
* absent (older gateway) the caller falls back to the embedded-webview
* cookie flow. This is the "observable ladder / compatibility fallback tied to
* an identified older runtime" the desktop guide requires.
*/
import { createHash, randomBytes } from 'node:crypto'
// The gateway status field that lists supported auth flows. See
// hermes_cli/web_server.py status handler.
const NATIVE_FLOW_ID = 'native_pkce'
export interface NativePkcePair {
verifier: string
challenge: string
method: 'S256'
}
export interface NativeTokenSet {
accessToken: string
refreshToken: string
expiresAt: number
provider: string
userId: string
}
/** base64url without `=` padding (RFC 7636 §4). */
function b64url(raw: Buffer): string {
return raw.toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
/**
* Generate a PKCE verifier/challenge pair (S256). The verifier is 32 random
* bytes base64url-encoded (43 chars, within RFC 7636's 43128 range).
*/
export function generatePkcePair(randomImpl: (n: number) => Buffer = randomBytes): NativePkcePair {
const verifier = b64url(randomImpl(32))
const challenge = b64url(createHash('sha256').update(verifier, 'ascii').digest())
return { verifier, challenge, method: 'S256' }
}
/** A high-entropy CSRF `state` value for the loopback round trip. */
export function generateState(randomImpl: (n: number) => Buffer = randomBytes): string {
return b64url(randomImpl(24))
}
/**
* True if a gateway `/api/status` body advertises the native PKCE flow.
* Tolerant of the field being absent (older gateway) or malformed.
*/
export function statusSupportsNativeFlow(statusBody: any): boolean {
const flows = statusBody && statusBody.auth_flows
return Array.isArray(flows) && flows.includes(NATIVE_FLOW_ID)
}
/**
* Decide the login strategy for a gated gateway from its status body.
* Returns 'native' when the gateway can do RFC 8252 AND we're not forced to
* the legacy path; 'embedded' otherwise (older gateway webview fallback).
*
* `forceEmbedded` lets a user/setting or an env override pin the legacy flow
* (e.g. a corporate proxy that blocks loopback). Precedence written down here,
* in one place, as a pure function per the desktop "observable ladder" rule.
*/
export function resolveLoginStrategy(
statusBody: any,
opts: { forceEmbedded?: boolean } = {}
): 'native' | 'embedded' {
if (opts.forceEmbedded) {
return 'embedded'
}
return statusSupportsNativeFlow(statusBody) ? 'native' : 'embedded'
}
/**
* Build the gateway `/auth/native/authorize` URL the system browser opens.
* `redirectUri` is the desktop's loopback callback (127.0.0.1:<port>/...).
* `provider` is optional omitted lets the gateway pick when it has exactly
* one session provider (the common hosted case).
*/
export function buildNativeAuthorizeUrl(
baseUrl: string,
params: { challenge: string; redirectUri: string; state: string; provider?: string }
): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
const q = new URLSearchParams({
code_challenge: params.challenge,
code_challenge_method: 'S256',
redirect_uri: params.redirectUri,
state: params.state
})
if (params.provider) {
q.set('provider', params.provider)
}
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/authorize?${q.toString()}`
}
/** The `/auth/native/token` endpoint URL for a gateway base URL. */
export function nativeTokenUrl(baseUrl: string): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/token`
}
/** The `/auth/native/refresh` endpoint URL for a gateway base URL. */
export function nativeRefreshUrl(baseUrl: string): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
return `${parsed.protocol}//${parsed.host}${prefix}/auth/native/refresh`
}
/**
* Parse the loopback redirect the gateway sends the browser to. Returns the
* `code` + `state`, or throws with the gateway's `error` if the flow failed.
* `expectedState` MUST match (CSRF defense RFC 6749 §10.12); a mismatch
* throws rather than proceeding.
*/
export function parseLoopbackCallback(
requestUrl: string,
expectedState: string
): { code: string } {
// requestUrl is the path+query the loopback server received, e.g.
// "/callback?code=...&state=...". Resolve against a dummy origin to parse.
const parsed = new URL(requestUrl, 'http://127.0.0.1')
const error = parsed.searchParams.get('error')
if (error) {
const desc = parsed.searchParams.get('error_description') || ''
throw new Error(`Gateway rejected native login: ${error}${desc ? ` (${desc})` : ''}`)
}
const code = parsed.searchParams.get('code') || ''
const state = parsed.searchParams.get('state') || ''
if (!code) {
throw new Error('Loopback callback missing authorization code')
}
if (!expectedState || state !== expectedState) {
// Never redeem a code that arrived with a mismatched state — it may be a
// forged callback trying to inject an attacker's code.
throw new Error('Loopback callback state mismatch (possible CSRF)')
}
return { code }
}
/**
* Normalize a `/auth/native/token` (or refresh) JSON response into a
* NativeTokenSet, validating the shape. Throws on a missing/short access
* token so a malformed response fails loudly rather than storing junk.
*/
export function parseTokenResponse(body: any): NativeTokenSet {
const accessToken = String(body?.access_token || '')
if (!accessToken) {
throw new Error('Gateway token response missing access_token')
}
const expiresAt = Number(body?.expires_at)
return {
accessToken,
refreshToken: String(body?.refresh_token || ''),
expiresAt: Number.isFinite(expiresAt) ? expiresAt : 0,
provider: String(body?.provider || ''),
userId: String(body?.user_id || '')
}
}
/**
* True when a stored token set is at/near expiry and should be refreshed
* before use. `skewSeconds` refreshes slightly early to avoid a race where
* the token expires in flight (mirrors the server's 60s cookie floor).
*/
export function tokenNeedsRefresh(tokens: Pick<NativeTokenSet, 'expiresAt'>, nowSeconds: number, skewSeconds = 60): boolean {
if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) {
// Unknown expiry ⇒ treat as needing refresh so we validate before use.
return true
}
return nowSeconds >= tokens.expiresAt - skewSeconds
}
export { NATIVE_FLOW_ID }

View file

@ -18,8 +18,10 @@ import { RICH_INPUT_SLOT } from './rich-editor'
export type ComposerTarget = 'edit' | 'main' | (string & {})
export type ComposerInsertMode = 'block' | 'inline'
interface FocusDetail {
export interface FocusDetail {
target: ComposerTarget
/** Append after focus (type-to-focus / soft `/`). */
typeChar?: string
}
interface InsertDetail {
@ -82,8 +84,10 @@ export const markActiveComposer = (target: ComposerTarget) => {
* Used by broadcast listeners (voice, Esc-to-stop) to act on exactly one. */
export const getActiveComposer = (): ComposerTarget => activeTarget
export const requestComposerFocus = (target: ComposerTarget | 'active' = 'active') =>
dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target) })
export const requestComposerFocus = (
target: ComposerTarget | 'active' = 'active',
{ typeChar }: { typeChar?: string } = {}
) => dispatch<FocusDetail>(FOCUS_EVENT, { target: resolve(target), typeChar })
export const requestComposerInsert = (
text: string,
@ -98,8 +102,8 @@ export const requestComposerInsert = (
dispatch<InsertDetail>(INSERT_EVENT, { mode, target: resolve(target), text: trimmed })
}
export const onComposerFocusRequest = (handler: (target: ComposerTarget) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, ({ target }) => handler(target))
export const onComposerFocusRequest = (handler: (detail: FocusDetail) => void) =>
subscribe<FocusDetail>(FOCUS_EVENT, handler)
export const onComposerInsertRequest = (handler: (detail: InsertDetail) => void) =>
subscribe<InsertDetail>(INSERT_EVENT, handler)

View file

@ -158,10 +158,19 @@ export function useComposerDraft({
return undefined
}
const offFocus = onComposerFocusRequest(requested => {
if (requested === target) {
setFocusRequestId(id => id + 1)
const offFocus = onComposerFocusRequest(({ target: requested, typeChar }) => {
if (requested !== target) {
return
}
// Type-to-focus appends at end; bare Enter just focuses.
if (typeChar) {
paintDraft(`${draftRef.current}${typeChar}`, true)
return
}
setFocusRequestId(id => id + 1)
})
const offInsert = onComposerInsertRequest(({ mode, target: requested, text }) => {
@ -174,7 +183,7 @@ export function useComposerDraft({
offFocus()
offInsert()
}
}, [appendExternalText, inputDisabled, target])
}, [appendExternalText, inputDisabled, paintDraft, target])
const stashAt = (scope: string | null, text = draftRef.current, attachments = attachmentScope.$attachments.get()) =>
stashSessionDraft(scope, text, attachments)

View file

@ -24,7 +24,16 @@ interface QueuePanelProps {
const entryPreview = (entry: QueuedPromptEntry, c: Translations['composer']) =>
entry.text.trim() || (entry.attachments.length > 0 ? c.attachmentOnly : c.emptyTurn)
export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onResume, onSendNow, parked }: QueuePanelProps) {
export function QueuePanel({
busy,
editingId,
entries,
onDelete,
onEdit,
onResume,
onSendNow,
parked
}: QueuePanelProps) {
const { t } = useI18n()
const c = t.composer
@ -54,13 +63,7 @@ export function QueuePanel({ busy, editingId, entries, onDelete, onEdit, onResum
) : undefined
}
defaultCollapsed={!parked}
icon={
<Codicon
className="text-muted-foreground/70"
name={parked ? 'debug-pause' : 'layers'}
size="0.8rem"
/>
}
icon={<Codicon className="text-muted-foreground/70" name={parked ? 'debug-pause' : 'layers'} size="0.8rem" />}
key={parked ? 'parked' : 'flowing'}
label={parked ? c.queuedPaused(entries.length) : c.queued(entries.length)}
>

View file

@ -14,18 +14,19 @@ import { PromptOverlays } from '@/components/prompt-overlays'
import { Button } from '@/components/ui/button'
import { ErrorState } from '@/components/ui/error-state'
import { TitleMenuTrigger } from '@/components/ui/title-menu-trigger'
import { getGlobalModelOptions, type HermesGateway } from '@/hermes'
import { type HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import type { ChatMessage } from '@/lib/chat-messages'
import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import { cn } from '@/lib/utils'
import { migrateSessionDraft } from '@/store/composer'
import { migrateQueuedPrompts, parkQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
import { $gatewaySwapTarget, $profiles } from '@/store/profile'
import { $activeGatewayProfile, $gatewaySwapTarget, $profiles } from '@/store/profile'
import {
$contextSuggestions,
$freshDraftReady,
@ -254,6 +255,7 @@ export function ChatView({
const sessionAnchor = isPrimary ? 'workspace' : `session-tile:${storedId ?? ''}`
const awaitingResponse = useStore(view.$awaitingResponse)
const busy = useStore(view.$busy)
const activeGatewayProfile = useStore($activeGatewayProfile)
const contextSuggestions = useStore($contextSuggestions)
// Per-session (SessionView) reads — a tile IS its session, so these come
// from the view slice, not the global atoms (which track the primary only).
@ -356,21 +358,8 @@ export function ChatView({
const threadKey = selectedSessionId || activeSessionId || (isRoutedSessionView ? location.pathname : 'new')
const modelOptionsQuery = useQuery<ModelOptionsResponse>({
queryKey: ['model-options', activeSessionId || 'global'],
queryFn: () => {
if (!activeSessionId) {
return getGlobalModelOptions()
}
if (!gateway) {
throw new Error('Hermes gateway unavailable')
}
return gateway.request<ModelOptionsResponse>('model.options', {
session_id: activeSessionId,
explicit_only: true
})
},
queryKey: modelOptionsQueryKey(activeGatewayProfile, activeSessionId),
queryFn: () => requestModelOptions({ gateway: gateway || undefined, sessionId: activeSessionId }),
enabled: gatewayOpen
})

View file

@ -35,6 +35,7 @@ import type { ChatMessage } from '@/lib/chat-messages'
import { sessionTitle } from '@/lib/chat-runtime'
import { createComposerAttachmentScope } from '@/store/composer'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import { sessionAwaitingInput } from '@/store/prompts'
import {
$gatewayState,
@ -112,6 +113,7 @@ function TileChat({
const { gatewayRef, requestGateway } = useGatewayRequest()
const queryClient = useQueryClient()
const { selectModel } = useModelControls({ queryClient, requestGateway })
const activeGatewayProfile = useStore($activeGatewayProfile)
const cwd = useStore(view.$cwd)
const gatewayOpen = useStore($gatewayState) === 'open'
@ -148,10 +150,11 @@ function TileChat({
<ModelMenuPanel
gateway={gatewayRef.current || undefined}
onSelectModel={selectModel}
profile={activeGatewayProfile}
requestGateway={requestGateway}
/>
) : null,
[gatewayOpen, gatewayRef, requestGateway, selectModel]
[activeGatewayProfile, gatewayOpen, gatewayRef, requestGateway, selectModel]
)
return (

View file

@ -21,7 +21,7 @@ import {
SidebarMenuButton,
SidebarMenuItem
} from '@/components/ui/sidebar'
import { TipKeybindLabel } from '@/components/ui/tooltip'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useContributions } from '@/contrib/react/use-contributions'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
@ -1315,59 +1315,65 @@ export function ChatSidebar({
scoped
/>
<div className="grid size-6 place-items-center">
<Button
aria-label={s.showProjects}
className={HEADER_NAV_BTN}
onClick={event => {
event.stopPropagation()
exitProjectScope()
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="list-unordered" size="0.75rem" />
</Button>
<Tip label={s.showProjects}>
<Button
aria-label={s.showProjects}
className={HEADER_NAV_BTN}
onClick={event => {
event.stopPropagation()
exitProjectScope()
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="list-unordered" size="0.75rem" />
</Button>
</Tip>
</div>
</div>
) : (
<div className="flex shrink-0 items-center gap-0.5">
{!showAllProfiles ? (
<Button
aria-label={agentsGrouped ? s.projects.newButton : s.nav['new-session']}
className={HEADER_ACTION_BTN}
onClick={event => {
event.stopPropagation()
if (agentsGrouped) {
openProjectCreate()
} else {
onNewSessionInWorkspace(null)
}
}}
size="icon-xs"
variant="ghost"
>
<Codicon name="add" size="0.75rem" />
</Button>
) : null}
<div className="grid size-6 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.projects.newButton : s.nav['new-session']}>
<Button
aria-label={agentsGrouped ? s.showSessions : s.showProjects}
className={cn(
HEADER_NAV_BTN,
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
aria-label={agentsGrouped ? s.projects.newButton : s.nav['new-session']}
className={HEADER_ACTION_BTN}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
if (agentsGrouped) {
openProjectCreate()
} else {
onNewSessionInWorkspace(null)
}
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
<Codicon name="add" size="0.75rem" />
</Button>
</Tip>
) : null}
<div className="grid size-6 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.showSessions : s.showProjects}>
<Button
aria-label={agentsGrouped ? s.showSessions : s.showProjects}
className={cn(
HEADER_NAV_BTN,
agentsGrouped && 'bg-(--ui-control-active-background) text-foreground opacity-100'
)}
onClick={event => {
event.stopPropagation()
setSidebarRecentsOpen(true)
setSidebarAgentsGrouped(!agentsGrouped)
}}
size="icon-xs"
variant="ghost"
>
<Codicon name={agentsGrouped ? 'list-unordered' : 'root-folder'} size="0.75rem" />
</Button>
</Tip>
) : null}
</div>
</div>

View file

@ -0,0 +1,54 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SidebarLoadMoreRow } from './load-more-row'
afterEach(cleanup)
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
sidebar: {
loadCount: (n: number) => `Load ${n} more`,
loadMore: 'Load more',
loading: 'Loading…'
}
}
})
}))
// The tooltip's open transition rides a real, un-act()-wrapped Radix timer
// that reliably never fires on the Linux CI runner (see dialog.test.tsx's
// skipped hover test) — so instead of hovering and waiting for the tip to
// open, we assert the structural fix directly: the button is now wrapped in
// a Tip (data-slot="tooltip-trigger"), which is what #<issue> was missing.
describe('SidebarLoadMoreRow', () => {
it('wraps the button in a Tip with the loading label as the trigger', () => {
render(<SidebarLoadMoreRow loading onClick={vi.fn()} step={0} />)
const button = screen.getByRole('button', { name: 'Loading…' })
expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy()
})
it('wraps the button in a Tip with the count label when a step is given', () => {
render(<SidebarLoadMoreRow onClick={vi.fn()} step={5} />)
const button = screen.getByRole('button', { name: 'Load 5 more' })
expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy()
})
it('wraps the button in a Tip with the generic label when step is 0', () => {
render(<SidebarLoadMoreRow onClick={vi.fn()} step={0} />)
const button = screen.getByRole('button', { name: 'Load more' })
expect(button.closest('[data-slot="tooltip-trigger"]')).toBeTruthy()
})
it('still fires onClick (Tip does not intercept the trigger interaction)', () => {
const onClick = vi.fn()
render(<SidebarLoadMoreRow onClick={onClick} step={0} />)
screen.getByRole('button', { name: 'Load more' }).click()
expect(onClick).toHaveBeenCalledOnce()
})
})

View file

@ -1,5 +1,6 @@
import { Codicon } from '@/components/ui/codicon'
import { GlyphSpinner } from '@/components/ui/glyph-spinner'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
interface SidebarLoadMoreRowProps {
@ -16,18 +17,20 @@ export function SidebarLoadMoreRow({ step, onClick, loading = false }: SidebarLo
const label = loading ? t.sidebar.loading : step > 0 ? t.sidebar.loadCount(step) : t.sidebar.loadMore
return (
<button
aria-label={label}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
{loading ? (
<GlyphSpinner ariaLabel={label} className="text-[0.75rem]" />
) : (
<Codicon name="ellipsis" size="0.75rem" />
)}
</button>
<Tip label={label}>
<button
aria-label={label}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground disabled:cursor-default disabled:opacity-60 disabled:hover:bg-transparent disabled:hover:text-(--ui-text-tertiary)"
disabled={loading}
onClick={onClick}
type="button"
>
{loading ? (
<GlyphSpinner ariaLabel={label} className="text-[0.75rem]" />
) : (
<Codicon name="ellipsis" size="0.75rem" />
)}
</button>
</Tip>
)
}

View file

@ -0,0 +1,87 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import type * as Nanostores from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { ProjectDialog } from './project-dialog'
afterEach(cleanup)
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
common: { cancel: 'Cancel', save: 'Save' },
sidebar: {
projects: {
addFolder: 'Add folder',
create: 'Create',
createDesc: 'Create a new project',
createFailed: 'Failed to create project',
createTitle: 'New project',
foldersLabel: 'Folders',
ideaGenerate: 'Generate',
ideaGenerating: 'Generating…',
ideaLabel: 'Idea',
ideaPlaceholder: 'What are you building?',
ideaShuffle: 'Shuffle ideas',
namePlaceholder: 'Project name',
noFolders: 'No folders yet',
primaryBadge: 'Primary',
removeFolder: 'Remove folder'
}
}
}
})
}))
// $projectDialog is a real nanostore atom in the app; recreate it here so
// useStore behaves identically without pulling in the rest of the projects
// store (backend calls, project list, etc.) which is irrelevant to the Tip fix.
// vi.mock factories are hoisted above the rest of the file, so the atom must
// be created inside vi.hoisted to exist by the time the factory runs.
const { $projectDialog } = vi.hoisted(() => {
const { atom } = require('nanostores') as typeof Nanostores
return {
$projectDialog: atom<{ mode: 'create' | 'rename' | 'add-folder'; name?: string; projectId?: string } | null>({
mode: 'create'
})
}
})
vi.mock('@/store/projects', () => ({
$projectDialog,
addProjectFolder: vi.fn(),
closeProjectDialog: vi.fn(),
createProject: vi.fn(),
generateProjectIdea: vi.fn(),
pickProjectFolder: vi.fn(async () => '/Users/test/my-folder'),
renameProject: vi.fn()
}))
vi.mock('@/store/notifications', () => ({
notifyError: vi.fn()
}))
vi.mock('@/lib/project-idea-templates', () => ({
randomIdeaTemplates: () => [{ emoji: '🚀', idea: 'A rocket tracker', label: 'Rocket tracker' }]
}))
const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]')
describe('ProjectDialog', () => {
it('wraps the "shuffle idea" button in a Tip', () => {
render(<ProjectDialog />)
const button = screen.getByRole('button', { name: 'Shuffle ideas' })
expect(tipTrigger(button)).toBeTruthy()
})
it('wraps the "remove folder" button in a Tip once a folder is added', async () => {
render(<ProjectDialog />)
fireEvent.click(screen.getByRole('button', { name: 'Add folder' }))
const button = await screen.findByRole('button', { name: 'Remove folder' })
expect(tipTrigger(button)).toBeTruthy()
})
})

View file

@ -14,6 +14,7 @@ import {
import { GenerateButton } from '@/components/ui/generate-button'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { type ProjectIdeaTemplate, randomIdeaTemplates } from '@/lib/project-idea-templates'
import { cn } from '@/lib/utils'
@ -197,16 +198,18 @@ export function ProjectDialog() {
{p.primaryBadge}
</span>
)}
<Button
aria-label={p.removeFolder}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
<Tip label={p.removeFolder}>
<Button
aria-label={p.removeFolder}
className="size-5 shrink-0 text-(--ui-text-quaternary) hover:text-foreground"
onClick={() => setFolders(prev => prev.filter(f => f !== folder))}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="close" size="0.75rem" />
</Button>
</Tip>
</li>
))}
</ul>
@ -258,17 +261,19 @@ export function ProjectDialog() {
{template.label}
</button>
))}
<Button
aria-label={p.ideaShuffle}
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() => setTemplates(randomIdeaTemplates())}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" />
</Button>
<Tip label={p.ideaShuffle}>
<Button
aria-label={p.ideaShuffle}
className="size-5 text-(--ui-text-quaternary) hover:text-foreground"
disabled={submitting}
onClick={() => setTemplates(randomIdeaTemplates())}
size="icon-xs"
type="button"
variant="ghost"
>
<Codicon name="refresh" size="0.75rem" />
</Button>
</Tip>
</div>
</div>
)}

View file

@ -0,0 +1,69 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/hermes'
import { ProjectOverviewRow } from './overview-row'
import type { SidebarProjectTree } from './workspace-groups'
afterEach(cleanup)
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
sidebar: {
newSessionIn: (label: string) => `New session in ${label}`,
projects: {
enter: (label: string) => `Enter ${label}`,
reorder: (label: string) => `Reorder ${label}`,
toggle: (label: string) => `Toggle ${label} sessions`
}
}
}
})
}))
vi.mock('./model', () => ({
PROJECT_PREVIEW_COUNT: 3,
latestProjectSessions: () => [],
useWorkspaceNodeOpen: () => [false, vi.fn()]
}))
// ProjectMenu (the kebab) has its own dedicated test file — stub it here so
// this file only exercises overview-row's own Tip usage (the disclosure
// toggle) plus the WorkspaceAddButton wiring.
vi.mock('./project-menu', () => ({
ProjectMenu: () => null
}))
const project = { id: 'p1', label: 'Test D' } as unknown as SidebarProjectTree
const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]')
describe('ProjectOverviewRow', () => {
it('wraps the "new session" add button in a Tip with the project-scoped label', () => {
render(<ProjectOverviewRow onNewSession={vi.fn()} project={project} />)
const button = screen.getByRole('button', { name: 'New session in Test D' })
expect(tipTrigger(button)).toBeTruthy()
})
it('wraps the disclosure toggle in a Tip when there are preview sessions', () => {
render(
<ProjectOverviewRow
previewSessions={[{ id: 's1' } as unknown as SessionInfo]}
project={project}
renderRows={() => null}
/>
)
const button = screen.getByRole('button', { name: 'Toggle Test D sessions' })
expect(tipTrigger(button)).toBeTruthy()
})
it('does not render the disclosure toggle when there is nothing to preview', () => {
render(<ProjectOverviewRow project={project} />)
expect(screen.queryByRole('button', { name: 'Toggle Test D sessions' })).toBeNull()
})
})

View file

@ -3,6 +3,7 @@ import { useRef } from 'react'
import { Codicon } from '@/components/ui/codicon'
import { DisclosureCaret } from '@/components/ui/disclosure-caret'
import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
@ -135,17 +136,19 @@ export function ProjectOverviewRow({
{project.label}
</SidebarRowLink>
{preview.length > 0 ? (
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
<Tip label={s.projects.toggle(project.label)}>
<button
aria-label={s.projects.toggle(project.label)}
className="flex flex-1 items-center self-stretch bg-transparent p-0"
onClick={toggleOpen}
type="button"
>
<DisclosureCaret
className="shrink-0 text-(--ui-text-tertiary) opacity-0 transition group-hover/workspace:opacity-100"
open={open}
/>
</button>
</Tip>
) : (
<span className="flex-1" />
)}

View file

@ -0,0 +1,132 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { ProjectMenu } from './project-menu'
import type { SidebarProjectTree } from './workspace-groups'
afterEach(cleanup)
// jsdom doesn't implement ResizeObserver; Radix's PopoverContent/Arrow use it
// (via @radix-ui/react-use-size) to measure the arrow once the popover is
// actually mounted. The kebab-only test above never opens a Popover, so it
// doesn't need this — only the appearance-popover test below does.
beforeAll(() => {
vi.stubGlobal(
'ResizeObserver',
class {
observe() {}
unobserve() {}
disconnect() {}
}
)
})
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
common: { cancel: 'Cancel', confirm: 'Confirm', done: 'Done', loading: 'Loading…' },
sidebar: {
projects: {
copyPath: 'Copy path',
deleteConfirm: 'This cannot be undone.',
menu: 'Project actions',
menuAddFolder: 'Add folder',
menuAppearance: 'Appearance',
menuDelete: 'Delete',
menuRename: 'Rename',
menuSetActive: 'Set active',
noColor: 'No color',
removeFromSidebar: 'Remove from sidebar',
reveal: 'Reveal in file manager'
}
}
}
})
}))
vi.mock('@/store/layout', () => ({
$panesFlipped: {
get: () => false,
listen: () => () => {},
subscribe: (fn: (v: boolean) => void) => {
fn(false)
return () => {}
}
},
dismissAutoProject: vi.fn()
}))
vi.mock('@/store/projects', () => ({
copyPath: vi.fn(),
deleteProject: vi.fn(),
openProjectAddFolder: vi.fn(),
openProjectRename: vi.fn(),
revealPath: vi.fn(),
setActiveProject: vi.fn(),
setProjectAppearance: vi.fn().mockResolvedValue(false)
}))
const project = {
color: null,
icon: null,
id: 'p1',
isAuto: false,
label: 'Test D',
path: '/repo'
} as unknown as SidebarProjectTree
const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]')
const openTriggerMenu = (trigger: HTMLElement) => {
// Radix's dropdown trigger opens on pointerdown (a synthetic 'click' fireEvent
// alone won't do it), so fire the full mouse sequence a real click produces —
// same technique as session-actions-menu.test.tsx (#67500).
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.click(trigger)
}
describe('ProjectMenu', () => {
it('wraps the kebab trigger in a Tip', () => {
render(<ProjectMenu isActive={false} project={project} />)
const button = screen.getByRole('button', { name: 'Project actions' })
expect(tipTrigger(button)).toBeTruthy()
})
// #67500 (Gille, second pass): when anchorRef is absent, the trigger used to
// be `<PopoverAnchor asChild>{trigger}</PopoverAnchor>` where `trigger` was
// ALREADY wrapped in <Tip> — so PopoverAnchor's asChild cloned Tip itself
// (Tip doesn't forward extra props to its children), and the popover's
// real-DOM anchor ref never reached the button. Composing Tip OUTSIDE
// PopoverAnchor (Tip > PopoverAnchor > DropdownMenuTrigger > button) fixes
// that ref delivery.
//
// What this test can't verify: jsdom has no layout engine, so the actual
// POSITIONING the anchor ref enables isn't observable here — same
// limitation already noted above for the icon grid. What it does verify:
// the 3-deep asChild chain doesn't regress into the same silent-drop
// failure as the original bug (#67500, first pass) — the trigger stays a
// real, clickable element that opens the menu and reaches the Appearance
// popover end-to-end, for the anchorRef-absent path specifically (the
// anchorRef-present path never touches PopoverAnchor and is covered by the
// kebab test above).
it('opens the appearance popover through the kebab trigger when anchorRef is absent', async () => {
render(<ProjectMenu isActive={false} project={project} />)
const trigger = screen.getByRole('button', { name: 'Project actions' })
openTriggerMenu(trigger)
const appearanceItem = await screen.findByRole('menuitem', { name: 'Appearance' })
fireEvent.click(appearanceItem)
// The color-swatch "No color" clear option only renders once the
// appearance Popover is actually open — proving the click reached the
// real button through the full Tip > PopoverAnchor > DropdownMenuTrigger
// chain rather than getting silently dropped on an intermediate wrapper.
expect(await screen.findByRole('button', { name: 'No color' })).toBeTruthy()
}, 15000)
})

View file

@ -13,6 +13,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Popover, PopoverAnchor, PopoverContent } from '@/components/ui/popover'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { PROFILE_SWATCHES } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
@ -128,7 +129,14 @@ export function ProjectMenu({
</DropdownMenuItem>
)
const trigger = (
// The bare trigger button (no Tip, no anchor) — composed with whichever of
// Tip / PopoverAnchor apply below, always OUTSIDE the asChild chain that
// ends at this button, never wrapping it directly. asChild clones only its
// immediate child, so any of these wrappers placed inside another
// asChild-consuming component (instead of around it) would have its
// injected props silently swallowed by that inner component instead of
// reaching the real DOM button (see #67500).
const triggerButton = (
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
@ -146,13 +154,24 @@ export function ProjectMenu({
</DropdownMenuTrigger>
)
// Tip always wraps the outermost element of whatever we render — either the
// trigger directly (anchorRef present: the popover anchors to the whole row
// via a separate virtualRef, so PopoverAnchor isn't involved here), or the
// PopoverAnchor-wrapped trigger (anchorRef absent: the popover anchors to
// this button itself). Either way, Tip > PopoverAnchor > DropdownMenuTrigger
// > button, so asChild composes props/ref all the way down to the real DOM
// node instead of stopping at an intermediate wrapper.
const trigger = (
<Tip label={p.menu}>{anchorRef ? triggerButton : <PopoverAnchor asChild>{triggerButton}</PopoverAnchor>}</Tip>
)
return (
<Popover onOpenChange={setAppearanceOpen} open={appearanceOpen}>
{/* Position the appearance popover against the row (when a ref is wired);
the kebab is only the dropdown trigger then. */}
{anchorRef ? <PopoverAnchor virtualRef={anchorRef as React.RefObject<HTMLElement>} /> : null}
<DropdownMenu>
{anchorRef ? trigger : <PopoverAnchor asChild>{trigger}</PopoverAnchor>}
{trigger}
{/* Closing the menu refocuses the trigger (also the popover anchor),
which the appearance popover would read as focus-outside and die on.
Suppress that refocus so it survives. */}
@ -230,19 +249,20 @@ export function ProjectMenu({
profile picker's width (icons flex to fill, not fixed-width). */}
<div className="mt-2 grid grid-cols-6 gap-1.5">
{ICONS.map(name => (
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
key={name}
onClick={() => void applyAppearance({ icon: project.icon === name ? null : name })}
style={project.icon === name && project.color ? { color: project.color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
<Tip key={name} label={name}>
<button
aria-label={name}
className={cn(
'grid aspect-square place-items-center rounded-md text-(--ui-text-tertiary) transition hover:bg-(--ui-control-hover-background)',
project.icon === name && 'bg-(--ui-control-active-background) text-foreground'
)}
onClick={() => void applyAppearance({ icon: project.icon === name ? null : name })}
style={project.icon === name && project.color ? { color: project.color } : undefined}
type="button"
>
<Codicon name={name} size="0.8125rem" />
</button>
</Tip>
))}
</div>
</PopoverContent>

View file

@ -0,0 +1,81 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { StartWorkButton, WorkspaceAddButton, WorkspaceMenu, WorkspaceShowMoreButton } from './workspace-header'
afterEach(cleanup)
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
sidebar: {
projects: {
copyPath: 'Copy path',
menu: 'Project actions',
removeWorktree: 'Remove worktree',
reveal: 'Reveal in file manager',
startWork: 'New worktree'
},
showMoreIn: (n: number, label: string) => `Show ${n} more in ${label}`
}
}
})
}))
vi.mock('@/store/projects', () => ({
copyPath: vi.fn(),
revealPath: vi.fn()
}))
// StartWorkButton renders the full WorktreeDialog (branch picker, git combobox,
// etc.) as soon as it's open — none of that is relevant to the tooltip fix, so
// stub it to keep this test focused on the trigger button.
vi.mock('./worktree-dialog', () => ({
WorktreeDialog: () => null
}))
const tipTrigger = (button: HTMLElement) => button.closest('[data-slot="tooltip-trigger"]')
describe('WorkspaceAddButton', () => {
it('wraps the "+" button in a Tip', () => {
render(<WorkspaceAddButton label="New session in Test D" onClick={vi.fn()} />)
const button = screen.getByRole('button', { name: 'New session in Test D' })
expect(tipTrigger(button)).toBeTruthy()
})
it('still fires onClick', () => {
const onClick = vi.fn()
render(<WorkspaceAddButton label="New session in Test D" onClick={onClick} />)
fireEvent.click(screen.getByRole('button', { name: 'New session in Test D' }))
expect(onClick).toHaveBeenCalledOnce()
})
})
describe('WorkspaceShowMoreButton', () => {
it('wraps the ellipsis button in a Tip with the composed label', () => {
render(<WorkspaceShowMoreButton count={5} label="Test D" onClick={vi.fn()} />)
const button = screen.getByRole('button', { name: 'Show 5 more in Test D' })
expect(tipTrigger(button)).toBeTruthy()
})
})
describe('WorkspaceMenu', () => {
it('wraps the kebab trigger in a Tip', () => {
render(<WorkspaceMenu onRemove={vi.fn()} path="/repo/lane" />)
const button = screen.getByRole('button', { name: 'Project actions' })
expect(tipTrigger(button)).toBeTruthy()
})
})
describe('StartWorkButton', () => {
it('wraps the git-branch trigger in a Tip', () => {
render(<StartWorkButton onStarted={vi.fn()} repoPath="/repo" />)
const button = screen.getByRole('button', { name: 'New worktree' })
expect(tipTrigger(button)).toBeTruthy()
})
})

View file

@ -10,6 +10,7 @@ import {
DropdownMenuSeparator,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { copyPath, revealPath } from '@/store/projects'
@ -39,14 +40,16 @@ function LaneLabel({ label, title }: { label: string; title?: string }) {
// "+" affordance shared by repo and worktree headers — reveals on header hover.
export function WorkspaceAddButton({ label, onClick }: { label: string; onClick: () => void }) {
return (
<button
aria-label={label}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={onClick}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
<Tip label={label}>
<button
aria-label={label}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={onClick}
type="button"
>
<Codicon name="add" size="0.75rem" />
</button>
</Tip>
)
}
@ -64,14 +67,16 @@ export function WorkspaceShowMoreButton({
const text = t.sidebar.showMoreIn(count, label)
return (
<button
aria-label={text}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onClick}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
<Tip label={text}>
<button
aria-label={text}
className="ml-auto grid size-5 place-items-center rounded-sm bg-transparent text-(--ui-text-tertiary) transition-colors hover:bg-(--ui-control-hover-background) hover:text-foreground"
onClick={onClick}
type="button"
>
<Codicon name="ellipsis" size="0.75rem" />
</button>
</Tip>
)
}
@ -84,16 +89,18 @@ export function WorkspaceMenu({ path, onRemove }: { path: null | string; onRemov
return (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
<Tip label={p.menu}>
<DropdownMenuTrigger asChild>
<button
aria-label={p.menu}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100 data-[state=open]:opacity-100"
onClick={event => event.stopPropagation()}
type="button"
>
<Codicon name="kebab-vertical" size="0.75rem" />
</button>
</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent align="end" className="w-48" sideOffset={6}>
<DropdownMenuItem disabled={!path} onSelect={() => void revealPath(path)}>
<Codicon name="folder-opened" size="0.875rem" />
@ -125,14 +132,16 @@ export function StartWorkButton({ repoPath, onStarted }: { repoPath: string; onS
return (
<>
<button
aria-label={p.startWork}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
onClick={() => setOpen(true)}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
<Tip label={p.startWork}>
<button
aria-label={p.startWork}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/section:opacity-100 focus-visible:opacity-100"
onClick={() => setOpen(true)}
type="button"
>
<Codicon name="git-branch" size="0.75rem" />
</button>
</Tip>
<WorktreeDialog onOpenChange={setOpen} onStarted={onStarted} open={open} repoPath={repoPath} />
</>
)

View file

@ -0,0 +1,120 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { SessionActionsMenu } from './session-actions-menu'
afterEach(cleanup)
// This file exists specifically to catch the regression flagged in #67500:
// SessionActionsMenu used to be composed as
// <DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
// with the caller wrapping ITS children in <Tip>. Radix's `asChild` clones
// its single child and injects onClick/aria-haspopup/ref onto it — but Tip
// doesn't forward those extra props to whatever it wraps, so they were
// silently dropped and the menu could stop opening. Tip has since moved
// inside this component (wrapping DropdownMenuTrigger itself, not the other
// way around) — these tests exercise the REAL component end-to-end (no mock
// of DropdownMenu/Tip) so a future regression of this composition fails here.
vi.mock('@/components/pane-shell/tree/store', () => ({
closeAllTreeTabs: vi.fn(),
closeOtherTreeTabs: vi.fn(),
closeTreeTabsToRight: vi.fn(),
treeTabCloseTargets: vi.fn(() => null)
}))
vi.mock('@/hermes', () => ({ renameSession: vi.fn() }))
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
common: { cancel: 'Cancel', close: 'Close', delete: 'Delete', save: 'Save' },
sidebar: {
projects: { menuAppearance: 'Appearance', noColor: 'No color' },
row: {
actionsFor: (title: string) => `Actions for ${title}`,
archive: 'Archive',
branchFrom: 'Branch from here',
copyId: 'Copy ID',
copyIdFailed: 'Failed to copy ID',
export: 'Export',
hideTabBar: 'Hide tab bar',
pin: 'Pin',
rename: 'Rename',
renameDesc: 'Rename this session',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
renamed: 'Renamed',
unpin: 'Unpin',
untitledPlaceholder: 'Untitled'
}
},
zones: { closeAll: 'Close all', closeOthers: 'Close others', closeToRight: 'Close to the right' }
}
})
}))
vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
vi.mock('@/lib/profile-color', () => ({ PROFILE_SWATCHES: [] }))
vi.mock('@/lib/session-export', () => ({ exportSession: vi.fn() }))
vi.mock('@/store/gateway', () => ({ activeGateway: vi.fn(() => null) }))
vi.mock('@/store/notifications', () => ({ notify: vi.fn(), notifyError: vi.fn() }))
vi.mock('@/store/session', () => ({
$activeSessionId: atom<null | string>(null),
$selectedStoredSessionId: atom<null | string>(null),
$sessions: atom<unknown[]>([]),
sessionMatchesStoredId: vi.fn(() => false),
sessionPinId: vi.fn((s: { id: string }) => s.id),
setSessions: vi.fn()
}))
vi.mock('@/store/session-color', () => ({
$sessionColorOverrides: atom<Record<string, string>>({}),
setSessionColorOverride: vi.fn()
}))
vi.mock('@/store/session-states', () => ({
$sessionTiles: atom<unknown[]>([]),
openSessionTile: vi.fn()
}))
vi.mock('@/store/windows', () => ({
canOpenSessionWindow: () => false,
openSessionInNewWindow: vi.fn()
}))
function renderMenu() {
return render(
<SessionActionsMenu sessionId="s1" title="My session" tooltip="Actions for My session">
<button aria-label="Actions for My session" type="button">
</button>
</SessionActionsMenu>
)
}
describe('SessionActionsMenu', () => {
it('shows the tooltip label wired to the real trigger button', () => {
renderMenu()
const trigger = screen.getByRole('button', { name: 'Actions for My session' })
expect(trigger.closest('[data-slot="tooltip-trigger"]')).toBeTruthy()
})
it('still opens the dropdown on click with the trigger wrapped in a Tip (#67500)', async () => {
renderMenu()
const trigger = screen.getByRole('button', { name: 'Actions for My session' })
// Radix's dropdown trigger opens on pointerdown (not on the synthetic
// 'click' fireEvent alone would dispatch), so fire the full mouse
// sequence a real click produces.
fireEvent.pointerDown(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.pointerUp(trigger, { button: 0, pointerType: 'mouse' })
fireEvent.click(trigger)
// If Tip (now composed around DropdownMenuTrigger, not the other way
// round) ever stopped forwarding the asChild-injected props again, this
// menu would never open and these queries would throw instead of
// resolving.
expect(await screen.findByRole('menu')).toBeTruthy()
expect(screen.getByRole('menuitem', { name: /rename/i })).toBeTruthy()
expect(screen.getByRole('menuitem', { name: /archive/i })).toBeTruthy()
})
})

View file

@ -41,6 +41,7 @@ import {
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Tip } from '@/components/ui/tooltip'
import { renameSession } from '@/hermes'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
@ -441,9 +442,21 @@ function useSessionActions({
interface SessionActionsMenuProps
extends SessionActions, Pick<React.ComponentProps<typeof DropdownMenuContent>, 'align' | 'sideOffset'> {
children: React.ReactNode
/** Tooltip label for the trigger. Composed INSIDE the dropdown trigger
* (Tip wraps DropdownMenuTrigger, not the other way around) Tip doesn't
* forward the extra props/ref an `asChild` clone injects, so putting it as
* the trigger's direct child silently drops onClick/aria-haspopup/ref and
* the menu stops opening (#67500). */
tooltip?: React.ReactNode
}
export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ...actions }: SessionActionsMenuProps) {
export function SessionActionsMenu({
children,
tooltip,
align = 'end',
sideOffset = 6,
...actions
}: SessionActionsMenuProps) {
const { t } = useI18n()
const { renameDialog, renderItems } = useSessionActions(actions)
const [open, setOpen] = useState(false)
@ -451,7 +464,9 @@ export function SessionActionsMenu({ children, align = 'end', sideOffset = 6, ..
return (
<>
<DropdownMenu onOpenChange={setOpen} open={open}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
<Tip label={tooltip}>
<DropdownMenuTrigger asChild>{children}</DropdownMenuTrigger>
</Tip>
<DropdownMenuContent
align={align}
aria-label={t.sidebar.row.actionsFor(actions.title)}

View file

@ -0,0 +1,198 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import type * as React from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo } from '@/hermes'
import type * as ComposerStatusStore from '@/store/composer-status'
import type * as SessionStore from '@/store/session'
import type * as SessionStatesStore from '@/store/session-states'
import type * as WindowsStore from '@/store/windows'
import { SidebarSessionRow } from './session-row'
afterEach(cleanup)
vi.mock('@/i18n', () => ({
useI18n: () => ({
t: {
sidebar: {
row: {
actionsFor: (title: string) => `Actions for ${title}`,
ageMin: 'm',
ageNow: 'now',
backgroundRunning: 'Running in background',
finishedUnread: 'Finished',
handoffOrigin: (platform: string) => `Started on ${platform}`,
needsInput: 'Needs input',
sessionRunning: 'Running',
waitingForAnswer: 'Waiting for answer'
}
}
}
})
}))
vi.mock('@/app/chat/profile-tag', () => ({ ProfileTag: () => null }))
vi.mock('@/app/chat/session-drag', () => ({ startSessionDrag: vi.fn() }))
// PlatformAvatar is intentionally NOT mocked (do not reintroduce this — see
// #67500, Gille's third pass): it's a forwardRef component that spreads its
// props onto the rendered span, and mocking it with a stand-in that spreads
// props itself only proves the MOCK forwards them, not that the real
// component does. This file exercises the actual production component so a
// regression in its ref/prop forwarding fails here again.
vi.mock('@/lib/chat-runtime', () => ({ sessionTitle: (s: SessionInfo) => (s as unknown as { title: string }).title }))
vi.mock('@/lib/haptics', () => ({ triggerHaptic: vi.fn() }))
vi.mock('@/lib/session-source', () => ({
handoffOriginSource: (state?: string, platform?: string) => (state && platform ? platform : null),
sessionSourceLabel: (source: string) => source
}))
vi.mock('@/lib/time', () => ({ coarseElapsed: () => ({ unit: 'minute' as const, value: 5 }) }))
// These mocks use importOriginal rather than replacing the module wholesale:
// session-row.tsx (and its transitive imports, e.g. session-color.ts) reads
// several store exports beyond the ones this file cares about, and that set
// keeps growing as the app evolves upstream. A wholesale replacement mock
// silently turns every export it doesn't list into `undefined`, which then
// crashes nanostores' `computed()` the moment a new dependency is added
// upstream (as happened twice already: $stalledSessionIds, then $sessions).
// Overriding only the named atoms we actually control keeps this test
// resilient to that drift.
vi.mock('@/store/composer-status', async importOriginal => {
const actual = await importOriginal<typeof ComposerStatusStore>()
return { ...actual, $backgroundRunningSessionIds: atom<string[]>([]) }
})
vi.mock('@/store/session', async importOriginal => {
const actual = await importOriginal<typeof SessionStore>()
return { ...actual, $unreadFinishedSessionIds: atom<string[]>([]) }
})
vi.mock('@/store/session-states', async importOriginal => {
const actual = await importOriginal<typeof SessionStatesStore>()
return {
...actual,
$attentionSessionIds: atom<string[]>([]),
$stalledSessionIds: atom<string[]>([]),
openSessionTile: vi.fn()
}
})
vi.mock('@/store/windows', async importOriginal => {
const actual = await importOriginal<typeof WindowsStore>()
return {
...actual,
canOpenSessionWindow: () => false,
openSessionInNewWindow: vi.fn()
}
})
// SessionActionsMenu owns the Tip-around-DropdownMenuTrigger composition
// itself now (see session-actions-menu.test.tsx, which exercises that real,
// unmocked end-to-end) — testing it again here via the mock would just
// duplicate that coverage and silently stop testing anything the moment the
// mock's shape drifts from the real component's props (as happened when
// `tooltip` was introduced). This file only needs to confirm session-row
// wires the right tooltip text into the `tooltip` prop, so the mock renders
// it in a way we can assert on directly instead of re-deriving Tip's
// internal DOM structure.
vi.mock('./session-actions-menu', () => ({
SessionActionsMenu: ({ children, tooltip }: { children: React.ReactNode; tooltip?: string }) => (
<div data-testid="session-actions-menu" data-tooltip={tooltip}>
{children}
</div>
),
SessionContextMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>
}))
vi.mock('./use-profile-prewarm', () => ({
useProfilePrewarm: () => ({ cancelPrewarm: vi.fn(), startPrewarm: vi.fn() })
}))
function makeSession(overrides: Partial<SessionInfo> & { title: string }): SessionInfo {
return {
handoff_platform: null,
handoff_state: null,
id: 's1',
last_active: 0,
profile: 'default',
started_at: 0,
...overrides
} as unknown as SessionInfo
}
const tipTrigger = (el: HTMLElement) => el.closest('[data-slot="tooltip-trigger"]')
const noop = vi.fn()
describe('SidebarSessionRow', () => {
it('wires the actions kebab tooltip text through to SessionActionsMenu', () => {
render(
<SidebarSessionRow
isPinned={false}
isSelected={false}
isWorking={false}
onArchive={noop}
onDelete={noop}
onPin={noop}
onResume={noop}
session={makeSession({ title: 'Hermes doctor health check results' })}
/>
)
expect(screen.getByTestId('session-actions-menu').getAttribute('data-tooltip')).toBe(
'Actions for Hermes doctor health check results'
)
})
it('does not render a handoff avatar for a locally-started session', () => {
const { container } = render(
<SidebarSessionRow
isPinned={false}
isSelected={false}
isWorking={false}
onArchive={noop}
onDelete={noop}
onPin={noop}
onResume={noop}
session={makeSession({ title: 'Local session' })}
/>
)
// PlatformAvatar's span is the only aria-hidden SPAN this row ever
// renders (idle dot / arc-border / branch-stem are all inactive here) —
// Codicon icons (e.g. the kebab trigger) are also aria-hidden but render
// as <i>, not <span>, so this selector doesn't accidentally match them.
expect(container.querySelector('span[aria-hidden="true"]')).toBeNull()
})
it('wraps the handoff platform avatar in a Tip for a session started on another platform', () => {
const { container } = render(
<SidebarSessionRow
isPinned={false}
isSelected={false}
isWorking={false}
onArchive={noop}
onDelete={noop}
onPin={noop}
onResume={noop}
session={makeSession({
handoff_platform: 'telegram',
handoff_state: 'active',
title: 'Continued from Telegram'
})}
/>
)
// PlatformAvatar is the REAL component here (see the note above the vi.mock
// block, #67500 third pass) — it renders the Telegram brand SVG rather
// than the platform name as text, so query the avatar span itself (the
// row's only aria-hidden span in this state) rather than text content,
// and confirm its tooltip trigger actually attaches to it — proving the
// real forwardRef/...rest path works, not a mock that fakes it.
const avatar = container.querySelector('span[aria-hidden="true"]')
expect(avatar).toBeTruthy()
expect(tipTrigger(avatar as HTMLElement)).toBeTruthy()
})
})

View file

@ -133,6 +133,7 @@ export function SidebarSessionRow({
profile={session.profile}
sessionId={session.id}
title={title}
tooltip={r.actionsFor(title)}
>
<Button
aria-label={r.actionsFor(title)}

View file

@ -13,6 +13,7 @@ import { Navigate, Route, Routes, useParams } from 'react-router-dom'
import { ContribBoundary } from '@/contrib/react/boundary'
import { useContributions } from '@/contrib/react/use-contributions'
import { $activeGatewayProfile } from '@/store/profile'
import { $freshDraftReady, $gatewayState } from '@/store/session'
import { ChatView } from '../chat'
@ -109,6 +110,7 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
actions: WiringActions
maxVoiceRecordingSeconds?: number
}) {
const activeGatewayProfile = useStore($activeGatewayProfile)
const gatewayState = useStore($gatewayState)
useContributions(ROUTES_AREA)
const routeContributions = contributedRoutes()
@ -128,10 +130,11 @@ export const ChatRoutesSurface = memo(function ChatRoutesSurface({
<ModelMenuPanel
gateway={gateway || undefined}
onSelectModel={actions.selectModel}
profile={activeGatewayProfile}
requestGateway={actions.requestGateway}
/>
) : null,
[actions, gateway, gatewayState]
[actions, activeGatewayProfile, gateway, gatewayState]
)
const chatView = (

View file

@ -139,6 +139,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
const selectedStoredSessionId = useStore($selectedStoredSessionId)
const messagingSessions = useStore($messagingSessions)
const activeGatewayProfile = useStore($activeGatewayProfile)
const profileScope = useStore($profileScope)
const routedSessionId = routeSessionId(location.pathname)
@ -341,6 +342,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}, [activeSessionIdRef, busyRef, selectedStoredSessionIdRef, updateSessionState])
const { handleGatewayEvent } = useMessageStream({
activeGatewayProfile,
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
@ -427,7 +429,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// Swapping the live gateway to another profile must re-pull that profile's
// global model + active-profile pill (both are nanostores — the blanket
// invalidateQueries on swap doesn't touch them).
const activeGatewayProfile = useStore($activeGatewayProfile)
const lastGatewayProfileRef = useRef(activeGatewayProfile)
useEffect(() => {
@ -899,12 +900,21 @@ export function ContribWiring({ children }: { children: ReactNode }) {
void refreshCurrentModel()
void queryClient.invalidateQueries({ queryKey: ['model-options'] })
}}
profile={activeGatewayProfile}
requestGateway={requestGateway}
/>
)}
<ModelPickerOverlay gateway={gatewayRef.current || undefined} onSelect={selectModel} />
<ModelPickerOverlay
gateway={gatewayRef.current || undefined}
onSelect={selectModel}
profile={activeGatewayProfile}
/>
<SessionPickerOverlay onResume={resumeSession} />
<ModelVisibilityOverlay gateway={gatewayRef.current || undefined} onOpenProviders={openProviderSettings} />
<ModelVisibilityOverlay
gateway={gatewayRef.current || undefined}
onOpenProviders={openProviderSettings}
profile={activeGatewayProfile}
/>
<UpdatesOverlay />
<GatewayConnectingOverlay />
<BootFailureOverlay />

View file

@ -7,6 +7,7 @@ import { closeActiveTerminal, createTerminal, cycleTerminal } from '@/app/right-
import { activateTreeTabSlot, cycleTreeTabInFocusedZone, layoutHasRootSide } from '@/components/pane-shell/tree/store'
import { contributedKeybindHandler, PROFILE_SLOT_COUNT, SESSION_SLOT_COUNT } from '@/lib/keybinds/actions'
import { comboAllowedInInput, comboFromEvent, isEditableTarget } from '@/lib/keybinds/combo'
import { composerFocusKeysAllowed, isComposerFocusSoftCombo, typeToFocusChar } from '@/lib/keybinds/composer-focus-keys'
import { $repoStatus } from '@/store/coding-status'
import { toggleCommandPalette } from '@/store/command-palette'
import { $capture, $comboIndex, endCapture, setBinding } from '@/store/keybinds'
@ -122,7 +123,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
handlersRef.current = {
'keybinds.openPanel': () => navigate(`${SETTINGS_ROUTE}?tab=keybinds`),
'composer.focus': () => requestComposerFocus('main'),
'composer.focus': () => requestComposerFocus('active'),
'composer.modelPicker': () => setModelPickerOpen(true),
'composer.voice': requestVoiceToggle,
@ -242,7 +243,15 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
const actionId = $comboIndex.get().get(combo)
// Unbound printable → type-to-focus. Bound chords (shift+n, …) win above.
if (!actionId) {
const typeChar = typeToFocusChar(event)
if (typeChar && composerFocusKeysAllowed(event, 'type')) {
event.preventDefault()
requestComposerFocus('active', { typeChar })
}
return
}
@ -250,6 +259,19 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
return
}
// Soft `/` / Enter: gated so dialogs/buttons/terminal keep those keys.
// Rebound chords fall through to the normal handler.
if (actionId === 'composer.focus' && isComposerFocusSoftCombo(combo)) {
if (!composerFocusKeysAllowed(event, combo)) {
return
}
event.preventDefault()
requestComposerFocus('active', { typeChar: combo === '/' ? '/' : undefined })
return
}
// Built-in handlers first (they carry React context); contributed
// actions bring their own `run` through the registry.
const handler = handlersRef.current[actionId] ?? contributedKeybindHandler(actionId)

View file

@ -12,7 +12,8 @@ import {
SiWechat,
SiWhatsapp
} from '@icons-pack/react-simple-icons'
import type { ComponentType, SVGProps } from 'react'
import type { ComponentPropsWithoutRef, ComponentType, SVGProps } from 'react'
import { forwardRef } from 'react'
import { Globe, Link as LinkIcon, MessageSquareText } from '@/lib/icons'
import { cn } from '@/lib/utils'
@ -54,13 +55,20 @@ const PLATFORM_ICONS: Record<string, PlatformIconSpec> = {
yuanbao: { Icon: SiBilibili, color: '#FB7299', kind: 'brand' }
}
interface PlatformAvatarProps {
interface PlatformAvatarProps extends Omit<ComponentPropsWithoutRef<'span'>, 'children'> {
platformId: string
platformName: string
className?: string
}
export function PlatformAvatar({ className, platformId, platformName }: PlatformAvatarProps) {
// forwardRef + spreading ...rest is required so a wrapping <Tip> (Radix
// Tooltip's `asChild`) can actually attach its trigger: asChild clones this
// component and injects a ref plus pointer/focus/aria handlers onto it. A
// plain function component with no ref/rest forwarding drops all of that
// silently — the tooltip renders but never opens (#67500).
export const PlatformAvatar = forwardRef<HTMLSpanElement, PlatformAvatarProps>(function PlatformAvatar(
{ className, platformId, platformName, style, ...rest },
ref
) {
const spec = PLATFORM_ICONS[platformId]
const baseClass = cn(
@ -70,7 +78,13 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
if (!spec) {
return (
<span aria-hidden="true" className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}>
<span
aria-hidden="true"
className={cn(baseClass, 'bg-(--ui-bg-tertiary) text-(--ui-text-tertiary)')}
ref={ref}
style={style}
{...rest}
>
{platformName.charAt(0).toUpperCase()}
</span>
)
@ -82,14 +96,17 @@ export function PlatformAvatar({ className, platformId, platformName }: Platform
<span
aria-hidden="true"
className={baseClass}
ref={ref}
style={{
// 16% tint of the brand color so the glyph reads against any surface
// without the avatar dominating the row.
backgroundColor: `color-mix(in srgb, ${color} 16%, transparent)`,
color
color,
...style
}}
{...rest}
>
{Icon ? <Icon className="size-3.5" /> : spec.monogram || platformName.charAt(0).toUpperCase()}
</span>
)
}
})

View file

@ -16,9 +16,10 @@ import { $focusedRuntimeId, $focusedSessionState } from '@/store/session-states'
interface ModelPickerOverlayProps {
gateway?: HermesGateway
onSelect: (selection: ModelSelection) => void
profile: string
}
export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProps) {
export function ModelPickerOverlay({ gateway, onSelect, profile }: ModelPickerOverlayProps) {
const primarySessionId = useStore($activeSessionId)
const primaryModel = useStore($currentModel)
const primaryProvider = useStore($currentProvider)
@ -45,6 +46,7 @@ export function ModelPickerOverlay({ gateway, onSelect }: ModelPickerOverlayProp
onOpenChange={setModelPickerOpen}
onSelect={selection => onSelect({ ...selection, sessionId })}
open={open}
profile={profile}
sessionId={sessionId}
/>
)

View file

@ -8,9 +8,10 @@ import { $activeSessionId, $gatewayState } from '@/store/session'
interface ModelVisibilityOverlayProps {
gateway?: HermesGateway
onOpenProviders: () => void
profile: string
}
export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibilityOverlayProps) {
export function ModelVisibilityOverlay({ gateway, onOpenProviders, profile }: ModelVisibilityOverlayProps) {
const activeSessionId = useStore($activeSessionId)
const gatewayOpen = useStore($gatewayState) === 'open'
const open = useStore($modelVisibilityOpen)
@ -25,6 +26,7 @@ export function ModelVisibilityOverlay({ gateway, onOpenProviders }: ModelVisibi
onOpenChange={setModelVisibilityOpen}
onOpenProviders={onOpenProviders}
open={open}
profile={profile}
sessionId={activeSessionId}
/>
)

View file

@ -1,3 +1,4 @@
import type { HermesSkin } from '@hermes/shared/skin'
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
@ -11,6 +12,7 @@ import { coerceGatewayText, coerceThinkingText, normalizePersonalityValue } from
import { playCompletionSound } from '@/lib/completion-sound'
import { resolveGatewayEventSessionId } from '@/lib/gateway-events'
import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
@ -45,6 +47,9 @@ import { clearActiveSessionTodos } from '@/store/todos'
import { recordToolDiff } from '@/store/tool-diffs'
import { reportInstallMethodWarning } from '@/store/updates'
import { notifyWorkspaceChanged, toolChangedPath, toolMayMutateFiles } from '@/store/workspace-events'
// Leaf import (not the `@/themes` barrel) to avoid pulling the ThemeProvider
// module graph into the gateway event hot path.
import { ingestBackendSkin } from '@/themes/backend-sync'
import type { RpcEvent } from '@/types/hermes'
import type { ClientSessionState } from '../../../types'
@ -66,6 +71,7 @@ const COMPACTION_RESUME_EVENT_TYPES = new Set([
])
interface GatewayEventDeps {
activeGatewayProfile: string
activeSessionIdRef: MutableRefObject<string | null>
compactedTurnRef: MutableRefObject<Set<string>>
lastCwdInfoSessionRef: MutableRefObject<string | null>
@ -98,6 +104,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const {
appendAssistantDelta,
appendReasoningDelta,
activeGatewayProfile,
activeSessionIdRef,
compactedTurnRef,
lastCwdInfoSessionRef,
@ -181,6 +188,21 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
if (event.type === 'gateway.ready') {
// Seed the active skin into the desktop theme registry without applying,
// so a fresh connect never overrides the user's persisted desktop theme.
ingestBackendSkin((payload as { skin?: HermesSkin } | undefined)?.skin, { apply: false })
return
} else if (event.type === 'skin.changed') {
// A runtime skin switch (Hermes activating an authored skin, or `/skin`
// on another surface). Only the active profile's change repaints.
const fromActiveProfile =
!event.profile || normalizeProfileKey(event.profile) === normalizeProfileKey($activeGatewayProfile.get())
if (fromActiveProfile) {
ingestBackendSkin(payload as HermesSkin | undefined, { apply: true })
}
return
} else if (event.type === 'session.info') {
// Apply session-scoped fields when the event targets the active
@ -353,7 +375,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
if (modelValueChanged || providerValueChanged) {
void queryClient.invalidateQueries({
queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options']
queryKey:
explicitSid && sessionId ? modelOptionsQueryKey(activeGatewayProfile, sessionId) : ['model-options']
})
}
} else if (event.type === 'message.start') {
@ -818,6 +841,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
appendAssistantDelta,
appendReasoningDelta,
activeSessionIdRef,
activeGatewayProfile,
compactedTurnRef,
completeAssistantMessage,
failAssistantMessage,

View file

@ -32,6 +32,7 @@ import { useGatewayEventHandler } from './gateway-event'
import { completionErrorText, delegateTaskPayloads, STREAM_DELTA_FLUSH_MS } from './utils'
interface MessageStreamOptions {
activeGatewayProfile?: string
activeSessionIdRef: MutableRefObject<string | null>
hydrateFromStoredSession: (
attempts?: number,
@ -55,6 +56,7 @@ interface QueuedStreamDeltas {
}
export function useMessageStream({
activeGatewayProfile = 'default',
activeSessionIdRef,
hydrateFromStoredSession,
queryClient,
@ -613,6 +615,7 @@ export function useMessageStream({
)
const handleGatewayEvent = useGatewayEventHandler({
activeGatewayProfile,
appendAssistantDelta,
appendReasoningDelta,
activeSessionIdRef,

View file

@ -5,6 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
@ -16,6 +17,7 @@ import { useMessageStream } from './index'
// actually changed. message.complete must coalesce sidebar refreshes.
const ACTIVE_SID = 'session-active'
const ACTIVE_PROFILE = 'compass'
let handleEvent: ((event: RpcEvent) => void) | null = null
let refreshHermesConfig: ReturnType<typeof vi.fn<() => Promise<void>>>
let refreshSessions: ReturnType<typeof vi.fn<() => Promise<void>>>
@ -26,6 +28,7 @@ function Harness() {
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const stream = useMessageStream({
activeGatewayProfile: ACTIVE_PROFILE,
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient,
@ -132,7 +135,7 @@ describe('session.info model-options invalidation gating', () => {
sessionInfo(ACTIVE_SID, { model: 'm2', provider: 'p1', running: true })
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['model-options', ACTIVE_SID] })
expect(invalidate).toHaveBeenCalledWith({ queryKey: modelOptionsQueryKey(ACTIVE_PROFILE, ACTIVE_SID) })
})
})

View file

@ -3,6 +3,8 @@ import { cleanup, render, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { getGlobalModelInfo } from '@/hermes'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { $activeGatewayProfile } from '@/store/profile'
import {
$activeSessionId,
$currentModel,
@ -79,6 +81,7 @@ function Harness({
describe('useModelControls', () => {
beforeEach(() => {
$activeGatewayProfile.set('default')
$activeSessionId.set(null)
setCurrentModel('')
setCurrentModelSource('')
@ -88,6 +91,7 @@ describe('useModelControls', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
$activeGatewayProfile.set('default')
$activeSessionId.set(null)
setCurrentModel('')
setCurrentModelSource('')
@ -201,6 +205,26 @@ describe('useModelControls', () => {
expect(setGlobalModel).not.toHaveBeenCalled()
})
it('updates only the active profile new-chat cache', async () => {
const queryClient = new QueryClient()
$activeGatewayProfile.set('compass')
const { result } = renderHook(() =>
useModelControls({
queryClient,
requestGateway: vi.fn()
})
)
await result.current.selectModel({ model: 'qwen3.6:35b-65k', provider: 'custom:local-ollama' })
expect(queryClient.getQueryData(modelOptionsQueryKey('compass'))).toMatchObject({
model: 'qwen3.6:35b-65k',
provider: 'custom:local-ollama'
})
expect(queryClient.getQueryData(modelOptionsQueryKey('default'))).toBeUndefined()
})
it('seeds an empty composer model from global but never clobbers a pick', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
@ -232,7 +256,11 @@ describe('useModelControls', () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const queryClient = new QueryClient()
queryClient.setQueryData(['model-options', 'global'], {
$activeGatewayProfile.set('compass')
queryClient.setQueryData(modelOptionsQueryKey('default'), {
providers: [{ models: ['openrouter/owl-alpha'], name: 'OpenRouter', slug: 'openrouter' }]
})
queryClient.setQueryData(modelOptionsQueryKey('compass'), {
providers: [{ models: ['openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }]
})
@ -253,7 +281,7 @@ describe('useModelControls', () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const queryClient = new QueryClient()
queryClient.setQueryData(['model-options', 'global'], {
queryClient.setQueryData(modelOptionsQueryKey('default'), {
providers: [{ models: ['openrouter/glm-4.7', 'openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }]
})
@ -346,16 +374,16 @@ describe('useModelControls', () => {
})
it('targets an explicit tile sessionId without clobbering the primary model', async () => {
const queryClient = new QueryClient()
$activeGatewayProfile.set('compass')
$activeSessionId.set('primary-runtime')
setCurrentModel('primary/model')
setCurrentProvider('openai')
const requestGateway = vi.fn(async () => ({ key: 'model', value: 'tile-model' }) as never)
let controls!: Controls
render(<Harness onReady={value => (controls = value)} requestGateway={requestGateway} />)
const { result } = renderHook(() => useModelControls({ queryClient, requestGateway }))
await expect(
controls.selectModel({
result.current.selectModel({
model: 'tile-model',
provider: 'anthropic',
sessionId: 'tile-runtime'
@ -370,5 +398,10 @@ describe('useModelControls', () => {
// Primary footer untouched — the busy primary must not absorb a tile pick.
expect($currentModel.get()).toBe('primary/model')
expect($currentProvider.get()).toBe('openai')
expect(queryClient.getQueryData(modelOptionsQueryKey('compass', 'tile-runtime'))).toMatchObject({
model: 'tile-model',
provider: 'anthropic'
})
expect(queryClient.getQueryData(modelOptionsQueryKey('default', 'tile-runtime'))).toBeUndefined()
})
})

View file

@ -4,8 +4,9 @@ import { useCallback, useRef } from 'react'
import type { ModelSelection } from '@/app/shell/model-menu-panel'
import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { manualPickRemoved } from '@/lib/model-options'
import { manualPickRemoved, modelOptionsQueryKey } from '@/lib/model-options'
import { notifyError } from '@/store/notifications'
import { $activeGatewayProfile } from '@/store/profile'
import {
$activeSessionId,
$currentModel,
@ -36,13 +37,19 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
// callbacks once and never re-evaluate — a captured prop would be stale
// forever. The store read is always current.
const updateModelOptionsCache = useCallback(
(sessionId: null | string, provider: string, model: string, includeGlobal: boolean) => {
(
sessionId: null | string,
provider: string,
model: string,
includeGlobal: boolean,
profile = $activeGatewayProfile.get()
) => {
const patch = (prev: ModelOptionsResponse | undefined) => ({ ...(prev ?? {}), provider, model })
queryClient.setQueryData<ModelOptionsResponse>(['model-options', sessionId || 'global'], patch)
queryClient.setQueryData<ModelOptionsResponse>(modelOptionsQueryKey(profile, sessionId), patch)
if (includeGlobal) {
queryClient.setQueryData<ModelOptionsResponse>(['model-options', 'global'], patch)
queryClient.setQueryData<ModelOptionsResponse>(modelOptionsQueryKey(profile), patch)
}
},
[queryClient]
@ -78,7 +85,9 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
return false
}
const options = queryClient.getQueryData<ModelOptionsResponse>(['model-options', 'global'])
const options = queryClient.getQueryData<ModelOptionsResponse>(
modelOptionsQueryKey($activeGatewayProfile.get())
)
return !manualPickRemoved(options?.providers, $currentProvider.get(), $currentModel.get())
}
@ -144,6 +153,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
: ($sessionStates.get()[liveSessionId!]?.provider ?? '')
const prevSource = getCurrentModelSource()
const liveGatewayProfile = $activeGatewayProfile.get()
if (touchesPrimary) {
setCurrentModel(selection.model)
@ -158,7 +168,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
}))
}
updateModelOptionsCache(liveSessionId, selection.provider, selection.model, touchesPrimary && !liveSessionId)
updateModelOptionsCache(
liveSessionId,
selection.provider,
selection.model,
touchesPrimary && !liveSessionId,
liveGatewayProfile
)
// No live session yet: the pick is pure UI state. session.create reads
// $currentModel/$currentProvider and applies it as that session's override.
@ -173,7 +189,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
value: `${selection.model} --provider ${selection.provider} --session`
})
void queryClient.invalidateQueries({ queryKey: ['model-options', liveSessionId] })
void queryClient.invalidateQueries({ queryKey: modelOptionsQueryKey(liveGatewayProfile, liveSessionId) })
return true
} catch (err) {
@ -189,7 +205,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
}))
}
updateModelOptionsCache(liveSessionId, prevProvider, prevModel, touchesPrimary && !liveSessionId)
updateModelOptionsCache(
liveSessionId,
prevProvider,
prevModel,
touchesPrimary && !liveSessionId,
liveGatewayProfile
)
notifyError(err, copy.modelSwitchFailed)
return false

View file

@ -0,0 +1,48 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/icons'
import { Pill } from '../primitives'
import { openExternal } from './open-external'
import type { BillingAccountRowView } from './use-billing-state'
export function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) {
// Destructure to a const so narrowing survives into the onClick closure below.
const { action } = row
return (
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.value}
</span>
)}
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
{row.chips?.map(chip => (
<Button
disabled={chip.disabled}
key={chip.label}
onClick={chip.url ? () => openExternal(chip.url) : undefined}
size="sm"
type="button"
variant="outline"
>
{chip.label}
</Button>
))}
{action && (
<Button
disabled={action.disabled}
onClick={action.disabled ? undefined : onAction ? onAction : () => openExternal(action.url)}
size="sm"
type="button"
variant="outline"
>
{action.label}
{!action.disabled && action.url && <ExternalLink className="size-3.5" />}
</Button>
)}
</div>
)
}

View file

@ -133,6 +133,48 @@ describe('createBillingApi', () => {
})
})
it('previews a subscription change with the chosen tier id', async () => {
requestGatewayMock.mockResolvedValueOnce({ effect: 'scheduled', ok: true, target_tier_name: 'Plus' })
const api = createBillingApi(requestGatewayMock)
const response = await api.previewSubscriptionChange('tier_plus')
expect(response).toEqual({ data: { effect: 'scheduled', ok: true, target_tier_name: 'Plus' }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.preview', { subscription_type_id: 'tier_plus' })
})
it('schedules a subscription change with the chosen tier id', async () => {
requestGatewayMock.mockResolvedValueOnce({ message: 'Downgrade scheduled.', ok: true })
const api = createBillingApi(requestGatewayMock)
const response = await api.scheduleSubscriptionChange('tier_plus')
expect(response).toEqual({ data: { message: 'Downgrade scheduled.', ok: true }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.change', { subscription_type_id: 'tier_plus' })
})
it('resumes (undoes) a scheduled change with no params', async () => {
requestGatewayMock.mockResolvedValueOnce({ message: 'Change cancelled.', ok: true })
const api = createBillingApi(requestGatewayMock)
const response = await api.resumeSubscription()
expect(response).toEqual({ data: { message: 'Change cancelled.', ok: true }, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('subscription.resume', {})
})
it('surfaces an insufficient_scope refusal from a subscription preview', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: { kind: 'insufficient_scope', message: 'billing:manage required' },
ok: false
})
const api = createBillingApi(requestGatewayMock)
const response = await api.scheduleSubscriptionChange('tier_plus')
expect(response).toMatchObject({ ok: false, refusal: { kind: 'insufficient_scope' } })
})
it('sends a step-up session id when provided', async () => {
requestGatewayMock.mockResolvedValueOnce({ granted: true, ok: true })

View file

@ -1,4 +1,4 @@
import { useMemo } from 'react'
import { createContext, useContext, useMemo } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
@ -9,6 +9,7 @@ import type {
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
SubscriptionPreviewResponse,
SubscriptionStateResponse
} from './types'
@ -48,6 +49,12 @@ export interface BillingApi {
chargeStatus: (chargeId: string) => Promise<BillingResult<BillingChargeStatusResponse>>
fetchBillingState: () => Promise<BillingResult<BillingStateResponse>>
fetchSubscriptionState: () => Promise<BillingResult<SubscriptionStateResponse>>
/** Chargeless quote for a plan change (POST /subscription/preview). */
previewSubscriptionChange: (tierId: string) => Promise<BillingResult<SubscriptionPreviewResponse>>
/** Clear a scheduled downgrade / cancellation — the undo (DELETE pending-change). */
resumeSubscription: () => Promise<BillingResult<BillingMutationResponse>>
/** Schedule a chargeless downgrade at period end (PUT pending-change). */
scheduleSubscriptionChange: (tierId: string) => Promise<BillingResult<BillingMutationResponse>>
stepUp: (sessionId?: string) => Promise<BillingResult<BillingMutationResponse>>
updateAutoReload: (input: UpdateAutoReloadInput) => Promise<BillingResult<BillingMutationResponse>>
}
@ -149,6 +156,15 @@ export const createBillingApi = (requestGateway: BillingRequestGateway): Billing
callBilling<BillingChargeStatusResponse>(requestGateway, 'billing.charge_status', { charge_id: chargeId }),
fetchBillingState: () => callBilling<BillingStateResponse>(requestGateway, 'billing.state'),
fetchSubscriptionState: () => callBilling<SubscriptionStateResponse>(requestGateway, 'subscription.state'),
previewSubscriptionChange: tierId =>
callBilling<SubscriptionPreviewResponse>(requestGateway, 'subscription.preview', {
subscription_type_id: tierId
}),
resumeSubscription: () => callBilling<BillingMutationResponse>(requestGateway, 'subscription.resume', {}),
scheduleSubscriptionChange: tierId =>
callBilling<BillingMutationResponse>(requestGateway, 'subscription.change', {
subscription_type_id: tierId
}),
stepUp: sessionId =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.step_up', {
...(sessionId !== undefined ? { session_id: sessionId } : {})
@ -161,8 +177,17 @@ export const createBillingApi = (requestGateway: BillingRequestGateway): Billing
})
})
export function useBillingApi(): BillingApi {
const { requestGateway } = useGatewayRequest()
// An override for the gateway-backed api — DEV fixtures provide a simulated
// implementation here so every consumer (hooks, rows) transparently runs against it
// with no fixture awareness of their own. `null` (the default) = the real gateway api.
const BillingApiContext = createContext<BillingApi | null>(null)
return useMemo(() => createBillingApi(requestGateway), [requestGateway])
export const BillingApiProvider = BillingApiContext.Provider
export function useBillingApi(): BillingApi {
const override = useContext(BillingApiContext)
const { requestGateway } = useGatewayRequest()
const real = useMemo(() => createBillingApi(requestGateway), [requestGateway])
return override ?? real
}

View file

@ -0,0 +1,293 @@
import { useQueryClient } from '@tanstack/react-query'
import { useState } from 'react'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { cn } from '@/lib/utils'
import { ListRow, Pill } from '../primitives'
import { RowValue } from './account-row-value'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
import { BillingRefusalInline } from './inline-feedback'
import type { BillingAutoReload, BillingStateResponse } from './types'
import type { BillingAccountRowView } from './use-billing-state'
export function AutoReloadRow({
autoReload,
bounds,
row
}: {
autoReload: BillingAutoReload
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
row: BillingAccountRowView
}) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
const [editing, setEditing] = useState(false)
// Validation errors are silent until the user edits a field or attempts a
// save — opening Manage on a prefilled (possibly below-min) config must not
// flash an error (spec §9).
const [showErrors, setShowErrors] = useState(false)
const [message, setMessage] = useState<null | { kind: 'error' | 'success'; text: string }>(null)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const [reloadTo, setReloadTo] = useState(
initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display)
)
const [saving, setSaving] = useState(false)
const [threshold, setThreshold] = useState(
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
const busy = saving
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
// Only the canonical-card enabled state edits in place (flagged in the view model).
// Off / divergent-card rows have no Manage affordance (or a portal link) and render
// read-only.
const editable = row.manageInApp === true
const resetFeedback = () => {
setConfirmDisable(false)
setMessage(null)
setRefusal(null)
}
const openEdit = () => {
resetFeedback()
setShowErrors(false)
setEditing(true)
}
const cancelEdit = () => {
resetFeedback()
setEditing(false)
}
const save = async () => {
if (!validation.values || busy) {
return
}
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({
enabled: true,
reload_to_usd: validation.values.reloadTo,
threshold_usd: validation.values.threshold
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
setEditing(false)
}
const disable = async () => {
if (busy) {
return
}
resetFeedback()
setSaving(true)
// The gateway's billing.auto_reload handler unconditionally requires threshold
// + top_up_amount (invalid_request otherwise), so a disable must still carry the
// current amounts — mirroring the TUI, which always sends both.
const result = await api.updateAutoReload({
enabled: false,
reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display),
threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
setEditing(false)
}
// Read-only states (off / divergent card) keep the original ListRow shape.
if (!editable) {
return (
<ListRow
action={<RowValue row={row} />}
below={
<>
{row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : null}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => {
resetFeedback()
setShowErrors(true)
setter(event.target.value)
}
// Zero-shift by exact reservation, not a magic min-height: the edit form is
// ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`),
// so the row's height always equals the tallest state at EVERY container width —
// no breakpoint math that under-reserves when the two inputs stack on narrow
// panes. The form is `invisible` + `aria-hidden` when not editing.
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-start">
<div className="min-w-0">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.title}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{row.description}
</div>
<div className="mt-3 grid [grid-template-areas:'stack']">
{/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
<div aria-hidden={!editing} className={cn('space-y-2 [grid-area:stack]', !editing && 'invisible')}>
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
<Input
aria-label="Auto-refill threshold"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setThreshold)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={threshold}
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
<Input
aria-label="Auto-refill reload-to amount"
className="mt-1 py-[3px]"
disabled={busy || !editing}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={onField(setReloadTo)}
size="sm"
step="0.01"
tabIndex={editing ? undefined : -1}
type="number"
value={reloadTo}
/>
</label>
</div>
{/* Pre-allocated error line — occupies height whether or not shown. */}
<div className="min-h-4 text-[length:var(--conversation-caption-font-size)] text-destructive">
{showErrors && validation.error ? validation.error : ''}
</div>
{confirmDisable ? (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
</Button>
<Button
disabled={busy}
onClick={() => setConfirmDisable(false)}
size="sm"
type="button"
variant="ghost"
>
Cancel
</Button>
</div>
) : (
<Button
disabled={busy}
onClick={() => setConfirmDisable(true)}
size="sm"
tabIndex={editing ? undefined : -1}
type="button"
variant="outline"
>
Disable
</Button>
)}
{/* Refusal stays INSIDE the reserved layer so it never pushes Usage. */}
<BillingRefusalInline refusal={refusal} />
</div>
{/* VIEW layer — success feedback overlaid in the same cell when not editing. */}
{!editing && message && (
<div className="[grid-area:stack]">
<InlineMessage kind={message.kind}>{message.text}</InlineMessage>
</div>
)}
</div>
</div>
{/* Action column swaps Manage ↔ Save/Cancel in place (top-aligned, no move). */}
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{editing ? (
<>
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
</Button>
<Button disabled={busy} onClick={cancelEdit} size="sm" type="button" variant="outline">
Cancel
</Button>
</>
) : (
<Button onClick={openEdit} size="sm" type="button" variant="outline">
Manage
</Button>
)}
</div>
</div>
</div>
)
}
// A one-line success/error note under the row — the only consumer of this shape.
function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) {
return (
<div
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)]',
kind === 'error' ? 'text-destructive' : 'text-(--ui-text-tertiary)'
)}
>
{children}
</div>
)
}

View file

@ -0,0 +1,123 @@
import type { BillingStateResponse } from './types'
import { EMPTY_BILLING_VALUE } from './use-billing-state'
export function clampAmount(raw: string, billing: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>): string {
const amount = parseAmount(raw)
if (amount == null) {
return ''
}
const min = parseAmount(billing.min_usd)
const max = parseAmount(billing.max_usd)
const clampedMin = min == null ? amount : Math.max(min, amount)
const clamped = max == null ? clampedMin : Math.min(max, clampedMin)
return formatAmountForRequest(clamped)
}
export function parseAmount(value?: null | number | string): null | number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') {
return null
}
const parsed = Number(value.replace(/[$,\s]/g, ''))
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
export function formatAmountForRequest(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}
export function initialAutoReloadAmount(...candidates: Array<null | string | undefined>): string {
for (const candidate of candidates) {
const amount = parseAmount(candidate)
if (amount != null) {
return formatAmountForRequest(amount)
}
}
return ''
}
export function validateAutoReloadInputs(
thresholdRaw: string,
reloadToRaw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { error?: string; values?: { reloadTo: string; threshold: string } } {
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
if (threshold.error || threshold.amount == null) {
return { error: threshold.error }
}
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
if (reloadTo.error || reloadTo.amount == null) {
return { error: reloadTo.error }
}
if (reloadTo.amount <= threshold.amount) {
return { error: 'Reload-to amount must be greater than the threshold.' }
}
return {
values: {
reloadTo: formatAmountForRequest(reloadTo.amount),
threshold: formatAmountForRequest(threshold.amount)
}
}
}
export function validateBillingAmount(
label: string,
raw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { amount?: number; error?: string } {
const cleaned = raw.trim().replace(/^\$/, '').trim()
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
}
const amount = Number(cleaned)
if (!(amount > 0)) {
return { error: `${label}: amount must be greater than $0.` }
}
const min = parseAmount(bounds.min_usd)
if (min != null && amount < min) {
return { error: `${label}: minimum is ${formatMoney(min)}.` }
}
const max = parseAmount(bounds.max_usd)
if (max != null && amount > max) {
return { error: `${label}: maximum is ${formatMoney(max)}.` }
}
return { amount }
}
export function formatMoney(value?: null | number | string): string {
const amount = parseAmount(value)
if (amount == null) {
return EMPTY_BILLING_VALUE
}
return new Intl.NumberFormat(undefined, {
currency: 'USD',
maximumFractionDigits: amount % 1 === 0 ? 0 : 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
style: 'currency'
}).format(amount)
}

View file

@ -0,0 +1,57 @@
import { Button } from '@/components/ui/button'
import { ExternalLink } from '@/lib/icons'
import { BillingRefusalInline } from './inline-feedback'
import { openExternal } from './open-external'
import { TierArt } from './tier-art'
import type { BillingPlanCardView } from './use-billing-state'
import { useResumeFlow } from './use-subscription-change'
export function CurrentPlanCard({ onViewPlans, plan }: { onViewPlans: () => void; plan: BillingPlanCardView }) {
const resumeFlow = useResumeFlow()
return (
<div className="@container">
<div className="grid gap-3 py-3 @2xl:grid-cols-[minmax(0,1fr)_minmax(15rem,22rem)] @2xl:items-center">
<div className="flex min-w-0 items-center gap-3">
<TierArt name={plan.tierName} />
<div className="min-w-0">
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2">
<span className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{plan.tierName}
</span>
{plan.price && (
<span className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.price}/mo
</span>
)}
</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{plan.caption}
</div>
</div>
</div>
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{plan.action && (
<Button onClick={onViewPlans} size="sm" type="button" variant="outline">
{plan.action.label}
</Button>
)}
{/* Scheduled downgrade → chargeless undo (subscription.resume), no confirm. */}
{plan.pending && (
<Button disabled={resumeFlow.busy} onClick={() => void resumeFlow.resume()} size="sm" type="button">
{resumeFlow.busy ? 'Undoing…' : 'Undo'}
</Button>
)}
{plan.link && (
<Button onClick={() => plan.link && openExternal(plan.link.url)} size="sm" type="button" variant="outline">
{plan.link.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
</div>
<BillingRefusalInline refusal={resumeFlow.refusal} />
</div>
)
}

View file

@ -1,5 +1,5 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption } from './types'
const current = (
overrides: Partial<NonNullable<SubscriptionStateResponse['current']>> = {}
@ -207,6 +207,144 @@ export const loggedOutSubscriptionState = {
usage: undefined
} satisfies SubscriptionStateResponse
// Full four-tier personal catalog. tier_ids are cuid-like (Prisma) on purpose:
// tier art keys off the lowercase NAME, never the id (ids differ per env). Dollar
// credits are 0.1 / 22 / 110 / 220 to exercise the "$X credits/mo" formatting.
const personalTierCatalog: SubscriptionTierOption[] = [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'cltier000free0000personal',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'cltier111plus1111personal',
tier_order: 1
},
{
dollars_per_month_display: '$100',
is_current: false,
is_enabled: true,
monthly_credits: '110',
name: 'Super',
tier_id: 'cltier222super222personal',
tier_order: 2
},
{
dollars_per_month_display: '$200',
is_current: false,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'cltier333ultra333personal',
tier_order: 3
}
]
const catalogWithCurrent = (currentTierId: null | string): SubscriptionTierOption[] =>
personalTierCatalog.map(tier => ({ ...tier, is_current: tier.tier_id === currentTierId }))
// Logged-in personal org, no subscription: exercises the "View plans" plan card
// and the full plans grid where every tier is an upgrade.
export const freePersonalBillingState = {
...postTrainBillingState,
balance_display: '$12.00',
balance_usd: '12.00',
org_name: 'Personal',
usage: {
available: true,
has_topup: true,
plan_name: 'Free',
renews_at: null,
renews_display: null,
status: 'active',
subscription_remaining_display: '$0',
topup_remaining_display: '$12.00',
total_spendable_display: '$12.00'
}
} satisfies BillingStateResponse
export const freePersonalSubscriptionState = {
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
org_id: 'org_personal_free',
org_name: 'Personal',
tiers: catalogWithCurrent(null),
usage: freePersonalBillingState.usage
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus: exercises the "Change plan" plan card, the current
// marker, upgrades (Super/Ultra), and the disabled downgrade (Free).
export const subscriberPersonalBillingState = {
...postTrainBillingState,
org_name: 'Personal',
usage: {
...postTrainBillingState.usage,
plan_name: 'Plus'
}
} satisfies BillingStateResponse
export const subscriberPersonalSubscriptionState = {
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: current({
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
}),
org_id: 'org_personal_plus',
org_name: 'Personal',
tiers: catalogWithCurrent('cltier111plus1111personal'),
usage: subscriberPersonalBillingState.usage
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus with a downgrade to Free already scheduled at period
// end: exercises the plan-card pending state + undo, and the grid's "Scheduled"
// marker on Free while Super/Ultra stay choosable.
export const pendingDowngradeSubscriptionState = {
...subscriberPersonalSubscriptionState,
current: current({
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
pending_downgrade_at: '2026-08-15T00:00:00Z',
pending_downgrade_display: 'Aug 15',
pending_downgrade_tier_name: 'Free',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
})
} satisfies SubscriptionStateResponse
// Personal subscriber on Plus with a cancellation (not a downgrade) scheduled at
// period end: exercises the plan-card "Cancels on …" copy + undo, with NO Scheduled
// grid marker (a cancellation has no target tier).
export const pendingCancellationSubscriptionState = {
...subscriberPersonalSubscriptionState,
current: current({
cancel_at_period_end: true,
cancellation_effective_at: '2026-08-15T00:00:00Z',
cancellation_effective_display: 'Aug 15',
credits_remaining: '12',
cycle_ends_at: '2026-08-15T00:00:00Z',
monthly_credits: '22',
tier_id: 'cltier111plus1111personal',
tier_name: 'Plus'
})
} satisfies SubscriptionStateResponse
const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
@ -270,6 +408,22 @@ function withUsage(
export const billingDevFixtures = {
healthy: withUsage('Healthy', { monthlyCapSpent: '89', remaining: '132' }),
'free-personal': {
billing: okBilling(freePersonalBillingState),
subscription: okSubscription(freePersonalSubscriptionState)
},
'subscriber-personal': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(subscriberPersonalSubscriptionState)
},
'pending-cancellation': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(pendingCancellationSubscriptionState)
},
'pending-downgrade': {
billing: okBilling(subscriberPersonalBillingState),
subscription: okSubscription(pendingDowngradeSubscriptionState)
},
'auto-refill-divergent': withUsage('Auto Refill Divergent', {
autoReload: {
...postTrainBillingState.auto_reload,

View file

@ -1,5 +1,7 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { MemoryRouter } from 'react-router-dom'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
@ -23,28 +25,38 @@ const apiMocks = vi.hoisted(() => ({
fetchBillingState: vi.fn(),
fetchSubscriptionState: vi.fn(),
openExternal: vi.fn(),
previewSubscriptionChange: vi.fn(),
resumeSubscription: vi.fn(),
scheduleSubscriptionChange: vi.fn(),
stepUp: vi.fn(),
updateAutoReload: vi.fn()
}))
vi.mock('./api', () => ({
// Pass-through provider — the mocked useBillingApi ignores any override anyway.
BillingApiProvider: ({ children }: { children: ReactNode }) => children,
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus,
fetchBillingState: apiMocks.fetchBillingState,
fetchSubscriptionState: apiMocks.fetchSubscriptionState,
previewSubscriptionChange: apiMocks.previewSubscriptionChange,
resumeSubscription: apiMocks.resumeSubscription,
scheduleSubscriptionChange: apiMocks.scheduleSubscriptionChange,
stepUp: apiMocks.stepUp,
updateAutoReload: apiMocks.updateAutoReload
})
}))
function renderBilling() {
function renderBilling(initialEntries: string[] = ['/settings?tab=billing']) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
<MemoryRouter initialEntries={initialEntries}>
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
</MemoryRouter>
)
return client
@ -79,7 +91,7 @@ describe('BillingSettings', () => {
)
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy()
expect(screen.getByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByText('$120 of $220 left')).toBeTruthy()
expect(screen.getByText('$876.47')).toBeTruthy()
expect(screen.getByText('$10 of $100 used').classList.contains('tabular-nums')).toBe(true)
@ -163,6 +175,17 @@ describe('BillingSettings', () => {
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
})
it('renders the enabled auto-refill row without crashing when the card is null', async () => {
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } })
)
renderBilling()
expect(await screen.findByText('Charges $10 automatically when your balance falls below $5.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Manage' })).toBeTruthy()
})
it('requires inline confirmation before disabling auto-refill', async () => {
renderBilling()
@ -176,7 +199,321 @@ describe('BillingSettings', () => {
fireEvent.click(screen.getByRole('button', { name: 'Turn off' }))
await waitFor(() => expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: false }))
// The gateway requires threshold + top_up_amount even to disable, so the current
// amounts ride along (todayBillingState: threshold $5, reload-to $10).
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: false,
reload_to_usd: '10',
threshold_usd: '5'
})
)
})
it('opens auto-refill edit without a validation error even when the saved config is below the minimum', async () => {
// todayBillingState: threshold $5 with min_usd $10 — invalid, but opening
// Manage must stay silent until the user edits or attempts to save (spec §9).
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
expect(screen.queryByText('Threshold: minimum is $10.')).toBeNull()
// Save is disabled because the prefilled config is invalid — but no error yet.
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
})
it('navigates to the in-app plans grid from the plan card and back', async () => {
const fixture = billingDevFixtures['free-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'View plans' }))
expect(await screen.findByText('Plans')).toBeTruthy()
// No subscription → the free tier is the inert current plan, the three paid
// tiers are "Choose ↗" upgrades (no "subscribe to Free").
expect(screen.getByText('Current plan')).toBeTruthy()
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(3)
expect(screen.queryByRole('button', { name: 'Downgrade' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Back to billing' }))
expect(await screen.findByRole('button', { name: 'View plans' })).toBeTruthy()
})
it('renders the current marker and an actionable downgrade when deep-linked to the plans grid', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Current plan')).toBeTruthy()
// Free sits below Plus → an in-app (enabled) "Downgrade" button, not disabled.
expect(screen.getByRole('button', { name: 'Downgrade' }).hasAttribute('disabled')).toBe(false)
// Super + Ultra are upgrades.
expect(screen.getAllByRole('button', { name: /Choose/ }).length).toBe(2)
})
it('falls back to overview (no live Choose grid) when a team deep-links bview=plans', async () => {
// Default beforeEach uses todaySubscriptionState (context: 'team') — no in-app
// plans capability, so the URL must not surface a grid of Choose buttons.
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('falls back to overview when a non-changer personal account deep-links bview=plans', async () => {
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
renderBilling(['/settings?tab=billing&bview=plans'])
expect(await screen.findByText('Payment')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
})
it('runs an in-app downgrade: preview → confirm → schedule with the tier id → refetch → overview', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: {
effect: 'scheduled',
effective_at: '2026-08-15T00:00:00Z',
monthly_credits_delta: '-88',
ok: true,
target_tier_name: 'Free'
},
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const client = renderBilling(['/settings?tab=billing&bview=plans'])
const invalidate = vi.spyOn(client, 'invalidateQueries')
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
await waitFor(() => expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
expect(await screen.findByText(/No charge now/)).toBeTruthy()
// Credits delta renders as signed dollars, not the raw wire string "-88".
expect(screen.getByText(/Monthly credits change: \$88\/mo\./)).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Confirm downgrade' }))
await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
// Scheduled → back on the overview.
expect(await screen.findByText('Payment')).toBeTruthy()
expect(screen.queryByText('Plans')).toBeNull()
})
it('surfaces the step-up affordance when scheduling a downgrade needs approval', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
renderBilling(['/settings?tab=billing&bview=plans'])
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
fireEvent.click(await screen.findByRole('button', { name: 'Confirm downgrade' }))
expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
// The failed schedule offers a retry in place.
expect(screen.getByRole('button', { name: 'Try again' })).toBeTruthy()
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledTimes(1)
})
it('undoes a scheduled downgrade from the plan card via resume', async () => {
const fixture = billingDevFixtures['pending-downgrade']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
expect(await screen.findByText('Changes to Free on Aug 15.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() => expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1))
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
})
it('undoes a scheduled cancellation from the plan card via resume', async () => {
const fixture = billingDevFixtures['pending-cancellation']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
renderBilling()
expect(await screen.findByText('Cancels on Aug 15.')).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Undo' }))
await waitFor(() => expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1))
})
it('locks out the other downgrade tiles and Back while a schedule is in flight', async () => {
// Current = Ultra so Free/Plus/Super are all downgrades (three tiles).
apiMocks.fetchBillingState.mockResolvedValue(billingDevFixtures['subscriber-personal'].billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 't_ultra', tier_name: 'Ultra' },
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 't_free',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 't_plus',
tier_order: 1
},
{
dollars_per_month_display: '$100',
is_current: false,
is_enabled: true,
monthly_credits: '110',
name: 'Super',
tier_id: 't_super',
tier_order: 2
},
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 't_ultra',
tier_order: 3
}
]
})
)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
let settleSchedule: (value: unknown) => void = () => {}
apiMocks.scheduleSubscriptionChange.mockReturnValue(
new Promise(resolve => {
settleSchedule = resolve
})
)
renderBilling(['/settings?tab=billing&bview=plans'])
const downgrades = await screen.findAllByRole('button', { name: 'Downgrade' })
expect(downgrades.length).toBe(3)
fireEvent.click(downgrades[0])
fireEvent.click(await screen.findByRole('button', { name: 'Confirm downgrade' }))
// Scheduling in flight → the two remaining tiles + Back are disabled.
await waitFor(() => {
const remaining = screen.getAllByRole('button', { name: 'Downgrade' })
expect(remaining.length).toBe(2)
expect(remaining.every(btn => btn.hasAttribute('disabled'))).toBe(true)
})
expect(screen.getByRole('button', { name: 'Back to billing' }).hasAttribute('disabled')).toBe(true)
settleSchedule({ data: { ok: true }, ok: true })
})
it('disables Undo while the resume is in flight', async () => {
const fixture = billingDevFixtures['pending-downgrade']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
let settleResume: (value: unknown) => void = () => {}
apiMocks.resumeSubscription.mockReturnValue(
new Promise(resolve => {
settleResume = resolve
})
)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Undo' }))
await waitFor(() => expect(screen.getByRole('button', { name: 'Undoing…' }).hasAttribute('disabled')).toBe(true))
settleResume({ data: { ok: true }, ok: true })
})
it('moves focus into the confirm panel (role=status) when a downgrade opens', async () => {
const fixture = billingDevFixtures['subscriber-personal']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', effective_at: '2026-08-15T00:00:00Z', ok: true, target_tier_name: 'Free' },
ok: true
})
renderBilling(['/settings?tab=billing&bview=plans'])
fireEvent.click(await screen.findByRole('button', { name: 'Downgrade' }))
const panel = await screen.findByRole('status')
expect(panel.getAttribute('aria-live')).toBe('polite')
expect(panel).toBe(panel.ownerDocument.activeElement)
})
it('keeps the auto-refill edit form mounted so the row height is reserved before editing', async () => {
renderBilling()
await screen.findByRole('button', { name: 'Manage' })
// Not editing: the inputs are already in the DOM (height reserved) but aria-hidden,
// so the accessible query finds nothing while the hidden-inclusive query does.
expect(screen.queryByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeNull()
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold', hidden: true })).toBeTruthy()
fireEvent.click(screen.getByRole('button', { name: 'Manage' }))
// Editing reveals the same reserved input.
expect(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' })).toBeTruthy()
})
it('renders auto-refill mutation refusals and step-up affordance', async () => {

View file

@ -5,22 +5,28 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Tip } from '@/components/ui/tooltip'
import { BarChart3, ExternalLink, RefreshCw } from '@/lib/icons'
import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { ListRow, Pill, SectionHeading, SettingsContent } from '../primitives'
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
import { ListRow, SectionHeading, SettingsContent } from '../primitives'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { RowValue } from './account-row-value'
import { BillingApiProvider } from './api'
import { AutoReloadRow } from './auto-reload-row'
import { clampAmount, formatMoney } from './billing-amounts'
import { CurrentPlanCard } from './current-plan-card'
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
import { resolveRefusal } from './errors'
import type { BillingAutoReload, BillingStateResponse } from './types'
import { StepUpInlineAction } from './inline-feedback'
import { openExternal } from './open-external'
import { BillingPlansView } from './plans-view'
import { createSimulatedBillingApi } from './simulated-api'
import type { BillingStateResponse } from './types'
import {
type BillingAccountRowView,
type BillingNoticeView,
type BillingUsageRowView,
deriveBillingView,
EMPTY_BILLING_VALUE,
formatUsageUpdatedAgo,
useBillingState,
useSubscriptionState
@ -28,6 +34,11 @@ import {
import { useChargeFlow } from './use-charge-poller'
import { useStepUpFlow } from './use-step-up'
// `bview` mirrors the settings pview/kview sub-view pattern (deep-linkable, replace
// navigation). `overview` is the default landing; `plans` is the in-app catalog.
const BILLING_VIEWS = ['overview', 'plans'] as const
type BillingSubView = (typeof BILLING_VIEWS)[number]
const FEATURE_BILLING_INVOICES = false
const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
@ -36,14 +47,6 @@ const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
type BillingFixtureSelection = 'live' | BillingDevFixtureName
function openExternal(url?: string) {
if (!url) {
return
}
void window.hermesDesktop?.openExternal?.(url)
}
function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | 'primary'; value: string }) {
return (
<div className="min-w-0">
@ -83,44 +86,6 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
)
}
function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccountRowView }) {
return (
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{row.value && (
<span className="min-w-0 truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{row.value}
</span>
)}
{row.pill && <Pill tone={row.pill.tone}>{row.pill.label}</Pill>}
{row.secondaryPill && <Pill>{row.secondaryPill}</Pill>}
{row.chips?.map(chip => (
<Button
disabled={chip.disabled}
key={chip.label}
onClick={chip.url ? () => openExternal(chip.url) : undefined}
size="sm"
type="button"
variant="outline"
>
{chip.label}
</Button>
))}
{row.action && (
<Button
disabled={row.action.disabled}
onClick={row.action.disabled ? undefined : onAction ? onAction : () => openExternal(row.action?.url)}
size="sm"
type="button"
variant="outline"
>
{row.action.label}
{!row.action.disabled && row.action.url && <ExternalLink className="size-3.5" />}
</Button>
)}
</div>
)
}
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
return <BuyCreditsRow billing={billing} row={row} />
@ -147,205 +112,6 @@ function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: Bil
)
}
function AutoReloadRow({
autoReload,
bounds,
row
}: {
autoReload: BillingAutoReload
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
row: BillingAccountRowView
}) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [confirmDisable, setConfirmDisable] = useState(false)
const [editing, setEditing] = useState(false)
const [message, setMessage] = useState<null | { kind: 'error' | 'success'; text: string }>(null)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const [reloadTo, setReloadTo] = useState(
initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display)
)
const [saving, setSaving] = useState(false)
const [threshold, setThreshold] = useState(
initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
)
const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
const busy = saving
const maxBound = bounds.max_usd ?? undefined
const minBound = bounds.min_usd ?? undefined
const resetFeedback = () => {
setConfirmDisable(false)
setMessage(null)
setRefusal(null)
}
const save = async () => {
if (!validation.values || busy) {
return
}
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({
enabled: true,
reload_to_usd: validation.values.reloadTo,
threshold_usd: validation.values.threshold
})
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill updated.' })
setEditing(false)
}
const disable = async () => {
if (busy) {
return
}
resetFeedback()
setSaving(true)
const result = await api.updateAutoReload({ enabled: false })
setSaving(false)
if (!result.ok) {
setRefusal(result.refusal)
return
}
await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
setEditing(false)
}
const below = editing ? (
<div className="mt-3 space-y-3">
<div className="grid gap-2 @2xl:grid-cols-2">
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Threshold
<Input
aria-label="Auto-refill threshold"
className="mt-1 h-8"
disabled={busy}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={event => {
resetFeedback()
setThreshold(event.target.value)
}}
step="0.01"
type="number"
value={threshold}
/>
</label>
<label className="min-w-0 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Reload to
<Input
aria-label="Auto-refill reload-to amount"
className="mt-1 h-8"
disabled={busy}
inputMode="decimal"
max={maxBound}
min={minBound}
onChange={event => {
resetFeedback()
setReloadTo(event.target.value)
}}
step="0.01"
type="number"
value={reloadTo}
/>
</label>
</div>
{validation.error && (
<div className="text-[length:var(--conversation-caption-font-size)] text-destructive">{validation.error}</div>
)}
<div className="flex min-w-0 flex-wrap items-center gap-2">
<Button disabled={busy || !validation.values} onClick={() => void save()} size="sm" type="button">
{busy ? 'Saving…' : 'Save'}
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(true)} size="sm" type="button" variant="outline">
Disable
</Button>
<Button
disabled={busy}
onClick={() => {
resetFeedback()
setEditing(false)
}}
size="sm"
type="button"
variant="outline"
>
Cancel
</Button>
</div>
{confirmDisable && (
<div className="flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Turn off auto-refill?</span>
<Button disabled={busy} onClick={() => void disable()} size="sm" type="button" variant="outline">
Turn off
</Button>
<Button disabled={busy} onClick={() => setConfirmDisable(false)} size="sm" type="button" variant="ghost">
Cancel
</Button>
</div>
)}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</div>
) : (
<>
{row.caption ? (
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{row.caption}
</div>
) : null}
<BillingRefusalInline refusal={refusal} />
{message && <InlineMessage kind={message.kind}>{message.text}</InlineMessage>}
</>
)
return (
<ListRow
action={
<RowValue
onAction={
row.action?.url
? undefined
: () => {
resetFeedback()
setEditing(true)
}
}
row={row}
/>
}
below={below}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: BillingAccountRowView }) {
const presets = useMemo(
() =>
@ -392,7 +158,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
))}
<Input
aria-label="Custom credit amount"
className="h-8 w-24"
className="w-24 py-[3px]"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
@ -403,6 +169,7 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
setAmount(event.target.value)
}}
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
size="sm"
step="0.01"
type="number"
value={amount}
@ -508,82 +275,6 @@ function BuyCreditsOutcome({
)
}
function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
const stepUp = useStepUpFlow()
if (!refusal) {
return null
}
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
<span className="font-medium text-foreground">{resolved.title}:</span> {resolved.message}
</span>
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => openExternal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
if (flow.verification) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
Open verification page
<ExternalLink className="size-3.5" />
</Button>
</span>
)
}
if (flow.message) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span>
{flow.message.title}: {flow.message.text}
</span>
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
Dismiss
</Button>
</span>
)
}
if (flow.phase === 'waiting') {
return <span>Waiting for verification link</span>
}
return (
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
Verify to continue
</Button>
)
}
function InlineMessage({ children, kind }: { children: string; kind: 'error' | 'success' }) {
return (
<div
className={cn(
'mt-2 text-[length:var(--conversation-caption-font-size)]',
kind === 'error' ? 'text-destructive' : 'text-(--ui-text-tertiary)'
)}
>
{children}
</div>
)
}
function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fallbackLabel: string }) {
const resolvedBar = bar ?? {
label: `${fallbackLabel} usage`,
@ -762,13 +453,15 @@ function BillingSettingsContent({
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const fixture =
import.meta.env.DEV && fixtureName && fixtureName !== 'live' ? billingDevFixtures[fixtureName] : undefined
const [subView, setSubView] = useRouteEnumParam<BillingSubView>('bview', BILLING_VIEWS, 'overview')
const billingState = useBillingState(!fixture)
const subscriptionState = useSubscriptionState(!fixture)
const billingResult = fixture?.billing ?? billingState.data
const subscriptionResult = fixture?.subscription ?? subscriptionState.data
// Fixture mode flows through the SAME query path — the simulated api (supplied by
// BillingApiProvider in the DEV wrapper) backs these fetches — so there is no
// fixture short-circuit here.
const billingState = useBillingState()
const subscriptionState = useSubscriptionState()
const billingResult = billingState.data
const subscriptionResult = subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const billing = billingResult?.ok ? billingResult.data : undefined
const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt)
@ -778,6 +471,22 @@ function BillingSettingsContent({
void Promise.all([billingState.refetch(), subscriptionState.refetch()])
}
const { paymentRow, refillRow, topupRow } = view
// Gate the plans sub-view on the SAME capability that renders the in-app button
// (`plan.action`): a team / non-changer deep-linking `bview=plans` must never
// reach a grid of live Choose buttons — it falls back to the overview.
const showPlans = subView === 'plans' && view.status === 'normal' && Boolean(view.plan?.action)
if (showPlans) {
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<BillingPlansView onBack={() => setSubView('overview')} tiers={view.tiers} />
</SettingsContent>
)
}
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
@ -792,13 +501,32 @@ function BillingSettingsContent({
{view.notice && <NoticeCard notice={view.notice} />}
{view.accountRows.length > 0 && (
<>
<SectionHeading icon={BarChart3} title="Account" />
{view.accountRows.map(row => (
<AccountRow billing={billing} key={row.id} row={row} />
))}
</>
{view.plan && (
<div className="mb-5">
<SectionHeading icon={Package} title="Plan" />
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
</div>
)}
{paymentRow && (
<div className="mb-5">
<SectionHeading icon={Lock} title="Payment" />
<AccountRow billing={billing} row={paymentRow} />
</div>
)}
{topupRow && (
<div className="mb-5">
<SectionHeading icon={Plus} title="One-time top-up" />
<AccountRow billing={billing} row={topupRow} />
</div>
)}
{refillRow && (
<div className="mb-5">
<SectionHeading icon={RefreshCw} title="Automatic refill" />
<AccountRow billing={billing} row={refillRow} />
</div>
)}
{view.usageRows.length > 0 && (
@ -828,8 +556,27 @@ function BillingSettingsContent({
function BillingSettingsWithDevFixtures() {
const [fixtureName, setFixtureName] = useState<BillingFixtureSelection>('live')
const queryClient = useQueryClient()
return <BillingSettingsContent fixtureName={fixtureName} onFixtureChange={setFixtureName} />
// DEV-only: a picked fixture is served by a simulated api (in-memory, mutable) that
// the whole subtree resolves via BillingApiProvider → useBillingApi. `live` → null →
// the real gateway api. Rebuilt per fixture so switching starts from a fresh copy.
const simulatedApi = useMemo(
() => (fixtureName !== 'live' ? createSimulatedBillingApi(billingDevFixtures[fixtureName]) : null),
[fixtureName]
)
// Switching fixtures (or its simulated api) must refetch, since the billing queries
// are keyed the same across fixtures.
useEffect(() => {
void queryClient.invalidateQueries({ queryKey: ['billing'] })
}, [queryClient, simulatedApi])
return (
<BillingApiProvider value={simulatedApi}>
<BillingSettingsContent fixtureName={fixtureName} onFixtureChange={setFixtureName} />
</BillingApiProvider>
)
}
export function BillingSettings() {
@ -840,129 +587,8 @@ export function BillingSettings() {
return <BillingSettingsContent />
}
function clampAmount(raw: string, billing: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>): string {
const amount = parseAmount(raw)
if (amount == null) {
return ''
}
const min = parseAmount(billing.min_usd)
const max = parseAmount(billing.max_usd)
const clampedMin = min == null ? amount : Math.max(min, amount)
const clamped = max == null ? clampedMin : Math.min(max, clampedMin)
return formatAmountForRequest(clamped)
}
function parseAmount(value?: null | number | string): null | number {
if (typeof value === 'number') {
return Number.isFinite(value) ? value : null
}
if (typeof value !== 'string') {
return null
}
const parsed = Number(value.replace(/[$,\s]/g, ''))
return Number.isFinite(parsed) && parsed > 0 ? parsed : null
}
function formatAmountForRequest(value: number): string {
return Number.isInteger(value) ? String(value) : value.toFixed(2).replace(/0+$/, '').replace(/\.$/, '')
}
function oldestUpdatedAt(...timestamps: number[]): number {
const populated = timestamps.filter(timestamp => timestamp > 0)
return populated.length > 0 ? Math.min(...populated) : Date.now()
}
function initialAutoReloadAmount(...candidates: Array<null | string | undefined>): string {
for (const candidate of candidates) {
const amount = parseAmount(candidate)
if (amount != null) {
return formatAmountForRequest(amount)
}
}
return ''
}
function validateAutoReloadInputs(
thresholdRaw: string,
reloadToRaw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { error?: string; values?: { reloadTo: string; threshold: string } } {
const threshold = validateBillingAmount('Threshold', thresholdRaw, bounds)
if (threshold.error || threshold.amount == null) {
return { error: threshold.error }
}
const reloadTo = validateBillingAmount('Reload-to', reloadToRaw, bounds)
if (reloadTo.error || reloadTo.amount == null) {
return { error: reloadTo.error }
}
if (reloadTo.amount <= threshold.amount) {
return { error: 'Reload-to amount must be greater than the threshold.' }
}
return {
values: {
reloadTo: formatAmountForRequest(reloadTo.amount),
threshold: formatAmountForRequest(threshold.amount)
}
}
}
function validateBillingAmount(
label: string,
raw: string,
bounds: Pick<BillingStateResponse, 'max_usd' | 'min_usd'>
): { amount?: number; error?: string } {
const cleaned = raw.trim().replace(/^\$/, '').trim()
if (!cleaned || !/^\d+(\.\d{1,2})?$/.test(cleaned)) {
return { error: `${label}: enter a dollar amount with at most 2 decimal places.` }
}
const amount = Number(cleaned)
if (!(amount > 0)) {
return { error: `${label}: amount must be greater than $0.` }
}
const min = parseAmount(bounds.min_usd)
if (min != null && amount < min) {
return { error: `${label}: minimum is ${formatMoney(min)}.` }
}
const max = parseAmount(bounds.max_usd)
if (max != null && amount > max) {
return { error: `${label}: maximum is ${formatMoney(max)}.` }
}
return { amount }
}
function formatMoney(value?: null | number | string): string {
const amount = parseAmount(value)
if (amount == null) {
return EMPTY_BILLING_VALUE
}
return new Intl.NumberFormat(undefined, {
currency: 'USD',
maximumFractionDigits: amount % 1 === 0 ? 0 : 2,
minimumFractionDigits: amount % 1 === 0 ? 0 : 2,
style: 'currency'
}).format(amount)
}

View file

@ -0,0 +1,70 @@
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ExternalLink } from '@/lib/icons'
import type { BillingRefusal } from './api'
import { resolveRefusal } from './errors'
import { useStepUpFlow } from './use-step-up'
export function StepUpInlineAction({ flow }: { flow: ReturnType<typeof useStepUpFlow> }) {
if (flow.verification) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span className="font-mono text-[0.72rem] font-semibold text-foreground">{flow.verification.code}</span>
<Button onClick={flow.openVerification} size="sm" type="button" variant="outline">
Open verification page
<ExternalLink className="size-3.5" />
</Button>
</span>
)
}
if (flow.message) {
return (
<span className="inline-flex min-w-0 flex-wrap items-center gap-2">
<span>
{flow.message.title}: {flow.message.text}
</span>
<Button onClick={flow.dismiss} size="sm" type="button" variant="outline">
Dismiss
</Button>
</span>
)
}
if (flow.phase === 'waiting') {
return <span>Waiting for verification link</span>
}
return (
<Button onClick={() => void flow.start()} size="sm" type="button" variant="outline">
Verify to continue
</Button>
)
}
export function BillingRefusalInline({ refusal }: { refusal: BillingRefusal | null }) {
const stepUp = useStepUpFlow()
if (!refusal) {
return null
}
const resolved = resolveRefusal(refusal)
const portalUrl = resolved.action.type === 'portal' ? resolved.action.url : undefined
return (
<div className="mt-2 flex min-w-0 flex-wrap items-center gap-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>
<span className="font-medium text-foreground">{resolved.title}:</span> {resolved.message}
</span>
{resolved.action.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => openExternalLink(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}

View file

@ -0,0 +1,9 @@
import { openExternalLink } from '@/lib/external-link'
// Optional-arg convenience over the canonical opener — the billing rows pass
// possibly-undefined URLs straight through from their view models.
export function openExternal(url?: string) {
if (url) {
openExternalLink(url)
}
}

View file

@ -0,0 +1,228 @@
import { useEffect, useRef } from 'react'
import { Button } from '@/components/ui/button'
import { openExternalLink } from '@/lib/external-link'
import { ChevronLeft, ExternalLink } from '@/lib/icons'
import { cn } from '@/lib/utils'
import { Pill } from '../primitives'
import { BillingRefusalInline } from './inline-feedback'
import { TierArt } from './tier-art'
import { type BillingPlanTierView, formatBillingDate, formatMonthlyCreditsDelta } from './use-billing-state'
import { type DowngradePhase, useDowngradeFlow } from './use-subscription-change'
type DowngradeFlow = ReturnType<typeof useDowngradeFlow>
// The human sentence for the panel body, derived purely from the phase. `null` while
// a refusal is the only thing to show (BillingRefusalInline renders that separately).
function previewMessage(phase: DowngradePhase, fallbackTierName: string): null | string {
if (phase.kind === 'previewing') {
return 'Checking this change…'
}
if (phase.kind === 'previewFailed') {
return null
}
const { preview } = phase
const targetName = preview.target_tier_name ?? fallbackTierName
const creditsDelta = formatMonthlyCreditsDelta(preview.monthly_credits_delta)
switch (preview.effect) {
case 'blocked':
return preview.reason ?? 'That change cannot be made here.'
case 'no_op':
return `You are already on ${targetName} — nothing to change.`
case 'scheduled':
return (
`Change to ${targetName} — takes effect ${formatBillingDate(preview.effective_at)}. No charge now; ` +
`you keep your current plan until then.${creditsDelta ? ` Monthly credits change: ${creditsDelta}.` : ''}`
)
default:
return 'This change cannot be scheduled here.'
}
}
// The in-card preview → confirm panel for a downgrade (mirrors the TUI confirm flow).
function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const active = flow.active
const panelRef = useRef<HTMLDivElement>(null)
const open = active?.target.tierId === tier.tierId
// Move focus into the panel on open so keyboard users land on the confirm flow;
// role="status"/aria-live announces the async preview text as it arrives.
useEffect(() => {
if (open) {
panelRef.current?.focus()
}
}, [open])
if (!active || active.target.tierId !== tier.tierId) {
return null
}
const { phase } = active
const captionCn = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)'
const refusal = phase.kind === 'previewFailed' || phase.kind === 'scheduleFailed' ? phase.refusal : null
const busy = phase.kind === 'previewing' || phase.kind === 'scheduling'
const message = previewMessage(phase, tier.name)
const canConfirm =
(phase.kind === 'ready' && phase.preview.effect === 'scheduled') ||
phase.kind === 'scheduling' ||
phase.kind === 'scheduleFailed'
return (
<div
aria-live="polite"
className="flex min-w-0 flex-col gap-2 rounded-md border border-border/70 bg-background/60 p-3 outline-none"
ref={panelRef}
role="status"
tabIndex={-1}
>
{message && <div className={captionCn}>{message}</div>}
<BillingRefusalInline refusal={refusal} />
<div className="flex min-w-0 flex-wrap items-center gap-2">
{phase.kind === 'previewFailed' ? (
<Button disabled={busy} onClick={flow.retryPreview} size="sm" type="button">
Try again
</Button>
) : canConfirm ? (
<Button disabled={busy} onClick={() => void flow.confirm()} size="sm" type="button">
{phase.kind === 'scheduling'
? 'Scheduling…'
: phase.kind === 'scheduleFailed'
? 'Try again'
: 'Confirm downgrade'}
</Button>
) : null}
<Button disabled={busy} onClick={flow.cancel} size="sm" type="button" variant="outline">
Cancel
</Button>
</div>
</div>
)
}
function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierView }) {
const isCurrent = tier.state === 'current'
const confirming = flow.active?.target.tierId === tier.tierId
const cardRef = useRef<HTMLDivElement>(null)
const wasConfirming = useRef(false)
// When the confirm panel closes (cancel / scheduled), return focus to this tile
// so keyboard focus is never left detached on the removed panel.
useEffect(() => {
if (wasConfirming.current && !confirming) {
cardRef.current?.focus()
}
wasConfirming.current = confirming
}, [confirming])
return (
<div
className={cn(
'flex min-w-0 flex-col gap-3 rounded-lg border p-4 outline-none',
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
)}
ref={cardRef}
tabIndex={-1}
>
<div className="flex min-w-0 items-center gap-3">
<TierArt name={tier.name} />
<div className="min-w-0">
<div className="truncate text-[length:var(--conversation-text-font-size)] font-medium text-foreground">
{tier.name}
</div>
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.priceDisplay}/mo
</div>
</div>
</div>
{tier.creditsDisplay && (
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{tier.creditsDisplay}
</div>
)}
<div className="mt-auto min-w-0 pt-1">
{isCurrent && <Pill tone="primary">Current plan</Pill>}
{tier.state === 'scheduled' && <Pill>Scheduled</Pill>}
{tier.state === 'upgrade' && (
<Button
onClick={() => tier.action && openExternalLink(tier.action.url)}
size="sm"
type="button"
variant="outline"
>
{tier.action.label}
<ExternalLink className="size-3.5" />
</Button>
)}
{tier.state === 'downgrade' &&
(confirming ? (
<DowngradeConfirm flow={flow} tier={tier} />
) : (
// Disabled while another tile's change is committing — no concurrent mutation.
<Button
disabled={flow.mutating}
onClick={() => flow.begin({ tierId: tier.tierId, tierName: tier.name })}
size="sm"
type="button"
variant="outline"
>
Downgrade
</Button>
))}
</div>
</div>
)
}
export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers: BillingPlanTierView[] }) {
// A scheduled downgrade lands the user back on the overview, where the plan card
// now shows the pending state with its undo.
const flow = useDowngradeFlow({ onScheduled: onBack })
return (
<div className="@container">
<div className="mb-2.5 flex items-center gap-2 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<Button
aria-label="Back to billing"
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={flow.mutating}
onClick={onBack}
size="sm"
type="button"
variant="ghost"
>
<ChevronLeft className="size-4" />
</Button>
<span>Plans</span>
</div>
{tiers.length > 0 ? (
<div className="grid gap-3 @lg:grid-cols-2 @3xl:grid-cols-3">
{tiers.map(tier => (
<PlanCard flow={flow} key={tier.tierId} tier={tier} />
))}
</div>
) : (
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
No plans are available to change to right now.
</div>
)}
</div>
)
}

View file

@ -0,0 +1,57 @@
import { describe, expect, it } from 'vitest'
import { billingDevFixtures } from './dev-fixtures'
import { createSimulatedBillingApi } from './simulated-api'
import { deriveBillingView } from './use-billing-state'
const FREE_TIER_ID = 'cltier000free0000personal'
describe('createSimulatedBillingApi', () => {
it('progresses the pending state through the whole loop: schedule sets it, resume clears it', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
const billing = await api.fetchBillingState()
// Baseline: subscriber on Plus, nothing pending.
const before = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(before.plan?.pending).toBeUndefined()
// Schedule a downgrade to Free → the very next fetch shows the pending card + marker.
expect((await api.scheduleSubscriptionChange(FREE_TIER_ID)).ok).toBe(true)
const afterSchedule = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(afterSchedule.plan?.pending).toMatchObject({ kind: 'downgrade', tierName: 'Free' })
expect(afterSchedule.tiers.find(tier => tier.name === 'Free')?.state).toBe('scheduled')
// Undo → pending cleared on the next fetch.
expect((await api.resumeSubscription()).ok).toBe(true)
const afterResume = deriveBillingView(billing, await api.fetchSubscriptionState())
expect(afterResume.plan?.pending).toBeUndefined()
expect(afterResume.tiers.some(tier => tier.state === 'scheduled')).toBe(false)
})
it('previews a chargeless scheduled change for the chosen tier', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
const preview = await api.previewSubscriptionChange(FREE_TIER_ID)
expect(preview).toMatchObject({ data: { effect: 'scheduled', target_tier_name: 'Free' }, ok: true })
})
it('undoes a scheduled cancellation too', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['pending-cancellation'])
const billing = await api.fetchBillingState()
expect(deriveBillingView(billing, await api.fetchSubscriptionState()).plan?.pending).toMatchObject({
kind: 'cancellation'
})
await api.resumeSubscription()
expect(deriveBillingView(billing, await api.fetchSubscriptionState()).plan?.pending).toBeUndefined()
})
it('does not mutate the shared fixture object', async () => {
const api = createSimulatedBillingApi(billingDevFixtures['subscriber-personal'])
await api.scheduleSubscriptionChange(FREE_TIER_ID)
const fixture = billingDevFixtures['subscriber-personal']
expect(deriveBillingView(fixture.billing, fixture.subscription).plan?.pending).toBeUndefined()
})
})

View file

@ -0,0 +1,89 @@
import type { BillingApi, BillingResult } from './api'
import type { BillingStateResponse, SubscriptionPreviewResponse, SubscriptionStateResponse } from './types'
/** The shape of one `billingDevFixtures` entry — a canned billing + subscription pair. */
export interface SimulatedFixture {
billing: BillingResult<BillingStateResponse>
subscription: BillingResult<SubscriptionStateResponse>
}
// A visible-but-brief pause so the live fixture loop actually sees the "Checking…" /
// "Scheduling…" / "Undoing…" transitions rather than an instant flip.
const SIMULATED_DELAY_MS = 300
const delay = (ms: number) => new Promise<void>(resolve => setTimeout(resolve, ms))
const ok = <T>(data: T): BillingResult<T> => ({ data, ok: true })
/**
* A fully in-memory BillingApi for DEV fixtures no gateway. Fetches serve a mutable
* copy of the fixture, and the subscription-change mutations WRITE that copy's pending
* state, so the fixture click-through genuinely progresses: schedule sets a pending
* downgrade ( the plan card's "Changes to …" + Undo, and the grid's Scheduled marker
* on refetch), and resume clears any pending downgrade OR cancellation. Consumers reach
* it transparently via `useBillingApi` (overridden by BillingApiProvider), so no code
* outside this file is fixture-aware.
*/
export function createSimulatedBillingApi(fixture: SimulatedFixture): BillingApi {
const billing = fixture.billing
// Mutable copy so scheduling/undo don't leak back into the shared fixture object.
let subscription: BillingResult<SubscriptionStateResponse> = structuredClone(fixture.subscription)
const patchCurrent = (patch: Partial<NonNullable<SubscriptionStateResponse['current']>>) => {
if (subscription.ok && subscription.data.current) {
subscription = ok({ ...subscription.data, current: { ...subscription.data.current, ...patch } })
}
}
const tierName = (tierId: string): null | string =>
(subscription.ok ? subscription.data.tiers.find(tier => tier.tier_id === tierId)?.name : null) ?? null
return {
charge: async (_amountUsd, idempotencyKey = 'sim-key') => ({
data: { charge_id: 'sim-charge', ok: true },
idempotencyKey,
ok: true
}),
chargeStatus: async () => ok({ amount_usd: '0', ok: true, settled_at: null, status: 'settled' }),
fetchBillingState: async () => billing,
fetchSubscriptionState: async () => subscription,
previewSubscriptionChange: async tierId => {
await delay(SIMULATED_DELAY_MS)
const preview: SubscriptionPreviewResponse = {
effect: 'scheduled',
effective_at: subscription.ok ? (subscription.data.current?.cycle_ends_at ?? null) : null,
ok: true,
target_tier_name: tierName(tierId)
}
return ok(preview)
},
resumeSubscription: async () => {
await delay(SIMULATED_DELAY_MS)
// Undo either scheduled change kind.
patchCurrent({
cancel_at_period_end: false,
cancellation_effective_at: null,
cancellation_effective_display: null,
pending_downgrade_at: null,
pending_downgrade_display: null,
pending_downgrade_tier_name: null
})
return ok({ message: 'Change cancelled.', ok: true })
},
scheduleSubscriptionChange: async tierId => {
await delay(SIMULATED_DELAY_MS)
patchCurrent({
pending_downgrade_at: subscription.ok ? (subscription.data.current?.cycle_ends_at ?? null) : null,
pending_downgrade_display: null,
pending_downgrade_tier_name: tierName(tierId)
})
return ok({ message: 'Downgrade scheduled.', ok: true })
},
stepUp: async () => ok({ granted: true, ok: true }),
updateAutoReload: async () => ok({ ok: true })
}
}

View file

@ -0,0 +1,25 @@
import { describe, expect, it } from 'vitest'
import { resolveTierArt } from './tier-art'
describe('resolveTierArt', () => {
it('keys art by lowercase tier name, case-insensitively', () => {
for (const name of ['Free', 'starter', 'Plus', 'SUPER', 'ultra']) {
expect(resolveTierArt(name)).not.toBeNull()
}
})
it('maps each named tier to its NAS blend mode', () => {
expect(resolveTierArt('free')?.blend).toBe('screen')
expect(resolveTierArt('plus')?.blend).toBe('screen')
expect(resolveTierArt('super')?.blend).toBe('lighten')
expect(resolveTierArt('ultra')?.blend).toBe('normal')
})
it('returns null for unknown or missing names so the card renders text-only', () => {
expect(resolveTierArt('Mystery')).toBeNull()
expect(resolveTierArt('')).toBeNull()
expect(resolveTierArt(null)).toBeNull()
expect(resolveTierArt(undefined)).toBeNull()
})
})

View file

@ -0,0 +1,69 @@
import automationArt from '@/assets/tiers/feature-automation.webp'
import connectArt from '@/assets/tiers/feature-connect.webp'
import memoryArt from '@/assets/tiers/feature-memory.webp'
import sandboxArt from '@/assets/tiers/feature-sandbox.webp'
import { cn } from '@/lib/utils'
// Reproduces the portal's tier-card hero treatment at thumbnail size: each webp sits
// over a solid Nous-blue well and blends into it. This blue well is the ONLY place
// Nous blue appears in the billing page — everything else stays on the app's own tokens.
const NOUS_BLUE = '#0000f2'
const BLEND_CLASS = {
lighten: 'mix-blend-lighten',
normal: '',
screen: 'mix-blend-screen'
} as const
type TierBlend = keyof typeof BLEND_CLASS
interface TierArtSpec {
blend: TierBlend
src: string
}
// Keyed by lowercase tier NAME, not tier_id: real tier_ids are Prisma cuids that
// differ per environment, while names are stable. `free`/`starter` share the
// entry-tier art. An unknown name resolves to null → the card renders text-only.
const TIER_ART: Record<string, TierArtSpec> = {
free: { blend: 'screen', src: connectArt },
plus: { blend: 'screen', src: memoryArt },
starter: { blend: 'screen', src: connectArt },
super: { blend: 'lighten', src: automationArt },
ultra: { blend: 'normal', src: sandboxArt }
}
export function resolveTierArt(tierName?: null | string): null | TierArtSpec {
if (!tierName) {
return null
}
return TIER_ART[tierName.trim().toLowerCase()] ?? null
}
/**
* Small rounded thumbnail (~40px) rendering the tier art over a Nous-blue well.
* Returns null for unknown tiers so the caller lays out a text-only card without
* reserving empty art space. Imported via vite static imports so the URLs resolve
* under a packaged `file://` origin with webSecurity on.
*/
export function TierArt({ className, name, size = 40 }: { className?: string; name?: null | string; size?: number }) {
const art = resolveTierArt(name)
if (!art) {
return null
}
return (
<div
className={cn('relative shrink-0 overflow-hidden rounded-md', className)}
style={{ background: NOUS_BLUE, height: size, width: size }}
>
<img
alt=""
className={cn('pointer-events-none absolute inset-0 size-full max-w-none object-cover', BLEND_CLASS[art.blend])}
src={art.src}
/>
</div>
)
}

View file

@ -9,6 +9,7 @@ import type {
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
@ -26,6 +27,7 @@ export type {
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionPreviewResponse,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,

View file

@ -13,7 +13,7 @@ import {
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { buildManageSubscriptionUrl, deriveBillingView } from './use-billing-state'
import { buildManageSubscriptionUrl, deriveBillingView, formatMonthlyCreditsDelta } from './use-billing-state'
function usageRowFor(
fixtureName: keyof typeof billingDevFixtures,
@ -62,15 +62,14 @@ describe('deriveBillingView', () => {
expect(view.status).toBe('normal')
expect(view.summary).toContainEqual({ label: 'Balance', value: '$996.47' })
expect(view.summary).toContainEqual({ label: 'Plan', value: 'Ultra · $200/mo' })
const buyCredits = view.accountRows.find(row => row.id === 'buy_credits')
expect(buyCredits?.description).toBe(
expect(view.topupRow?.description).toBe(
"Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page."
)
expect(buyCredits?.chips).toBeUndefined()
expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({
expect(view.topupRow?.chips).toBeUndefined()
expect(view.refillRow).toMatchObject({
action: { label: 'Manage' },
caption: 'Refill $10 when balance falls below $5',
description: 'Charges $10 automatically when your balance falls below $5.',
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' }
})
expect(view.usageRows.map(row => row.id)).toEqual(['subscription_credits', 'topup_credits', 'monthly_cap'])
@ -80,15 +79,9 @@ describe('deriveBillingView', () => {
const view = deriveBillingView(okBilling(postTrainBillingState), okSubscription(postTrainSubscriptionState))
expect(view.status).toBe('normal')
expect(view.accountRows.find(row => row.id === 'payment_method')?.value).toBe('Visa •••• 4242 - subscription card')
expect(view.accountRows.find(row => row.id === 'buy_credits')?.chips?.map(chip => chip.label)).toEqual([
'$25',
'$50',
'$100'
])
expect(view.accountRows.find(row => row.id === 'subscription')?.action?.url).toBe(
'https://portal.nousresearch.com/manage-subscription?org_id=org_123'
)
expect(view.paymentRow?.value).toBe('Visa •••• 4242 - subscription card')
expect(view.topupRow?.chips?.map(chip => chip.label)).toEqual(['$25', '$50', '$100'])
expect(view.plan?.link?.url).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
expect(view.usageRows.find(row => row.id === 'subscription_credits')).toMatchObject({
bar: { value: 0.4 },
value: '$40 of $100 left'
@ -107,11 +100,9 @@ describe('deriveBillingView', () => {
okSubscription(todaySubscriptionState)
)
const autoReload = view.accountRows.find(row => row.id === 'auto_reload')
expect(autoReload?.caption).toContain('Mastercard ••4444')
expect(autoReload?.caption).toContain('reconcile')
expect(autoReload?.action).toEqual({
expect(view.refillRow?.caption).toContain('Mastercard ••4444')
expect(view.refillRow?.caption).toContain('reconcile')
expect(view.refillRow?.action).toEqual({
label: 'Reconcile ↗',
url: 'https://portal.nousresearch.com/billing'
})
@ -129,17 +120,30 @@ describe('deriveBillingView', () => {
okSubscription(todaySubscriptionState)
)
const autoReload = view.accountRows.find(row => row.id === 'auto_reload')
expect(view.refillRow?.caption).toContain('a different card')
expect(view.refillRow?.caption).not.toContain('null')
expect(view.refillRow?.action?.url).toBe('https://portal.nousresearch.com/billing')
})
expect(autoReload?.caption).toContain('a different card')
expect(autoReload?.caption).not.toContain('null')
expect(autoReload?.action?.url).toBe('https://portal.nousresearch.com/billing')
it('renders the normal enabled auto-refill row when the card is null (no crash)', () => {
// The gateway emits auto_reload.card: null for a missing/unknown-kind card.
const view = deriveBillingView(
okBilling({ ...todayBillingState, auto_reload: { ...todayBillingState.auto_reload, card: null } }),
okSubscription(todaySubscriptionState)
)
expect(view.refillRow).toMatchObject({
action: { label: 'Manage' },
description: 'Charges $10 automatically when your balance falls below $5.',
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' }
})
})
it('keeps buy credit controls visible but disabled when no card is on file', () => {
const fixture = billingDevFixtures['no-card']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const buyCredits = view.accountRows.find(row => row.id === 'buy_credits')
const buyCredits = view.topupRow
expect(buyCredits).toMatchObject({
action: { disabled: true, label: 'Buy' },
@ -158,7 +162,9 @@ describe('deriveBillingView', () => {
expect(view.notice).toMatchObject({
title: 'Connect your Nous account'
})
expect(view.accountRows).toEqual([])
expect(view.paymentRow).toBeUndefined()
expect(view.topupRow).toBeUndefined()
expect(view.refillRow).toBeUndefined()
expect(view.usageRows).toEqual([])
})
@ -170,115 +176,25 @@ describe('deriveBillingView', () => {
expect(view.notice).toMatchObject({
title: 'Billing endpoint unavailable'
})
expect(view.accountRows).toEqual([])
expect(view.paymentRow).toBeUndefined()
expect(view.topupRow).toBeUndefined()
expect(view.refillRow).toBeUndefined()
})
it('keeps subscription unavailable as a row-level degradation when billing.state succeeds', () => {
it('keeps subscription unavailable as a plan-card degradation with a live portal link', () => {
const view = deriveBillingView(okBilling(todayBillingState), endpointUnavailableSubscription)
const subscription = view.accountRows.find(row => row.id === 'subscription')
expect(view.status).toBe('normal')
expect(subscription).toMatchObject({
expect(view.plan).toMatchObject({
caption: 'Subscription details are unavailable; opening the portal is still available.',
value: 'Ultra'
tierName: 'Ultra'
})
expect(view.plan?.action).toBeUndefined()
// The caption promises the portal is still reachable — so the link must exist.
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription'
})
})
it('free with catalog: tier chips render inline and open the portal', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0',
name: 'Free',
tier_id: 'free',
tier_order: 0
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '3000',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '1000',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
}
]
})
)
const subscription = view.accountRows.find(row => row.id === 'subscription')
expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.')
expect(subscription?.chips).toEqual([
{ disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: `${subscription?.action?.url}&plan=plus` },
{ disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` }
])
})
it('subscriber who can change plans: current tier marked inert, others open the portal', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
tiers: [
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '1000',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '3000',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
}
]
})
)
const subscription = view.accountRows.find(row => row.id === 'subscription')
expect(subscription?.chips).toEqual([
{ disabled: true, label: '✓ Plus · $20/mo · $1,000 credits/mo' },
{ disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` }
])
})
it('members and team contexts get no tier chips', () => {
const member = deriveBillingView(
okBilling(todayBillingState),
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
const team = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(member.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined()
expect(team.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined()
})
it('clamps overdrawn subscription credits to $0 and names the overage', () => {
@ -380,6 +296,417 @@ describe('deriveBillingView', () => {
})
})
describe('derivePlanCard (current-plan card)', () => {
it('offers an in-app "View plans" button for a free personal account that can change plans', () => {
const fixture = billingDevFixtures['free-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({ action: { label: 'View plans' }, tierName: 'Free' })
expect(view.plan?.link).toBeUndefined()
})
it('offers an in-app "Change plan" button for a personal subscriber', () => {
const fixture = billingDevFixtures['subscriber-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({ action: { label: 'Change plan' }, price: '$20', tierName: 'Plus' })
expect(view.plan?.link).toBeUndefined()
})
it('gives teams a portal escape hatch and no in-app button', () => {
// todaySubscriptionState is context: 'team'.
const view = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState))
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
it('gives non-changing members a portal link but no in-app button', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' })
)
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link).toMatchObject({
label: 'Adjust plan ↗',
url: 'https://portal.nousresearch.com/manage-subscription?org_id=sid-5'
})
})
it('withholds the in-app button (no dead click) and offers the portal link when nothing is actionable', () => {
// A subscriber whose only enabled tier is the one they are already on: the grid
// would show a single inert card, so the "Change plan" button must not appear.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'solo', tier_name: 'Solo' },
tiers: [
{
dollars_per_month_display: '$10',
is_current: true,
is_enabled: true,
monthly_credits: '10',
name: 'Solo',
tier_id: 'solo',
tier_order: 0
}
]
})
)
expect(view.tiers.map(tier => tier.state)).toEqual(['current'])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
it('gives a top-tier subscriber a portal link, not a dead in-app button', () => {
// On the highest tier, every enabled tile below is a downgrade — but downgrades are
// themselves actionable in-app at ticket 11, so this stays a "Change plan" account.
// (The dead-grid case is a subscriber whose only tile is `current`; covered above.)
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'top', tier_name: 'Ultra' },
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 't_free',
tier_order: 0
},
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'top',
tier_order: 1
}
]
})
)
expect(view.tiers.some(tier => tier.state === 'upgrade')).toBe(false)
// Free below is an in-app downgrade → still actionable → in-app button.
expect(view.plan?.action).toMatchObject({ label: 'Change plan' })
})
it('surfaces a scheduled downgrade as the plan-card pending state (drives the undo)', () => {
const fixture = billingDevFixtures['pending-downgrade']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({
action: { label: 'Change plan' },
caption: 'Changes to Free on Aug 15.',
pending: { kind: 'downgrade', tierName: 'Free', when: 'Aug 15' },
tierName: 'Plus'
})
})
it('surfaces a scheduled cancellation as "Cancels on …" with the same undo and no grid marker', () => {
const fixture = billingDevFixtures['pending-cancellation']
const view = deriveBillingView(fixture.billing, fixture.subscription)
expect(view.plan).toMatchObject({
action: { label: 'Change plan' },
caption: 'Cancels on Aug 15.',
pending: { kind: 'cancellation', when: 'Aug 15' },
tierName: 'Plus'
})
// A cancellation has no target tier → nothing to mark in the grid.
expect(view.tiers.some(tier => tier.state === 'scheduled')).toBe(false)
})
it('lets a pending downgrade win over a cancellation when both are set', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: {
...todaySubscriptionState.current,
cancel_at_period_end: true,
cancellation_effective_at: '2026-09-01T00:00:00Z',
cancellation_effective_display: 'Sep 1',
pending_downgrade_at: '2026-08-15T00:00:00Z',
pending_downgrade_display: 'Aug 15',
pending_downgrade_tier_name: 'Free',
tier_id: 'plus',
tier_name: 'Plus'
},
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'free',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
}
]
})
)
// Downgrade wins (names a concrete target); card + grid agree on it.
expect(view.plan?.pending).toMatchObject({ kind: 'downgrade', tierName: 'Free' })
expect(view.plan?.caption).toBe('Changes to Free on Aug 15.')
expect(view.tiers.find(tier => tier.name === 'Free')?.state).toBe('scheduled')
})
it('offers only the portal link when the tier catalog is empty', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
tiers: []
})
)
expect(view.tiers).toEqual([])
expect(view.plan?.action).toBeUndefined()
expect(view.plan?.link?.label).toBe('Adjust plan ↗')
})
})
describe('derivePlanTiers (plans grid)', () => {
it('marks the current tier, upgrades, and in-app downgrades for a subscriber', () => {
const fixture = billingDevFixtures['subscriber-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Plus', 'Super', 'Ultra'])
expect(byName.Free.state).toBe('downgrade')
// Downgrades act in-app (no portal URL / caption) — the PlanCard wires the confirm flow.
expect('action' in byName.Free).toBe(false)
expect(byName.Plus.state).toBe('current')
expect('action' in byName.Plus).toBe(false)
expect(byName.Super).toMatchObject({
action: {
url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_plus&plan=cltier222super222personal'
},
creditsDisplay: '$110 credits/mo',
state: 'upgrade'
})
expect(byName.Ultra.state).toBe('upgrade')
})
it('marks the pending downgrade target "scheduled" (inert) while other tiers stay actionable', () => {
const fixture = billingDevFixtures['pending-downgrade']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
// Free is the scheduled target → inert marker, not another "Downgrade".
expect(byName.Free.state).toBe('scheduled')
expect('action' in byName.Free).toBe(false)
expect(byName.Plus.state).toBe('current')
// Reschedule stays possible on the other lower/higher tiers.
expect(byName.Super.state).toBe('upgrade')
expect(byName.Ultra.state).toBe('upgrade')
})
it('marks the free/lowest tier current (inert) and every paid tier an upgrade when there is no subscription', () => {
const fixture = billingDevFixtures['free-personal']
const view = deriveBillingView(fixture.billing, fixture.subscription)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
// No "subscribe to Free" — the $0 tier is the current plan, not a choice.
expect(view.tiers.map(tier => tier.state)).toEqual(['current', 'upgrade', 'upgrade', 'upgrade'])
expect('action' in byName.Free).toBe(false)
// No downgrade state can exist without a subscription.
expect(view.tiers.some(tier => tier.state === 'downgrade')).toBe(false)
expect(byName.Plus).toMatchObject({
action: {
url: 'https://portal.nousresearch.com/manage-subscription?org_id=org_personal_free&plan=cltier111plus1111personal'
}
})
})
it('still lists a tier whose name has no art mapping (text-only card, no layout break)', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'cltier_free_0000',
tier_order: 0
},
{
dollars_per_month_display: '$5',
is_current: false,
is_enabled: true,
monthly_credits: '5',
name: 'Mystery',
tier_id: 'cltier_mystery_0000',
tier_order: 5
}
]
})
)
// The unknown-named paid tier still lists (art resolves to null → text-only).
expect(view.tiers.map(tier => tier.name)).toEqual(['Free', 'Mystery'])
expect(view.tiers.find(tier => tier.name === 'Mystery')?.state).toBe('upgrade')
})
it('keeps a grandfathered (is_enabled:false) CURRENT tier inert and orders downgrades against it', () => {
// NAS marks a grandfathered current tier is_enabled:false. It must still appear
// (inert "Current plan") and define the order boundary — lower enabled tiers are
// downgrades, higher ones are Choose.
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: { ...todaySubscriptionState.current, tier_id: 'legacy_mid', tier_name: 'Legacy' },
tiers: [
{
dollars_per_month_display: '$5',
is_current: false,
is_enabled: true,
monthly_credits: '5',
name: 'Basic',
tier_id: 'basic',
tier_order: 0
},
{
dollars_per_month_display: '$15',
is_current: true,
is_enabled: false,
monthly_credits: '15',
name: 'Legacy',
tier_id: 'legacy_mid',
tier_order: 1
},
{
dollars_per_month_display: '$40',
is_current: false,
is_enabled: true,
monthly_credits: '40',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 2
}
]
})
)
const byName = Object.fromEntries(view.tiers.map(tier => [tier.name, tier]))
expect(view.tiers.map(tier => tier.name)).toEqual(['Basic', 'Legacy', 'Ultra'])
expect(byName.Legacy.state).toBe('current')
expect('action' in byName.Legacy).toBe(false)
expect(byName.Basic.state).toBe('downgrade')
expect('action' in byName.Basic).toBe(false)
expect(byName.Ultra).toMatchObject({ action: { label: 'Choose ↗' }, state: 'upgrade' })
})
it('backs Choose URLs with billing.portal_url (org_id + plan intact) when the subscription has no portal_url', () => {
const view = deriveBillingView(
okBilling({ ...todayBillingState, portal_url: 'https://billing.example.com/x' }),
okSubscription({
...todaySubscriptionState,
can_change_plan: true,
context: 'personal',
current: null,
portal_url: null,
tiers: [
{
dollars_per_month_display: '$0',
is_current: false,
is_enabled: true,
monthly_credits: '0.1',
name: 'Free',
tier_id: 'free0',
tier_order: 0
},
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus1',
tier_order: 1
}
]
})
)
expect(view.tiers.find(tier => tier.name === 'Plus')).toMatchObject({
action: { url: 'https://billing.example.com/manage-subscription?org_id=sid-5&plan=plus1' }
})
})
it('drops grandfathered (is_enabled: false) tiers from the grid', () => {
const view = deriveBillingView(
okBilling(todayBillingState),
okSubscription({
...todaySubscriptionState,
context: 'personal',
current: null,
tiers: [
{
dollars_per_month_display: '$20',
is_current: false,
is_enabled: true,
monthly_credits: '22',
name: 'Plus',
tier_id: 'plus',
tier_order: 1
},
{
dollars_per_month_display: '$9',
is_current: false,
is_enabled: false,
monthly_credits: '9',
name: 'Legacy',
tier_id: 'legacy',
tier_order: 1
}
]
})
)
expect(view.tiers.map(tier => tier.name)).toEqual(['Plus'])
})
})
describe('buildManageSubscriptionUrl', () => {
it('mirrors the TUI manage-subscription URL construction', () => {
expect(
@ -390,26 +717,46 @@ describe('buildManageSubscriptionUrl', () => {
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('appends the tier as a plan query param when provided', () => {
it('appends plan=<tierId> when a tier is chosen', () => {
expect(
buildManageSubscriptionUrl(
{
org_id: 'org_123',
portal_url: 'https://portal.nousresearch.com/billing'
},
undefined,
'ultra'
{ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' },
null,
'tier_abc'
)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=ultra')
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=tier_abc')
})
it('omits the plan param when no tierId is given', () => {
it('omits the plan param when no tier is given', () => {
expect(
buildManageSubscriptionUrl(
{ org_id: null, portal_url: 'https://portal.nousresearch.com/billing' },
undefined,
undefined
)
).toBe('https://portal.nousresearch.com/manage-subscription')
buildManageSubscriptionUrl({ org_id: 'org_123', portal_url: 'https://portal.nousresearch.com/billing' }, null)
).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123')
})
it('applies org_id + plan to the hard-coded portal fallback when no portal_url resolves', () => {
// Regression: the fallback must be the last-resort ORIGIN, not a bare return that
// silently drops org_id/plan.
expect(buildManageSubscriptionUrl({ org_id: 'org_z', portal_url: null }, null, 'tier_q')).toBe(
'https://portal.nousresearch.com/manage-subscription?org_id=org_z&plan=tier_q'
)
})
})
describe('formatMonthlyCreditsDelta', () => {
it('renders a bare negative decimal as signed dollars, never a raw number', () => {
// Credits are DOLLARS — "-88" must not render bare.
expect(formatMonthlyCreditsDelta('-88')).toBe('$88/mo')
})
it('renders a positive delta with a plus sign and dollar formatting', () => {
expect(formatMonthlyCreditsDelta('40')).toBe('+$40/mo')
expect(formatMonthlyCreditsDelta('-12.50')).toBe('$12.50/mo')
})
it('hides the line (null) for a zero or absent delta', () => {
expect(formatMonthlyCreditsDelta('0')).toBeNull()
expect(formatMonthlyCreditsDelta(null)).toBeNull()
expect(formatMonthlyCreditsDelta(undefined)).toBeNull()
expect(formatMonthlyCreditsDelta('')).toBeNull()
})
})

View file

@ -5,7 +5,7 @@ import { fmtDate } from '@/lib/time'
import type { BillingRefusal, BillingResult } from './api'
import { useBillingApi } from './api'
import { resolveRefusal } from './errors'
import type { BillingStateResponse, SubscriptionStateResponse, UsageModelData } from './types'
import type { BillingStateResponse, SubscriptionStateResponse, SubscriptionTierOption, UsageModelData } from './types'
export const EMPTY_BILLING_VALUE = '—'
export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing'
@ -50,7 +50,9 @@ export interface BillingAccountRowView {
caption?: string
chips?: BillingChipView[]
description: string
id: 'auto_reload' | 'buy_credits' | 'payment_method' | 'subscription'
id: 'auto_reload' | 'buy_credits' | 'payment_method'
/** The auto-refill row that edits its amounts in place (canonical-card enabled). */
manageInApp?: true
pill?: {
label: string
tone: 'muted' | 'primary'
@ -60,6 +62,48 @@ export interface BillingAccountRowView {
value?: string
}
/**
* A change scheduled at period end that `subscription.resume` can undo. A downgrade
* names its target tier (and marks it in the grid); a cancellation has no target
* (the whole plan lapses), so the grid shows no marker for it.
*/
export type PendingPlanTransition =
| { kind: 'cancellation'; when: string }
| { kind: 'downgrade'; tierName: string; when: string }
/**
* The current-plan summary that replaces the old subscription row. Carries EITHER
* one in-app `action` (View plans / Change plan) OR a portal `link` ("Adjust plan
* "), never both a discriminated pair so consumers don't guard for the impossible
* "both present" / "neither present" cases.
*/
export type BillingPlanCardView = {
caption: string
/** A scheduled downgrade / cancellation waiting at period end (drives the undo). */
pending?: PendingPlanTransition
price?: string
tierName: string
} & ({ action: { label: string }; link?: undefined } | { action?: undefined; link: { label: string; url: string } })
interface BillingPlanTierBase {
creditsDisplay?: string
name: string
priceDisplay: string
tierId: string
}
/**
* One card in the `bview=plans` grid, discriminated by `state`: `upgrade` carries its
* portal `action`; `downgrade` is actionable IN-APP (the flow keys off `tierId`, so it
* needs no url/caption); `scheduled` is the inert pending-downgrade target; `current`
* is inert. The union lets consumers read `action` without defensive `?.`.
*/
export type BillingPlanTierView =
| (BillingPlanTierBase & { state: 'current' })
| (BillingPlanTierBase & { state: 'downgrade' })
| (BillingPlanTierBase & { state: 'scheduled' })
| (BillingPlanTierBase & { action: { label: string; url: string }; state: 'upgrade' })
export interface BillingUsageRowView {
bar?: {
label: string
@ -75,10 +119,19 @@ export interface BillingUsageRowView {
}
export interface BillingView {
accountRows: BillingAccountRowView[]
notice?: BillingNoticeView
/** Payment section row. Absent outside the normal (logged-in) state. */
paymentRow?: BillingAccountRowView
/** Current-plan card (Plan section). Absent until billing.state resolves. */
plan?: BillingPlanCardView
/** Automatic-refill section row. */
refillRow?: BillingAccountRowView
status: 'loading' | 'logged_out' | 'normal' | 'refusal'
summary: BillingSummaryItemView[]
/** Live tier catalog for the plans sub-view (empty when unavailable). */
tiers: BillingPlanTierView[]
/** One-time top-up section row. */
topupRow?: BillingAccountRowView
usageRows: BillingUsageRowView[]
}
@ -110,19 +163,19 @@ export function deriveBillingView(
): BillingView {
if (!stateResult) {
return {
accountRows: [],
status: 'loading',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
if (!stateResult.ok) {
return {
accountRows: [],
notice: refusalNotice(stateResult.refusal),
status: 'refusal',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
@ -132,7 +185,6 @@ export function deriveBillingView(
if (!billing.logged_in || subscription?.logged_in === false) {
return {
accountRows: [],
notice: {
action: { label: 'Open portal ↗', url: billing.portal_url ?? subscription?.portal_url ?? FALLBACK_PORTAL_URL },
message: 'Run /portal in the TUI or open the Nous portal to connect your account.',
@ -140,12 +192,25 @@ export function deriveBillingView(
},
status: 'logged_out',
summary: emptySummary(),
tiers: [],
usageRows: []
}
}
// One "can change plans in-app" verdict, shared by the plan card (button vs portal
// link) and the grid (whether upgrade tiles are actionable) so the invariant lives
// in one place.
const capable = plansCapable(subscription, subscriptionResult)
// Computed once and threaded to both the card (caption + undo) and the grid
// (Scheduled marker), so the two never disagree about what's pending.
const pending = pendingTransition(subscription?.current)
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
return {
accountRows: deriveAccountRows(billing, subscription, subscriptionResult),
notice: undefined,
paymentRow: paymentMethodRow(billing),
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
refillRow: autoReloadRow(billing),
status: 'normal',
summary: [
{ label: 'Balance', value: displayBalance(billing) },
@ -156,6 +221,8 @@ export function deriveBillingView(
value: billing.auto_reload ? (billing.auto_reload.enabled ? 'Enabled' : 'Off') : EMPTY_BILLING_VALUE
}
],
tiers,
topupRow: buyCreditsRow(billing),
usageRows: deriveUsageRows(billing, subscription)
}
}
@ -163,9 +230,14 @@ export function deriveBillingView(
export function buildManageSubscriptionUrl(
subscription?: null | Pick<SubscriptionStateResponse, 'org_id' | 'portal_url'>,
fallbackPortalUrl?: null | string,
tierId?: string
// Optional tier to pre-select on the portal, appended as `plan=<tierId>`
// (validated server-side by the NAS reader, draft #748).
tierId?: null | string
): string {
const portalUrls = [subscription?.portal_url, fallbackPortalUrl].filter(
// The hard-coded portal is the LAST-RESORT origin, not a bare early return:
// org_id / plan must still be applied to it so a null portal_url never silently
// strips the params that route the user to the right org + pre-selected tier.
const portalUrls = [subscription?.portal_url, fallbackPortalUrl, FALLBACK_PORTAL_BILLING_URL].filter(
(url): url is string => typeof url === 'string' && url.length > 0
)
@ -243,17 +315,214 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
}
}
function deriveAccountRows(
// The active tier from the UNFILTERED catalog — a grandfathered current tier is
// is_enabled:false, so it must still resolve here (by is_current or matching id).
function findCurrentTier(subscription: null | SubscriptionStateResponse): SubscriptionTierOption | undefined {
const current = subscription?.current
return subscription?.tiers?.find(tier => tier.is_current || tier.tier_id === current?.tier_id)
}
// Whether this account can change plans in-app: a personal (non-team) subscription
// the server says the user can change, whose payload actually loaded.
function plansCapable(
subscription: null | SubscriptionStateResponse,
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined
): boolean {
if (!subscription || (subscriptionResult && !subscriptionResult.ok)) {
return false
}
return subscription.context !== 'team' && Boolean(subscription.can_change_plan)
}
// Monthly credits are dollars; NAS sends a bare decimal string. Never render a
// bare number — always "$110 credits/mo" (mirrors the retired subscriptionTierChips).
function creditsPerMonthDisplay(monthlyCredits: null | string): string | undefined {
const credits = Number((monthlyCredits ?? '').replace(/,/g, ''))
return Number.isFinite(credits) && credits > 0 ? `$${credits.toLocaleString('en-US')} credits/mo` : undefined
}
/**
* A monthly-credits delta from a plan-change preview. NAS sends a bare dollar
* decimal ("-88"); credits are DOLLARS, so render it as signed dollars
* ("$88/mo"), never the raw number. Zero / absent null so the caller hides
* the line entirely.
*/
export function formatMonthlyCreditsDelta(delta?: null | string): null | string {
const amount = parseAmount(delta)
if (amount == null || amount === 0) {
return null
}
return `${amount < 0 ? '' : '+'}${formatMoney(Math.abs(amount))}/mo`
}
/**
* The current-plan card. It offers the in-app "View plans" / "Change plan" button
* ONLY when the account is plans-capable AND the grid has an actual UPGRADE to offer
* a top-tier subscriber (only downgrades / current below them) would otherwise open
* a grid with nothing to do. In every no-button case (teams, non-changers, refused
* subscription, top tier, empty catalog) the card ALWAYS carries the portal
* escape-hatch link so the user is never stranded on an info-only card.
*/
function derivePlanCard(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse,
subscriptionResult?: BillingResult<SubscriptionStateResponse>
): BillingAccountRowView[] {
return [
paymentMethodRow(billing),
subscriptionRow(billing, subscription, subscriptionResult),
buyCreditsRow(billing),
autoReloadRow(billing)
]
subscriptionResult: BillingResult<SubscriptionStateResponse> | undefined,
tiers: BillingPlanTierView[],
capable: boolean,
pending: PendingPlanTransition | undefined
): BillingPlanCardView {
const current = subscription?.current
const tierName = current?.tier_name ?? billing.usage?.plan_name ?? 'Free'
// Price resolves against the UNFILTERED catalog so a grandfathered current tier
// still shows its price.
const price = findCurrentTier(subscription)?.dollars_per_month_display
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
const unavailable = subscriptionResult ? !subscriptionResult.ok : false
const caption = unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
: pending
? pending.kind === 'downgrade'
? `Changes to ${pending.tierName} on ${pending.when}.`
: `Cancels on ${pending.when}.`
: current
? `Renews ${renewal}`
: 'No active subscription — paid models draw down top-up credits.'
// Actionable = a paid tier above (upgrade) or an in-app downgrade below the current
// one. Ticket 11 counts downgrades (they act in-app, so they carry no `action`); a
// top-tier subscriber with neither still gets the portal-link fallback below.
const hasActionableTier = tiers.some(tier => tier.state === 'upgrade' || tier.state === 'downgrade')
if (capable && hasActionableTier) {
return { action: { label: current ? 'Change plan' : 'View plans' }, caption, pending, price, tierName }
}
return {
caption,
// No in-app action → always hand off to the portal so the user isn't stranded.
link: {
label: 'Adjust plan ↗',
url: buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url)
},
pending,
price,
tierName
}
}
// The change scheduled at period end (undoable via subscription.resume). NAS may
// carry a pending downgrade (`pending_downgrade_*`, with a target tier name) and/or a
// scheduled cancellation (`cancel_at_period_end` + `cancellation_effective_*`).
// Precedence: a downgrade WINS if both are somehow set — it names a concrete target
// tier, the stronger, more specific signal, and is what the grid marks.
function pendingTransition(
current: null | undefined | NonNullable<SubscriptionStateResponse['current']>
): PendingPlanTransition | undefined {
if (current?.pending_downgrade_tier_name && current.pending_downgrade_at) {
return {
kind: 'downgrade',
tierName: current.pending_downgrade_tier_name,
when: current.pending_downgrade_display ?? formatBillingDate(current.pending_downgrade_at)
}
}
if (current?.cancel_at_period_end && current.cancellation_effective_at) {
return {
kind: 'cancellation',
when: current.cancellation_effective_display ?? formatBillingDate(current.cancellation_effective_at)
}
}
return undefined
}
/**
* The plans-grid catalog. Each card's state depends on its order relative to the
* current tier: current = inert marker; higher = "Choose ↗" opening the portal with
* the tier pre-selected; lower = an in-app "Downgrade" (chargeless, scheduled via the
* gateway). The already-scheduled downgrade target renders as an inert "Scheduled"
* marker; other lower tiers stay actionable (picking one reschedules). With no active
* subscription the lowest-order ($0 / free) tier stands in as the current plan, so
* there is no "subscribe to Free" upgrade and no downgrade state.
*
* Empty unless `capable`: only a plans-capable account gets actionable tiles, and the
* plan card / deep-link gate on the same verdict so the grid never mints an
* upgrade action nobody may take. `fallbackPortalUrl` (billing.portal_url) backs the
* Choose URLs when the subscription payload has no portal_url, so org_id + plan are
* never dropped.
*/
function derivePlanTiers(
subscription: null | SubscriptionStateResponse,
fallbackPortalUrl: null | string,
capable: boolean,
pending: PendingPlanTransition | undefined
): BillingPlanTierView[] {
if (!capable || !subscription) {
return []
}
const allTiers = subscription.tiers ?? []
const current = subscription.current
const explicitCurrent = findCurrentTier(subscription)
// The grid shows the enabled catalog plus the grandfathered current tier (so it
// still renders as the inert "Current plan" card), sorted low→high.
const gridTiers = allTiers
.filter(tier => tier.is_enabled || tier.tier_id === explicitCurrent?.tier_id)
.slice()
.sort((a, b) => a.tier_order - b.tier_order)
if (gridTiers.length === 0) {
return []
}
// No active subscription → the lowest-order ($0 / free) tier stands in as the
// current plan: inert, never a "subscribe to Free" upgrade, and (being lowest)
// never leaving room for a downgrade.
const currentTier = explicitCurrent ?? (current == null ? gridTiers[0] : undefined)
const currentOrder = currentTier?.tier_order
const manageBase = subscription.portal_url ?? fallbackPortalUrl
// Only a downgrade has a target tier to mark; a cancellation has none.
const pendingName = pending?.kind === 'downgrade' ? pending.tierName : null
return gridTiers.map((tier): BillingPlanTierView => {
const base: BillingPlanTierBase = {
creditsDisplay: creditsPerMonthDisplay(tier.monthly_credits),
name: tier.name,
priceDisplay: tier.dollars_per_month_display,
tierId: tier.tier_id
}
if (currentTier && tier.tier_id === currentTier.tier_id) {
return { ...base, state: 'current' }
}
// A scheduled downgrade target is inert (matched by name — NAS sends no id for
// the pending target). Name is a safe key: SubscriptionTypes.name is @unique in
// NAS, so two tiers can't collide. Checked before the downgrade branch since the
// target IS a lower tier.
if (pendingName && tier.name === pendingName) {
return { ...base, state: 'scheduled' }
}
// Downgrade = strictly below the current tier's order → an in-app chargeless
// change (the PlanCard wires the confirm flow by tierId).
if (currentOrder != null && tier.tier_order < currentOrder) {
return { ...base, state: 'downgrade' }
}
return {
...base,
action: { label: 'Choose ↗', url: buildManageSubscriptionUrl(subscription, manageBase, tier.tier_id) },
state: 'upgrade'
}
})
}
function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView {
@ -279,71 +548,6 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView
}
}
/**
* Tier catalog as chips for accounts that can change plans; the current plan is
* inert, every other opens the portal where the change/start happens, deep-linked
* to that tier via `?plan=`.
*/
function subscriptionTierChips(
subscription: null | SubscriptionStateResponse,
fallbackPortalUrl?: null | string
): BillingChipView[] | undefined {
// Teams have no personal subscription to sell into.
if (!subscription?.can_change_plan || subscription.context === 'team') {
return undefined
}
const tiers = (subscription.tiers ?? [])
.filter(tier => tier.is_enabled && tier.tier_order > 0)
.sort((a, b) => a.tier_order - b.tier_order)
if (tiers.length === 0) {
return undefined
}
return tiers.map(tier => {
// Monthly credits are dollars; NAS sends a bare decimal string.
const credits = Number((tier.monthly_credits ?? '').replace(/,/g, ''))
const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : ''
const label = `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}`
return tier.is_current
? { disabled: true, label: `${label}` }
: { disabled: false, label, url: buildManageSubscriptionUrl(subscription, fallbackPortalUrl, tier.tier_id) }
})
}
function subscriptionRow(
billing: BillingStateResponse,
subscription: null | SubscriptionStateResponse,
subscriptionResult?: BillingResult<SubscriptionStateResponse>
): BillingAccountRowView {
const fallbackPortalUrl = subscription?.portal_url ?? billing.portal_url
const manageUrl = buildManageSubscriptionUrl(subscription, fallbackPortalUrl)
const current = subscription?.current
const fallbackPlan = billing.usage?.plan_name ?? EMPTY_BILLING_VALUE
const value = current?.tier_name ?? fallbackPlan
const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at)
const unavailable = subscriptionResult && !subscriptionResult.ok
const chips = subscriptionTierChips(subscription, fallbackPortalUrl)
return {
action: { label: 'Adjust plan ↗', url: manageUrl },
caption: unavailable
? 'Subscription details are unavailable; opening the portal is still available.'
: `Renews ${renewal}`,
chips,
description:
!current && chips
? 'Paid models need a subscription — pick a plan to start it on the portal.'
: 'Review your plan and change it from the billing portal.',
id: 'subscription',
secondaryPill: 'opens portal',
title: 'Subscription',
value
}
}
function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
if (!billing.card) {
return {
@ -355,7 +559,7 @@ function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
portalUrl: billing.portal_url ?? undefined
}).message,
id: 'buy_credits',
title: 'Buy credits'
title: 'Buy credits now'
}
}
@ -365,19 +569,24 @@ function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView {
return {
description: disabledReason,
id: 'buy_credits',
title: 'Buy credits'
title: 'Buy credits now'
}
}
return {
action: { disabled: true, label: 'Buy' },
chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })),
description: 'Add top-up credits for agent runs outside your plan.',
description: 'A single charge on your card, added to your balance today.',
id: 'buy_credits',
title: 'Buy credits'
title: 'Buy credits now'
}
}
// The generic first sentence shared by the off / absent / divergent states,
// where the concrete amounts aren't the headline. The configured state overrides
// this with the disambiguating "Charges $X … below $Y." sentence (spec §8).
const AUTO_REFILL_GENERIC = 'Keep your balance topped up when it drops below your threshold.'
function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
const autoReload = billing.auto_reload
@ -385,24 +594,26 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
return {
action: { disabled: true, label: 'Manage' },
caption: 'Manage auto-refill from the portal.',
description: 'Keep your balance topped up when it drops below your threshold.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: EMPTY_BILLING_VALUE, tone: 'muted' },
title: 'Auto-refill'
title: 'Refill when low'
}
}
if (!autoReload.enabled) {
return {
caption: 'Turn on auto-refill from the portal',
description: 'Keep your balance topped up when it drops below your threshold.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Off', tone: 'muted' },
title: 'Auto-refill'
title: 'Refill when low'
}
}
if (autoReload.card.kind === 'distinct') {
// A null card (gateway emits it for a missing/unknown-kind card) falls through to
// the default enabled path below — the same treatment as a canonical card.
if (autoReload.card?.kind === 'distinct') {
const { brand, last4 } = autoReload.card
const cardLabel = brand && last4 ? `${capitalize(brand)} ••${last4}` : 'a different card'
const portalUrl = billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL
@ -410,22 +621,27 @@ function autoReloadRow(billing: BillingStateResponse): BillingAccountRowView {
return {
action: { label: 'Reconcile ↗', url: portalUrl },
caption: `Auto-refill charges ${cardLabel} — reconcile on the portal`,
description: 'Keep your balance topped up when it drops below your threshold.',
description: AUTO_REFILL_GENERIC,
id: 'auto_reload',
pill: { label: 'Enabled', tone: 'primary' },
title: 'Auto-refill'
title: 'Refill when low'
}
}
const reloadTo = autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd)
const threshold = autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
return {
action: { label: 'Manage' },
caption: `Refill ${autoReload.reload_to_display || formatMoney(autoReload.reload_to_usd)} when balance falls below ${
autoReload.threshold_display || formatMoney(autoReload.threshold_usd)
}`,
description: 'Keep your balance topped up when it drops below your threshold.',
// Numbers live in the first sentence (spec §8); the swap region below carries
// the editable fields, so no redundant caption here.
description: `Charges ${reloadTo} automatically when your balance falls below ${threshold}.`,
id: 'auto_reload',
// The only row that edits in place — AutoReloadRow keys its swap layout off this
// flag rather than sniffing the action label.
manageInApp: true,
pill: { label: 'Enabled', tone: 'primary' },
title: 'Auto-refill'
title: 'Refill when low'
}
}
@ -519,8 +735,7 @@ function displayPlan(subscription: null | SubscriptionStateResponse, usage?: Usa
return EMPTY_BILLING_VALUE
}
const currentTier = subscription?.tiers.find(t => t.is_current || t.tier_id === current?.tier_id)
const price = currentTier?.dollars_per_month_display
const price = findCurrentTier(subscription)?.dollars_per_month_display
return price ? `${tier} · ${price}/mo` : tier
}
@ -534,7 +749,6 @@ function topupCreditsValue(billing: BillingStateResponse, usage?: UsageModelData
)
}
function buyCreditsDisabledReason(billing: BillingStateResponse): null | string {
if (!billing.is_admin) {
return resolveRefusal({ kind: 'role_required', message: '' }).message

View file

@ -0,0 +1,171 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { act, renderHook, waitFor } from '@testing-library/react'
import type { ReactNode } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
const apiMocks = vi.hoisted(() => ({
previewSubscriptionChange: vi.fn(),
resumeSubscription: vi.fn(),
scheduleSubscriptionChange: vi.fn()
}))
vi.mock('./api', () => ({ useBillingApi: () => apiMocks }))
import { useDowngradeFlow, useResumeFlow } from './use-subscription-change'
function wrapper({ children }: { children: ReactNode }) {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
return <QueryClientProvider client={client}>{children}</QueryClientProvider>
}
afterEach(() => {
vi.clearAllMocks()
vi.unstubAllEnvs()
})
describe('useDowngradeFlow', () => {
it('previews then schedules with the tier id, refetches, and calls onScheduled', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const onScheduled = vi.fn()
const { result } = renderHook(() => useDowngradeFlow({ onScheduled }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledWith('t_free')
await act(async () => {
await result.current.confirm()
})
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('t_free')
expect(onScheduled).toHaveBeenCalledTimes(1)
expect(result.current.active).toBeNull()
})
it('records a preview refusal as the previewFailed phase and re-runs on retry', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('previewFailed'))
const phase = result.current.active?.phase
if (phase?.kind !== 'previewFailed') {
throw new Error('expected previewFailed phase')
}
expect(phase.refusal.kind).toBe('insufficient_scope')
act(() => result.current.retryPreview())
await waitFor(() => expect(apiMocks.previewSubscriptionChange).toHaveBeenCalledTimes(2))
})
it('cancel clears the active change without scheduling', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
act(() => result.current.cancel())
expect(result.current.active).toBeNull()
expect(apiMocks.scheduleSubscriptionChange).not.toHaveBeenCalled()
})
it('exposes mutating only while the schedule RPC is in flight', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
let settleSchedule: (value: unknown) => void = () => {}
apiMocks.scheduleSubscriptionChange.mockReturnValue(
new Promise(resolve => {
settleSchedule = resolve
})
)
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
expect(result.current.mutating).toBe(false)
act(() => {
void result.current.confirm()
})
await waitFor(() => expect(result.current.mutating).toBe(true))
act(() => settleSchedule({ data: { ok: true }, ok: true }))
await waitFor(() => expect(result.current.mutating).toBe(false))
})
it('fires a single schedule RPC when confirm is double-activated in the same tick', async () => {
apiMocks.previewSubscriptionChange.mockResolvedValue({
data: { effect: 'scheduled', ok: true, target_tier_name: 'Free' },
ok: true
})
apiMocks.scheduleSubscriptionChange.mockResolvedValue({ data: { ok: true }, ok: true })
const { result } = renderHook(() => useDowngradeFlow({ onScheduled: vi.fn() }), { wrapper })
act(() => result.current.begin({ tierId: 't_free', tierName: 'Free' }))
await waitFor(() => expect(result.current.active?.phase.kind).toBe('ready'))
// Two synchronous activations before React commits busy='schedule'.
await act(async () => {
void result.current.confirm()
void result.current.confirm()
})
expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledTimes(1)
})
})
describe('useResumeFlow', () => {
it('resumes (undo) and clears the refusal on success', async () => {
apiMocks.resumeSubscription.mockResolvedValue({ data: { ok: true }, ok: true })
const { result } = renderHook(() => useResumeFlow(), { wrapper })
await act(async () => {
await result.current.resume()
})
expect(apiMocks.resumeSubscription).toHaveBeenCalledTimes(1)
expect(result.current.refusal).toBeNull()
})
it('surfaces a resume refusal', async () => {
apiMocks.resumeSubscription.mockResolvedValue({
ok: false,
refusal: { kind: 'insufficient_scope', message: 'billing:manage required' }
})
const { result } = renderHook(() => useResumeFlow(), { wrapper })
await act(async () => {
await result.current.resume()
})
expect(result.current.refusal?.kind).toBe('insufficient_scope')
})
})

View file

@ -0,0 +1,177 @@
import { useQueryClient } from '@tanstack/react-query'
import { useRef, useState } from 'react'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import type { SubscriptionPreviewResponse } from './types'
export interface DowngradeTarget {
tierId: string
tierName: string
}
/**
* The state machine for one downgrade attempt. Modeled as a discriminated union
* rather than four independent nullables so impossible combinations (a preview AND a
* refusal, a "ready" with no quote) simply cannot be represented, and the panel reads
* exactly one `kind`.
*/
export type DowngradePhase =
| { kind: 'previewFailed'; refusal: BillingRefusal }
| { kind: 'previewing' }
| { kind: 'ready'; preview: SubscriptionPreviewResponse }
| { kind: 'scheduleFailed'; preview: SubscriptionPreviewResponse; refusal: BillingRefusal }
| { kind: 'scheduling'; preview: SubscriptionPreviewResponse }
export interface ActiveDowngrade {
phase: DowngradePhase
target: DowngradeTarget
}
function invalidateBilling(queryClient: ReturnType<typeof useQueryClient>) {
return Promise.all([
queryClient.invalidateQueries({ queryKey: ['billing', 'state'] }),
queryClient.invalidateQueries({ queryKey: ['billing', 'subscription'] })
])
}
/**
* The in-app downgrade flow: preview (chargeless quote) confirm schedule at
* period end. Typed refusals surface via the shared BillingRefusalInline (which
* drives the step-up for insufficient_scope, exactly like the auto-reload save);
* the caller retries by clicking the same button after verifying. On a scheduled
* success it refetches billing + subscription and calls `onScheduled`.
*
* The api comes from `useBillingApi`, which DEV fixtures override with a simulated
* implementation so this flow has no fixture/simulation awareness of its own.
*/
export function useDowngradeFlow({ onScheduled }: { onScheduled: () => void }) {
const api = useBillingApi()
const queryClient = useQueryClient()
const [active, setActive] = useState<ActiveDowngrade | null>(null)
// Monotonic run id discards results from a superseded/cancelled attempt.
const runIdRef = useRef(0)
// Synchronous mutex: two clicks in the same tick both see active.busy === null
// (React hasn't committed the 'schedule' state yet), so guard on a ref too — no
// double schedule RPC. Cleared on every confirm() exit (below).
const schedulingRef = useRef(false)
const runPreview = async (target: DowngradeTarget, runId: number) => {
const result = await api.previewSubscriptionChange(target.tierId)
if (runIdRef.current !== runId) {
return
}
setActive({
phase: result.ok ? { kind: 'ready', preview: result.data } : { kind: 'previewFailed', refusal: result.refusal },
target
})
}
const begin = (target: DowngradeTarget) => {
const runId = runIdRef.current + 1
runIdRef.current = runId
setActive({ phase: { kind: 'previewing' }, target })
void runPreview(target, runId)
}
const retryPreview = () => {
if (active) {
begin(active.target)
}
}
const confirm = async () => {
// Only a quoted state (ready, or a failed schedule being retried) can commit.
if (!active || schedulingRef.current || (active.phase.kind !== 'ready' && active.phase.kind !== 'scheduleFailed')) {
return
}
schedulingRef.current = true
const { target } = active
const { preview } = active.phase
const runId = runIdRef.current + 1
runIdRef.current = runId
setActive({ phase: { kind: 'scheduling', preview }, target })
const result = await api.scheduleSubscriptionChange(target.tierId)
schedulingRef.current = false
if (runIdRef.current !== runId) {
return
}
if (!result.ok) {
// A refusal (e.g. insufficient_scope → step-up) leaves the panel open in
// scheduleFailed, so the same button becomes a manual "Try again" AFTER the
// user elevates. We deliberately do NOT auto-replay the mutation on step-up
// success — this matches the auto-reload save's manual-retry pattern.
setActive({ phase: { kind: 'scheduleFailed', preview, refusal: result.refusal }, target })
return
}
await invalidateBilling(queryClient)
setActive(null)
onScheduled()
}
const cancel = () => {
runIdRef.current += 1
setActive(null)
}
// True only while the mutating RPC (schedule) is in flight — used to lock out
// every other Downgrade tile + Back while one change is committing (the server
// also 409s overlapping mutations per-org, so this is UI honesty, not the only
// defense).
const mutating = active?.phase.kind === 'scheduling'
return { active, begin, cancel, confirm, mutating, retryPreview }
}
/**
* The undo for a scheduled downgrade / cancellation: a chargeless
* `subscription.resume` (no confirm step) that refetches on success. A refusal
* (e.g. insufficient_scope step-up) surfaces via `refusal`. The api (real or the
* DEV-fixture simulation) comes from `useBillingApi`.
*/
export function useResumeFlow() {
const api = useBillingApi()
const queryClient = useQueryClient()
const [busy, setBusy] = useState(false)
const [refusal, setRefusal] = useState<BillingRefusal | null>(null)
const runningRef = useRef(false)
const resume = async () => {
if (runningRef.current) {
return
}
runningRef.current = true
setBusy(true)
setRefusal(null)
const result = await api.resumeSubscription()
if (!result.ok) {
// Refusal → unlock immediately so the user can retry / step up.
runningRef.current = false
setBusy(false)
setRefusal(result.refusal)
return
}
// Success → hold the lock (Undo stays disabled) THROUGH the refetch, so the
// button never re-enables against the stale, still-pending card.
await invalidateBilling(queryClient)
runningRef.current = false
setBusy(false)
}
return { busy, refusal, resume }
}

View file

@ -19,7 +19,7 @@ import { Skeleton } from '@/components/ui/skeleton'
import type { HermesGateway } from '@/hermes'
import { useI18n } from '@/i18n'
import { ChevronDown, ChevronRight } from '@/lib/icons'
import { requestModelOptions } from '@/lib/model-options'
import { modelOptionsQueryKey, requestModelOptions } from '@/lib/model-options'
import {
currentPickerSelection,
displayModelName,
@ -59,6 +59,7 @@ export interface ModelSelection {
interface ModelMenuPanelProps {
gateway?: HermesGateway
onSelectModel: (selection: ModelSelection) => Promise<boolean> | void
profile?: string
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
}
@ -67,7 +68,7 @@ interface ProviderGroup {
provider: ModelOptionProvider
}
export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: ModelMenuPanelProps) {
export function ModelMenuPanel({ gateway, onSelectModel, profile = 'default', requestGateway }: ModelMenuPanelProps) {
const { t } = useI18n()
const copy = t.shell.modelMenu
const closeMenu = useContext(ModelMenuCloseContext)
@ -87,7 +88,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
const collapsedProviders = useStore($collapsedProviders)
const modelOptions = useQuery({
queryKey: ['model-options', activeSessionId || 'global'],
queryKey: modelOptionsQueryKey(profile, activeSessionId),
// Gateway-first even with no session yet: a connected (possibly remote)
// gateway owns the model catalog, including virtual providers like `moa`
// that the local REST fallback can't know about (#53817).
@ -148,7 +149,7 @@ export function ModelMenuPanel({ gateway, onSelectModel, requestGateway }: Model
setRefreshing(true)
try {
const queryKey = ['model-options', activeSessionId || 'global']
const queryKey = modelOptionsQueryKey(profile, activeSessionId)
const next = await requestModelOptions({ gateway, refresh: true, sessionId: activeSessionId })

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.7 KiB

Some files were not shown because too many files have changed in this diff Show more