mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge origin/main into feat/hermes-relay-shared-metrics
Signed-off-by: Alex Fournier <afournier@nvidia.com>
This commit is contained in:
commit
761fce7f79
480 changed files with 65880 additions and 3400 deletions
5
.github/workflows/ci.yml
vendored
5
.github/workflows/ci.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
18
.github/workflows/review-labels.yml
vendored
18
.github/workflows/review-labels.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
44
.github/workflows/supply-chain-audit.yml
vendored
44
.github/workflows/supply-chain-audit.yml
vendored
|
|
@ -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
8
.gitignore
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
131
agent/battery.py
Normal 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}%"
|
||||
|
|
@ -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
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"),
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
488
agent/secret_sources/command.py
Normal file
488
agent/secret_sources/command.py
Normal 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)
|
||||
|
|
@ -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_"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 upgrade→Checkout / downgrade→scheduled
|
||||
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)
|
||||
# =============================================================================
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { expect, test } from '@playwright/test'
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
PACKAGED_BINARY_PATH,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
56
apps/desktop/e2e/scripts/seed_session_db.py
Normal file
56
apps/desktop/e2e/scripts/seed_session_db.py
Normal 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
166
apps/desktop/e2e/test.ts
Normal 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 }
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
72
apps/desktop/electron/native-auth-decisions.test.ts
Normal file
72
apps/desktop/electron/native-auth-decisions.test.ts
Normal 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' })
|
||||
})
|
||||
64
apps/desktop/electron/native-auth-decisions.ts
Normal file
64
apps/desktop/electron/native-auth-decisions.ts
Normal 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' }
|
||||
}
|
||||
169
apps/desktop/electron/native-oauth-login.test.ts
Normal file
169
apps/desktop/electron/native-oauth-login.test.ts
Normal 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
|
||||
)
|
||||
})
|
||||
213
apps/desktop/electron/native-oauth-login.ts
Normal file
213
apps/desktop/electron/native-oauth-login.ts
Normal 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>✓ 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 }
|
||||
190
apps/desktop/electron/native-oauth.test.ts
Normal file
190
apps/desktop/electron/native-oauth.test.ts
Normal 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 (43–128).
|
||||
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)
|
||||
})
|
||||
215
apps/desktop/electron/native-oauth.ts
Normal file
215
apps/desktop/electron/native-oauth.ts
Normal 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 desktop→IDP 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 43–128 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 }
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
54
apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx
Normal file
54
apps/desktop/src/app/chat/sidebar/load-more-row.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
87
apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx
Normal file
87
apps/desktop/src/app/chat/sidebar/project-dialog.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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" />
|
||||
)}
|
||||
|
|
|
|||
132
apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx
Normal file
132
apps/desktop/src/app/chat/sidebar/projects/project-menu.test.tsx
Normal 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)
|
||||
})
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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} />
|
||||
</>
|
||||
)
|
||||
|
|
|
|||
120
apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx
Normal file
120
apps/desktop/src/app/chat/sidebar/session-actions-menu.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
198
apps/desktop/src/app/chat/sidebar/session-row.test.tsx
Normal file
198
apps/desktop/src/app/chat/sidebar/session-row.test.tsx
Normal 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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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)}
|
||||
|
|
|
|||
|
|
@ -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 = (
|
||||
|
|
|
|||
|
|
@ -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 />
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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) })
|
||||
})
|
||||
})
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
48
apps/desktop/src/app/settings/billing/account-row-value.tsx
Normal file
48
apps/desktop/src/app/settings/billing/account-row-value.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
293
apps/desktop/src/app/settings/billing/auto-reload-row.tsx
Normal file
293
apps/desktop/src/app/settings/billing/auto-reload-row.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
123
apps/desktop/src/app/settings/billing/billing-amounts.ts
Normal file
123
apps/desktop/src/app/settings/billing/billing-amounts.ts
Normal 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)
|
||||
}
|
||||
57
apps/desktop/src/app/settings/billing/current-plan-card.tsx
Normal file
57
apps/desktop/src/app/settings/billing/current-plan-card.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
70
apps/desktop/src/app/settings/billing/inline-feedback.tsx
Normal file
70
apps/desktop/src/app/settings/billing/inline-feedback.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
9
apps/desktop/src/app/settings/billing/open-external.ts
Normal file
9
apps/desktop/src/app/settings/billing/open-external.ts
Normal 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)
|
||||
}
|
||||
}
|
||||
228
apps/desktop/src/app/settings/billing/plans-view.tsx
Normal file
228
apps/desktop/src/app/settings/billing/plans-view.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
57
apps/desktop/src/app/settings/billing/simulated-api.test.ts
Normal file
57
apps/desktop/src/app/settings/billing/simulated-api.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
89
apps/desktop/src/app/settings/billing/simulated-api.ts
Normal file
89
apps/desktop/src/app/settings/billing/simulated-api.ts
Normal 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 })
|
||||
}
|
||||
}
|
||||
25
apps/desktop/src/app/settings/billing/tier-art.test.ts
Normal file
25
apps/desktop/src/app/settings/billing/tier-art.test.ts
Normal 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()
|
||||
})
|
||||
})
|
||||
69
apps/desktop/src/app/settings/billing/tier-art.tsx
Normal file
69
apps/desktop/src/app/settings/billing/tier-art.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
})
|
||||
})
|
||||
177
apps/desktop/src/app/settings/billing/use-subscription-change.ts
Normal file
177
apps/desktop/src/app/settings/billing/use-subscription-change.ts
Normal 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 }
|
||||
}
|
||||
|
|
@ -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 })
|
||||
|
||||
|
|
|
|||
BIN
apps/desktop/src/assets/tiers/feature-automation.webp
Normal file
BIN
apps/desktop/src/assets/tiers/feature-automation.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.3 KiB |
BIN
apps/desktop/src/assets/tiers/feature-connect.webp
Normal file
BIN
apps/desktop/src/assets/tiers/feature-connect.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 7.5 KiB |
BIN
apps/desktop/src/assets/tiers/feature-memory.webp
Normal file
BIN
apps/desktop/src/assets/tiers/feature-memory.webp
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 5.1 KiB |
BIN
apps/desktop/src/assets/tiers/feature-sandbox.webp
Normal file
BIN
apps/desktop/src/assets/tiers/feature-sandbox.webp
Normal file
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
Loading…
Add table
Add a link
Reference in a new issue