Compare commits

..
Author SHA1 Message Date
ethernet 7acd6c902c fix(types): fix ty python env resolution + triage discord adapter
The .venv directory (created by uv run) contained Python 3.13 with no
deps installed. ty auto-discovers .venv for module resolution, so it
could not find discord.py, aiohttp, etc., producing ~1000 false
"Module has no member" errors.

Removing the stray .venv makes ty fall back to the nix env (Python 3.12
with all deps installed). Also set python-version = "3.12" in
[tool.ty.environment] with a comment explaining why.

ty diagnostics: 4,422 -> 3,385 (-1,037)
Tests: 496 passed, 0 failed

fix(nix): use python311 in dev shell (matches requires-python floor)

Production venv still uses python312 (nixos-unstable default). The dev
editable venv now uses python311 — the requires-python floor (>=3.11) —
so ty type checking and local dev catch 3.12+-only syntax that wouldnt
2026-07-17 15:35:38 -04:00
ethernet 0cb42c10a4 fix(types): declare AIAgent instance attributes as class-level annotations
init_agent() in agent/agent_init.py sets ~176 instance attributes on the
AIAgent instance, but ty cannot track cross-module attribute assignment
through a function that receives the instance as a plain `agent` parameter.
This caused 186 unresolved-attribute errors in run_agent.py alone.

Declaring the attributes as class-level annotations (PEP 526) tells ty
they exist, clearing 182 of 190 errors (190→8). The remaining 8 are:
- 2 duck-typed object.function accesses (hasattr-guarded, safe)
- 2 missing attrs (_current_tool, _api_call_count — added)
- 2 None-narrowing on iteration_budget (.used, .max_total)
- 2 None-narrowing on client (.close)

Overall ty diagnostics: 4,648 → 4,422 (−226)
Tests: 496 passed, 0 failed
2026-07-17 15:05:55 -04:00
ethernet 169bfe20e4 fix(types): sweep invalid-parameter-default across core modules
Add `| None` to ~250 params across 30 files that were annotated as bare
`str`, `list`, `dict`, `int`, `Callable`, etc. but defaulted to None.

This is a big typechecking fix. each one cascades, making ty stop
narrowing those vars to None and clearing downstream
unresolved-attribute / not-subscriptable / invalid-argument-type errors.

Two type bugs surfaced and fixed during the sweep:

1. Lowercase `callable` (builtin function) used as type annotation in
   28 callback params in agent_init.py + run_agent.py. `callable | None`
   isn't valid. Fixed to `Callable` (typing).

2. String forward-ref with `| None` (`"IterationBudget" | None`) is
    wrong because `|` can't OR a str with NoneType. Fixed to
   `Optional["IterationBudget"]`.

Also:
- Configure ty to exclude tests/ via [tool.ty.src] in pyproject.toml
  (tests are ~57% of diagnostics, lowest-value typing target)

ty diagnostics: 13,290 -> 4,648 (core only, tests excluded)
Tests: 496 passed, 0 failed
2026-07-17 15:04:17 -04:00
464 changed files with 5116 additions and 41430 deletions
+5 -39
View File
@@ -5,12 +5,6 @@ description: >-
the sub-workflows a PR can affect. Outputs are always "true" on push/dispatch
events and fail open (everything "true") when the diff cannot be computed.
inputs:
github-token:
description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow.
required: false
default: ${{ github.token }}
outputs:
python:
description: Run Python tests / ruff / ty / windows-footguns.
@@ -47,12 +41,7 @@ runs:
id: classify
shell: bash
env:
# Fall back to the built-in read-only token when the caller passes an
# empty value. Fork PRs get no repo secrets, so AUTOFIX_BOT_PAT is ""
# there, and an input `default:` only applies when the input is omitted,
# not when it's passed empty. Without this fallback the compare API
# fails on forks and the classifier fails open (every lane forced on).
GH_TOKEN: ${{ inputs.github-token || github.token }}
GH_TOKEN: ${{ github.token }}
REPO: ${{ github.repository }}
EVENT_NAME: ${{ github.event_name }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
@@ -68,33 +57,10 @@ runs:
# event payload instead of the "current PR files" endpoint. The SHAs
# are frozen at trigger time, so the file list is deterministic even
# if the PR receives a new push between trigger and detect.
#
# Retried: a rate-limit blip or eventual-consistency 404 on a
# freshly-pushed HEAD would otherwise silently fall open (all lanes
# run — safe, but wasteful and it masks the API failure).
#
# `.files[]?` (null-safe): with --paginate, a PR more than 100
# commits ahead of its merge-base paginates the compare, and pages
# after the first carry `files: null` — bare `.files[]` makes jq
# die with "cannot iterate over: null", which fails every retry
# and forces the fail-open path (seen on stacked PRs). The full
# file list (up to the API's 300-file cap) is on page one.
CHANGED=""
for i in 1 2 3; do
if CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[]?.filename')"; then
break
fi
if [ "$i" = 3 ]; then
echo "::warning::compare API failed after 3 attempts — failing open (all lanes run)"
CHANGED=""
break
fi
echo "::warning::compare API failed (attempt $i); retrying in 10s"
sleep 10
done
CHANGED="$(gh api \
--paginate \
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
--jq '.files[].filename' || true)"
fi
echo "Changed files:"
+3 -23
View File
@@ -3,8 +3,7 @@ description: >-
Run a shell command, retrying on non-zero exit. For dependency installs
(npm ci, uv sync) whose only failures are transient network/toolchain
flakes — a node-gyp header fetch, a registry blip — so CI self-heals
instead of needing a manual re-run. Can also capture stdout as a step
output for commands whose result must be consumed by later steps.
instead of needing a manual re-run.
inputs:
command:
@@ -20,16 +19,10 @@ inputs:
description: Directory to run in.
default: "."
outputs:
stdout:
description: Captured stdout from the successful attempt (empty if not needed).
value: ${{ steps.retry.outputs.stdout }}
runs:
using: composite
steps:
- id: retry
shell: bash
- shell: bash
working-directory: ${{ inputs.working-directory }}
# command goes through env, never interpolated into the script body, so
# a command with quotes/specials can't break or inject into the runner.
@@ -39,25 +32,12 @@ runs:
_DELAY: ${{ inputs.delay }}
run: |
set -uo pipefail
_OUTFILE="$(mktemp)"
trap 'rm -f "$_OUTFILE"' EXIT
n=0
while :; do
n=$((n + 1))
echo "::group::attempt $n/$_ATTEMPTS: $_CMD"
# Run the command, capturing stdout to a temp file while still
# streaming to the log. We redirect first, then tee the file to
# stdout — this avoids pipefail + tee exit-code interactions that
# can cause the if-branch to be skipped under set -e.
if bash -c "$_CMD" > "$_OUTFILE"; then
cat "$_OUTFILE"
if bash -c "$_CMD"; then
echo "::endgroup::"
# Preserve newlines in the output via heredoc delimiter.
{
echo 'stdout<<__RETRY_STDOUT_EOF__'
cat "$_OUTFILE"
echo '__RETRY_STDOUT_EOF__'
} >> "$GITHUB_OUTPUT"
exit 0
fi
echo "::endgroup::"
+1 -24
View File
@@ -35,7 +35,6 @@ jobs:
detect:
name: Detect affected areas
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
python: ${{ steps.classify.outputs.python }}
frontend: ${{ steps.classify.outputs.frontend }}
@@ -52,10 +51,6 @@ jobs:
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so classification still works there.
github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
@@ -68,7 +63,6 @@ jobs:
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
secrets: inherit
lint:
name: Python lints
@@ -78,55 +72,47 @@ jobs:
with:
event_name: ${{ needs.detect.outputs.event_name }}
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
secrets: inherit
js-tests:
name: JS & TS checks
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
secrets: inherit
docs-site:
name: Docs Site
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
secrets: inherit
history-check:
name: Deny unrelated histories
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
secrets: inherit
contributor-check:
name: Check contributors
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
secrets: inherit
uv-lockfile:
name: Check uv.lock
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
secrets: inherit
lockfile-diff:
name: package-lock.json diff
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
uses: ./.github/workflows/lockfile-diff.yml
secrets: inherit
docker-lint:
name: Lint Docker scripts
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
secrets: inherit
docker:
name: Build&Test Docker image
@@ -145,12 +131,10 @@ jobs:
scan: ${{ needs.detect.outputs.scan == 'true' }}
deps: ${{ needs.detect.outputs.deps == 'true' }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
secrets: inherit
osv-scanner:
name: OSV scan
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit
# ─────────────────────────────────────────────────────────────────────
# Gate: runs after everything. ``if: always()`` ensures it reports a
@@ -177,7 +161,6 @@ jobs:
# - docker
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Evaluate job results
env:
@@ -208,7 +191,6 @@ jobs:
needs: [all-checks-pass, docker]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -226,10 +208,7 @@ jobs:
- name: Collect timings and generate report
env:
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so the timings API read still works
# there instead of hard-failing this advisory job on every fork PR.
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
python3 scripts/ci/timings_report.py \
--baseline ci-timings-baseline.json \
@@ -238,8 +217,6 @@ jobs:
--summary-out ci-timings-summary.md
- name: Upload HTML report
# Advisory report — artifact-service blips must not fail the job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
with:
+7 -13
View File
@@ -9,7 +9,6 @@ permissions:
jobs:
check-attribution:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -28,9 +27,7 @@ jobs:
exit 0
fi
# An email is mapped if it has a file in contributors/emails/
# (one file per email — conflict-free) or an entry in the frozen
# legacy AUTHOR_MAP in scripts/release.py.
# Check each email against AUTHOR_MAP in release.py
MISSING=""
while IFS= read -r email; do
# Skip teknium and bot emails
@@ -39,12 +36,9 @@ jobs:
continue ;;
esac
# Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern)
if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then
continue # GitHub id+login noreply emails auto-resolve
fi
if [ -f "contributors/emails/${email}" ]; then
continue # mapped via the contributors directory
continue # GitHub noreply emails auto-resolve
fi
if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
@@ -55,19 +49,19 @@ jobs:
if [ -n "$MISSING" ]; then
echo ""
echo "⚠️ New contributor email(s) without a mapping:"
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
echo -e "$MISSING"
echo ""
echo "Add a mapping file (do NOT edit AUTHOR_MAP in release.py):"
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
echo -e "$MISSING" | while read -r line; do
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
[ -z "$email" ] && continue
echo " python3 scripts/add_contributor.py ${email} <github-username>"
echo " \"${email}\": \"<github-username>\","
done
echo ""
echo "To find the GitHub username for an email:"
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
exit 1
else
echo "✅ All contributor emails are mapped."
echo "✅ All contributor emails are mapped in AUTHOR_MAP."
fi
+6 -12
View File
@@ -41,15 +41,13 @@ jobs:
# doesn't auto-deploy via the deploy-docs path.
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- name: Trigger Vercel Deploy
run: curl -fsS --retry 3 --retry-delay 10 -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
deploy-docs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: github-pages
url: ${{ steps.deploy.outputs.page_url }}
@@ -67,14 +65,12 @@ jobs:
python-version: '3.11'
- name: Install PyYAML for skill extraction
uses: ./.github/actions/retry
with:
command: pip install pyyaml==6.0.2 httpx==0.28.1
run: pip install pyyaml==6.0.2 httpx==0.28.1
- name: Prepare skills index (unified multi-source catalog)
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
run: |
@@ -154,10 +150,8 @@ jobs:
run: python3 website/scripts/generate-skill-docs.py
- name: Install dependencies
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: website
run: npm ci
working-directory: website
- name: Build Docusaurus
run: npm run build
+13 -22
View File
@@ -127,13 +127,12 @@ jobs:
run: uv python install 3.11
- name: Install Python dependencies (for docker tests)
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uses: ./.github/actions/retry
with:
command: uv sync --locked --python 3.11 --extra dev
run: |
# ``dev`` extra pulls in pytest, pytest-asyncio —
# everything tests/docker/ needs. We deliberately avoid ``all``
# here because the docker tests only drive the container via
# subprocess and don't import hermes_agent's optional deps.
uv sync --locked --python 3.11 --extra dev
- name: Run docker integration tests
env:
@@ -189,23 +188,15 @@ jobs:
args+=("${IMAGE_NAME}@sha256:${digest_file}")
done
if [ "${{ github.event_name }}" = "release" ]; then
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
docker buildx imagetools create \
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
"${args[@]}"
else
tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
docker buildx imagetools create \
-t "${IMAGE_NAME}:main" \
-t "${IMAGE_NAME}:latest" \
"${args[@]}"
fi
# Retry: Docker Hub API + just-pushed digest eventual consistency
# can transiently fail the create; the operation is idempotent.
for i in 1 2 3; do
if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
break
fi
if [ "$i" = 3 ]; then
echo "::error::imagetools create failed after 3 attempts"
exit 1
fi
echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
sleep 20
done
- name: Inspect image
env:
-1
View File
@@ -9,7 +9,6 @@ permissions:
jobs:
docs-site-checks:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-1
View File
@@ -22,7 +22,6 @@ permissions:
jobs:
check-common-ancestor:
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
-2
View File
@@ -8,7 +8,6 @@ jobs:
workspaces:
name: List npm workspaces
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
packages: ${{ steps.set-matrix.outputs.packages }}
steps:
@@ -33,7 +32,6 @@ jobs:
name: Typecheck & Test
needs: workspaces
runs-on: ubuntu-latest
timeout-minutes: 20
strategy:
matrix:
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
+3 -7
View File
@@ -180,11 +180,7 @@ jobs:
- name: Require ci-reviewed label
id: label-check
env:
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
# read-only on forks) so the gate works on fork PRs; fall back to it
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
# "label absent" rather than hard-failing the step.
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
@@ -204,7 +200,7 @@ jobs:
- name: Post or update review warning
if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
@@ -252,7 +248,7 @@ jobs:
- name: Update previous warning to passed
if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
+1 -1
View File
@@ -61,7 +61,7 @@ jobs:
- name: Post or update PR comment
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
REPO: ${{ github.repository }}
PR: ${{ github.event.pull_request.number }}
CHANGED: ${{ steps.diff.outputs.changed }}
+2 -3
View File
@@ -20,7 +20,6 @@ jobs:
check-freshness:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Probe live index
id: probe
@@ -29,7 +28,7 @@ jobs:
URL="https://hermes-agent.nousresearch.com/docs/api/skills-index.json"
echo "Probing $URL"
# -L follows redirects; -f fails on HTTP errors; -s suppresses progress
if ! curl -fsSL --retry 3 --retry-delay 10 -o /tmp/skills-index.json "$URL"; then
if ! curl -fsSL -o /tmp/skills-index.json "$URL"; then
echo "status=fetch-failed" >> "$GITHUB_OUTPUT"
echo "detail=Could not download $URL" >> "$GITHUB_OUTPUT"
exit 0
@@ -111,7 +110,7 @@ jobs:
- name: Open issue on degraded / failed probe
if: steps.probe.outputs.status != 'ok'
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
STATUS: ${{ steps.probe.outputs.status }}
DETAIL: ${{ steps.probe.outputs.detail }}
run: |
+3 -7
View File
@@ -20,7 +20,6 @@ jobs:
# Only run on the upstream repository, not on forks
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -29,13 +28,11 @@ jobs:
python-version: "3.11"
- name: Install dependencies
uses: ./.github/actions/retry
with:
command: pip install httpx==0.28.1 pyyaml==6.0.2
run: pip install httpx==0.28.1 pyyaml==6.0.2
- name: Build skills index
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: python scripts/build_skills_index.py
- name: Upload index artifact
@@ -52,9 +49,8 @@ jobs:
needs: build-index
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Trigger Deploy Site workflow
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
+4 -11
View File
@@ -43,7 +43,6 @@ jobs:
name: Scan PR for critical supply chain risks
if: inputs.scan
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -53,7 +52,7 @@ jobs:
- name: Scan diff for critical patterns
id: scan
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
@@ -142,7 +141,7 @@ jobs:
- name: Post critical finding comment
if: steps.scan.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BODY="## 🚨 CRITICAL Supply Chain Risk Detected
@@ -165,7 +164,6 @@ jobs:
name: Check PyPI dependency upper bounds
if: inputs.deps
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -203,7 +201,7 @@ jobs:
- name: Post unbounded dep warning
if: steps.bounds.outputs.found == 'true'
env:
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
BODY="## ⚠️ Unbounded PyPI Dependency Detected
@@ -231,7 +229,6 @@ jobs:
name: MCP catalog security review
if: inputs.mcp_catalog
runs-on: ubuntu-latest
timeout-minutes: 15
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -240,11 +237,7 @@ jobs:
- name: Require explicit MCP catalog review label
env:
# Read-only label lookup. Use the built-in GITHUB_TOKEN (present and
# read-only on forks) so the gate works on fork PRs; fall back to it
# when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to
# "label absent" rather than hard-failing the step.
GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
PR="${{ github.event.pull_request.number }}"
-11
View File
@@ -20,7 +20,6 @@ jobs:
generate:
name: "Generate slices"
runs-on: ubuntu-latest
timeout-minutes: 10
outputs:
matrix: ${{ steps.matrix.outputs.matrix }}
steps:
@@ -32,12 +31,6 @@ jobs:
with:
path: test_durations.json
key: test-durations
# Saves use test-durations-${run_id}, so the exact key above never
# matches — without this prefix fallback the cache ALWAYS missed,
# LPT slicing ran on no data, and unbalanced slices pushed heavy
# files toward the per-file timeout under load.
restore-keys: |
test-durations-
- name: Generate test slices
id: matrix
@@ -121,9 +114,6 @@ jobs:
NOUS_API_KEY: ""
- name: Upload per-slice durations
# Advisory artifact (feeds slice balancing) — a transient artifact-
# service blip must not fail an otherwise-green test slice.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: test-durations-slice-${{ matrix.slice.index }}
@@ -136,7 +126,6 @@ jobs:
needs: test
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Download all slice durations
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+4 -21
View File
@@ -26,7 +26,6 @@ jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
@@ -57,24 +56,10 @@ jobs:
node-version: "22"
- name: Build web dashboard
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: web
- name: Compile web dashboard
run: npm run build
working-directory: web
run: cd web && npm ci && npm run build
- name: Build TUI bundle
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: ui-tui
- name: Compile TUI bundle
run: npm run build
working-directory: ui-tui
run: cd ui-tui && npm ci && npm run build
- name: Bundle TUI into hermes_cli
run: |
@@ -105,7 +90,6 @@ jobs:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: pypi
url: https://pypi.org/p/hermes-agent
@@ -131,7 +115,6 @@ jobs:
if: startsWith(github.ref, 'refs/tags/')
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # attach assets to the existing release
id-token: write # sigstore signing
@@ -145,7 +128,7 @@ jobs:
- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ github.token }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
@@ -171,7 +154,7 @@ jobs:
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }}
GITHUB_TOKEN: ${{ github.token }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
+1 -14
View File
@@ -74,20 +74,7 @@ jobs:
# rebase and regenerate uv.lock."
- name: Verify uv.lock is up-to-date
run: |
# uv lock --check re-resolves against PyPI (network). Retry so a
# registry blip doesn't read as "lockfile stale". A genuinely stale
# lockfile fails all attempts (deterministic), costing only seconds.
ok=false
for i in 1 2 3; do
if uv lock --check; then
ok=true
break
fi
[ "$i" = 3 ] && break
echo "::warning::uv lock --check failed (attempt $i); retrying in 10s"
sleep 10
done
if [ "$ok" != true ]; then
if ! uv lock --check; then
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
## ❌ uv.lock is out of sync with pyproject.toml
-8
View File
@@ -1294,14 +1294,6 @@ scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
```
**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
section with both attempts' output. A FLAKY report is a bug to fix, not noise
to ignore — timing-sensitive tests must not assume a quiet runner (loose
wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
negative-timing races).
#### Subprocess-per-test-file isolation
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
+29 -31
View File
@@ -26,8 +26,8 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
# zombies non-blockingly on SIGCHLD and additionally supervises the main
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get -o Acquire::Retries=3 update && \
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
@@ -40,30 +40,33 @@ RUN apt-get -o Acquire::Retries=3 update && \
# we map between them inline. The noarch + symlinks tarballs are
# architecture-independent and reused as-is.
#
# We use `curl` instead of `ADD` for ALL three tarballs: `ADD` evaluates its
# URL at parse time (no ARG / TARGETARCH substitution) and — critically for
# CI reliability — cannot retry, so a single GitHub-release CDN blip fails
# the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips,
# and every tarball is still checksum-verified below before extraction.
# We use `curl` instead of `ADD` for the per-arch tarball because `ADD`
# evaluates its URL at parse time, before any ARG / TARGETARCH substitution
# — splitting one URL per arch into two ADDs would download both on every
# build and leave dead bytes in the cache. A single curl + arch-keyed URL
# is simpler and cache-friendlier.
#
# Supply-chain integrity: every tarball is checksum-verified against the
# upstream-published SHA256. To bump S6_OVERLAY_VERSION, fetch the four
# `.sha256` files from the corresponding release and update the ARGs. The
# checksum lookup happens during build, so a compromised release artifact
# fails the build loudly instead of silently producing a tampered image.
ARG TARGETARCH
ARG S6_OVERLAY_VERSION=3.2.3.0
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-symlinks-noarch.tar.xz /tmp/
RUN set -eu; \
case "${TARGETARCH:-amd64}" in \
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
esac; \
base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \
"${base}/s6-overlay-noarch.tar.xz"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \
"${base}/s6-overlay-symlinks-noarch.tar.xz"; \
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
"${base}/s6-overlay-${s6_arch}.tar.xz"; \
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${s6_arch}.tar.xz"; \
{ \
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
@@ -73,19 +76,17 @@ RUN set -eu; \
tar -C / -Jxpf /tmp/s6-overlay-noarch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-arch.tar.xz; \
tar -C / -Jxpf /tmp/s6-overlay-symlinks-noarch.tar.xz; \
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256
# #34192 / #66679: backward-compat shim for orchestration templates that
# still reference the legacy /usr/bin/tini entrypoint (Hostinger's
# 'Hermes WebUI' catalog, NAS compose projects that preserve an old
# entrypoint on image update, etc.). A plain symlink to /init made the
# path exist, but forwarded tini flags like `-g` into s6-overlay's
# rc.init as the container CMD (`rc.init: 91: -g: not found`) and
# boot-looped any `restart: unless-stopped` deploy. The shim strips the
# tini CLI surface, then exec's /init + main-wrapper — see
# docker/tini-shim.sh. Safe to drop once the affected catalogs are
# updated.
COPY --chmod=0755 docker/tini-shim.sh /usr/bin/tini
rm /tmp/s6-overlay-*.tar.xz /tmp/s6-overlay.sha256; \
# #34192: backward-compat shim for orchestration templates that still\
# reference the legacy /usr/bin/tini entrypoint (e.g. Hostinger's\
# 'Hermes WebUI' catalog). The image has moved to s6-overlay /init\
# as PID 1 (see ENTRYPOINT below + the migration comment at the top\
# of this file), but external wrappers pinned to /usr/bin/tini will\
# crash with 'tini: No such file or directory' on startup. The shim\
# symlinks /usr/bin/tini -> /init so legacy wrappers exec the right\
# PID-1 reaper without behavior change for users on the current\
# ENTRYPOINT. Safe to drop once the affected catalogs are updated.\
ln -sf /init /usr/bin/tini
# Non-root user for runtime; UID can be overridden via HERMES_UID at runtime
RUN useradd -u 10000 -m -d /opt/data hermes
@@ -134,11 +135,8 @@ COPY apps/shared/ apps/shared/
# guards against a future regression if the source npm version changes.
ENV npm_config_install_links=false
RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
for i in 1 2 3; do \
npx playwright install --with-deps chromium --only-shell && break || \
{ [ "$i" = 3 ] && exit 1; echo "playwright install failed (attempt $i); retrying in 10s"; sleep 10; }; \
done && \
RUN npm install --prefer-offline --no-audit && \
npx playwright install --with-deps chromium --only-shell && \
npm cache clean --force
# ---------- Layer-cached Python dependency install ----------
+7 -7
View File
@@ -214,7 +214,7 @@ def build_nous_credits_snapshot(account_info) -> Optional[AccountUsageSnapshot]:
return None
details.append(f"Top up: {nous_portal_topup_url(account_info)}")
details.append("(or run /topup)")
details.append("(or run /credits)")
plan = getattr(sub, "plan", None) if sub is not None else None
return AccountUsageSnapshot(
@@ -340,7 +340,7 @@ def _snapshot_from_credits_state(state) -> Optional[AccountUsageSnapshot]:
@dataclass(frozen=True)
class CreditsView:
"""Surface-agnostic data for the ``/topup`` balance view.
"""Surface-agnostic data for the ``/credits`` command.
One portal fetch, one parse — consumed identically by the CLI panel, the
gateway button, and any other money surface. Fail-open: when not logged in
@@ -356,11 +356,11 @@ class CreditsView:
def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> CreditsView:
"""Build the /topup balance view: balance block + identity line + top-up URL.
"""Build the /credits view: balance block + identity line + top-up URL.
Reuses the same account fetch + snapshot + URL builder as the /usage credits
block, so the numbers always match. The balance block is the rendered
snapshot MINUS its trailing top-up/command-hint lines (the /topup surface
snapshot MINUS its trailing top-up/command-hint lines (the /credits surface
supplies its own affordance). Fail-open → ``CreditsView(logged_in=False)``.
"""
not_logged_in = CreditsView(logged_in=False)
@@ -386,7 +386,7 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
timeout=timeout
)
except Exception:
logger.debug("credits ▸ /topup portal fetch failed (fail-open)", exc_info=True)
logger.debug("credits ▸ /credits portal fetch failed (fail-open)", exc_info=True)
return not_logged_in
if account is None or not getattr(account, "logged_in", False):
@@ -394,8 +394,8 @@ def build_credits_view(*, markdown: bool = False, timeout: float = 10.0) -> Cred
snapshot = build_nous_credits_snapshot(account)
# Balance lines = the snapshot block minus the two trailing affordance lines
# ("Top up: <url>" + "(or run /topup)") that build_nous_credits_snapshot
# appends for the /usage surface. /topup renders its own button/panel.
# ("Top up: <url>" + "(or run /credits)") that build_nous_credits_snapshot
# appends for the /usage surface. /credits renders its own button/panel.
balance_lines: list[str] = []
if snapshot is not None:
rendered = render_account_usage_lines(snapshot, markdown=markdown)
+46 -46
View File
@@ -275,71 +275,71 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An
def init_agent(
agent,
base_url: str = None,
api_key: str = None,
provider: str = None,
api_mode: str = None,
acp_command: str = None,
base_url: str | None = None,
api_key: str | None = None,
provider: str | None = None,
api_mode: str | None = None,
acp_command: str | None = None,
acp_args: list[str] | None = None,
command: str = None,
command: str | None = None,
args: list[str] | None = None,
model: str = "",
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
tool_delay: float = 1.0,
enabled_toolsets: List[str] = None,
disabled_toolsets: List[str] = None,
enabled_toolsets: List[str] | None = None,
disabled_toolsets: List[str] | None = None,
save_trajectories: bool = False,
verbose_logging: bool = False,
quiet_mode: bool = False,
tool_progress_mode: str = "all",
ephemeral_system_prompt: str = None,
ephemeral_system_prompt: str | None = None,
log_prefix_chars: int = 100,
log_prefix: str = "",
providers_allowed: List[str] = None,
providers_ignored: List[str] = None,
providers_order: List[str] = None,
provider_sort: str = None,
providers_allowed: List[str] | None = None,
providers_ignored: List[str] | None = None,
providers_order: List[str] | None = None,
provider_sort: str | None = None,
provider_require_parameters: bool = False,
provider_data_collection: str = None,
provider_data_collection: str | None = None,
openrouter_min_coding_score: Optional[float] = None,
session_id: str = None,
tool_progress_callback: callable = None,
tool_start_callback: callable = None,
tool_complete_callback: callable = None,
thinking_callback: callable = None,
reasoning_callback: callable = None,
clarify_callback: callable = None,
read_terminal_callback: callable = None,
step_callback: callable = None,
stream_delta_callback: callable = None,
interim_assistant_callback: callable = None,
tool_gen_callback: callable = None,
status_callback: callable = None,
notice_callback: callable = None,
notice_clear_callback: callable = None,
session_id: str | None = None,
tool_progress_callback: Callable | None = None,
tool_start_callback: Callable | None = None,
tool_complete_callback: Callable | None = None,
thinking_callback: Callable | None = None,
reasoning_callback: Callable | None = None,
clarify_callback: Callable | None = None,
read_terminal_callback: Callable | None = None,
step_callback: Callable | None = None,
stream_delta_callback: Callable | None = None,
interim_assistant_callback: Callable | None = None,
tool_gen_callback: Callable | None = None,
status_callback: Callable | None = None,
notice_callback: Callable | None = None,
notice_clear_callback: Callable | None = None,
event_callback: Optional[Callable[[str, dict], None]] = None,
reaction_callback: Optional[Callable[[str], None]] = None,
max_tokens: int = None,
reasoning_config: Dict[str, Any] = None,
service_tier: str = None,
request_overrides: Dict[str, Any] = None,
prefill_messages: List[Dict[str, Any]] = None,
platform: str = None,
user_id: str = None,
user_id_alt: str = None,
user_name: str = None,
chat_id: str = None,
chat_name: str = None,
chat_type: str = None,
thread_id: str = None,
gateway_session_key: str = None,
max_tokens: int | None = None,
reasoning_config: Dict[str, Any] | None = None,
service_tier: str | None = None,
request_overrides: Dict[str, Any] | None = None,
prefill_messages: List[Dict[str, Any]] | None = None,
platform: str | None = None,
user_id: str | None = None,
user_id_alt: str | None = None,
user_name: str | None = None,
chat_id: str | None = None,
chat_name: str | None = None,
chat_type: str | None = None,
thread_id: str | None = None,
gateway_session_key: str | None = None,
skip_context_files: bool = False,
load_soul_identity: bool = False,
skip_memory: bool = False,
session_db=None,
parent_session_id: str = None,
iteration_budget: "IterationBudget" = None,
fallback_model: Dict[str, Any] = None,
parent_session_id: str | None = None,
iteration_budget: Optional["IterationBudget"] = None,
fallback_model: Dict[str, Any] | None = None,
credential_pool=None,
checkpoints_enabled: bool = False,
checkpoint_max_snapshots: int = 20,
+4 -42
View File
@@ -37,7 +37,6 @@ from agent.tool_dispatch_helpers import _trajectory_normalize_msg, make_tool_res
from agent.trajectory import convert_scratchpad_to_think
from agent.credential_pool import STATUS_EXHAUSTED
from agent.error_classifier import FailoverReason
from agent.turn_context import drop_stale_api_content
from utils import base_url_host_matches, base_url_hostname, env_var_enabled, atomic_json_write
logger = logging.getLogger(__name__)
@@ -247,7 +246,7 @@ def sanitize_tool_call_arguments(
messages: list,
*,
logger=None,
session_id: str = None,
session_id: str | None = None,
) -> int:
"""Repair corrupted assistant tool-call argument JSON in-place."""
log = logger or logging.getLogger(__name__)
@@ -588,10 +587,6 @@ def repair_message_sequence(agent, messages: List[Dict]) -> int:
if prev_content and new_content
else (prev_content or new_content)
)
# Merged content invalidates the api_content sidecar (exact
# bytes previously sent for the pre-merge message) — drop it
# so replay can't substitute stale bytes.
drop_stale_api_content(prev)
repairs += 1
continue
merged.append(msg)
@@ -648,16 +643,16 @@ def strip_think_blocks(agent, content: str) -> str:
"""Remove reasoning/thinking blocks from content, returning only visible text.
Handles four cases:
1. Closed tag pairs (`` <think> ``) the common path when
1. Closed tag pairs (``<think></think>``) the common path when
the provider emits complete reasoning blocks.
2. Unterminated open tag at a block boundary (start of text or
after a newline) e.g. MiniMax M2.7 / NIM endpoints where the
closing tag is dropped. Everything from the open tag to end
of string is stripped. The block-boundary check mirrors
``gateway/stream_consumer.py``'s filter so models that mention
`` <think>`` in prose aren't over-stripped.
``<think>`` in prose aren't over-stripped.
3. Stray orphan open/close tags that slip through.
4. Tag variants: `` <think>``, ``<thinking>``, ``<reasoning>``,
4. Tag variants: ``<think>``, ``<thinking>``, ``<reasoning>``,
``<REASONING_SCRATCHPAD>``, ``<thought>`` (Gemma 4), all
case-insensitive.
@@ -677,39 +672,6 @@ def strip_think_blocks(agent, content: str) -> str:
"""
if not content:
return ""
# Coerce non-string content to text before any regex runs. Providers
# that return assistant ``content`` as a list of blocks (Anthropic via
# OpenRouter emits ``[{"type":"text",...}, {"type":"thinking",...}]``) or
# as a dict flow into this shared helper from several callers — most
# notably ``_interim_assistant_visible_text`` reading a *stored* history
# message whose content was persisted as a list. A raw list/dict reaching
# ``re.sub`` below raises ``TypeError: expected string or bytes-like
# object, got 'list'``, which the outer conversation loop swallows and
# retries forever (observed as an infinite "preparing terminal…" loop on
# Anthropic models via OpenRouter). Flatten here so every caller is safe.
if not isinstance(content, str):
if isinstance(content, list):
_parts: list[str] = []
for _part in content:
if isinstance(_part, str):
_parts.append(_part)
elif isinstance(_part, dict):
_ptype = str(_part.get("type") or "").strip().lower()
# Drop reasoning/thinking blocks outright — this function's
# whole job is to strip them, and their text lives under
# different keys ("thinking", "reasoning") per provider.
if _ptype in {"thinking", "reasoning", "redacted_thinking"}:
continue
_text = _part.get("text")
if isinstance(_text, str) and _text:
_parts.append(_text)
content = "".join(_parts)
elif isinstance(content, dict):
content = str(content.get("text") or content.get("content") or "")
else:
content = str(content)
if not content:
return ""
# 1. Closed tag pairs — case-insensitive for all variants so
# mixed-case tags (<THINK>, <Thinking>) don't slip through to
# the unterminated-tag pass and take trailing content with them.
+4 -4
View File
@@ -633,8 +633,8 @@ def _common_betas_for_base_url(
def _build_anthropic_client_with_bearer_hook(
token_provider,
base_url: str = None,
timeout: float = None,
base_url: str | None = None,
timeout: float | None = None,
*,
drop_context_1m_beta: bool = False,
):
@@ -709,8 +709,8 @@ def _build_anthropic_client_with_bearer_hook(
def build_anthropic_client(
api_key,
base_url: str = None,
timeout: float = None,
base_url: str | None = None,
timeout: float | None = None,
*,
drop_context_1m_beta: bool = False,
):
-16
View File
@@ -66,19 +66,3 @@ def safe_schedule_threadsafe(
coro.close()
log.log(log_level, "%s: %s", log_message, exc)
return None
def consume_detached_task_result(task: "asyncio.Future[Any]") -> None:
"""Retrieve a detached task's result without surfacing cancellation.
Used as an ``add_done_callback`` on tasks that were cancelled and
detached (e.g. an adapter close path that swallows ``CancelledError``
past its teardown deadline). Observing ``task.exception()`` prevents
"exception was never retrieved" noise on the event loop; cancellation
and any terminal error are deliberately swallowed the task's owner
already gave up on it.
"""
try:
task.exception()
except (asyncio.CancelledError, Exception):
pass
+35 -42
View File
@@ -3972,7 +3972,7 @@ async def _call_fallback_candidate_async(
def _try_payment_fallback(
failed_provider: str,
task: str = None,
task: str | None = None,
reason: str = "payment error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Try alternative providers after a payment/credit or connection error.
@@ -4023,7 +4023,7 @@ def _try_payment_fallback(
def _try_main_agent_model_fallback(
failed_provider: str,
task: str = None,
task: str | None = None,
reason: str = "error",
) -> Tuple[Optional[Any], Optional[str], str]:
"""Last-resort fallback to the user's main agent provider + model.
@@ -4665,12 +4665,12 @@ def _normalize_resolved_model(model_name: Optional[str], provider: str) -> Optio
def resolve_provider_client(
provider: str,
model: str = None,
model: str | None = None,
async_mode: bool = False,
raw_codex: bool = False,
explicit_base_url: str = None,
explicit_api_key: str = None,
api_mode: str = None,
explicit_base_url: str | None = None,
explicit_api_key: str | None = None,
api_mode: str | None = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
@@ -6086,11 +6086,11 @@ def _compat_model(client: Any, model: Optional[str], cached_default: Optional[st
def _get_cached_client(
provider: str,
model: str = None,
model: str | None = None,
async_mode: bool = False,
base_url: str = None,
api_key: str = None,
api_mode: str = None,
base_url: str | None = None,
api_key: str | None = None,
api_mode: str | None = None,
main_runtime: Optional[Dict[str, Any]] = None,
is_vision: bool = False,
task: Optional[str] = None,
@@ -6222,11 +6222,11 @@ _AUX_DIRECT_API_BASE_URLS: Dict[str, str] = {
def _resolve_task_provider_model(
task: str = None,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
task: str | None = None,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
"""Determine provider + model for a call.
@@ -6253,13 +6253,6 @@ def _resolve_task_provider_model(
cfg_model = str(task_config.get("model", "")).strip() or None
cfg_base_url = str(task_config.get("base_url", "")).strip() or None
cfg_api_key = str(task_config.get("api_key", "")).strip() or None
# Resolve key_env → env var when api_key is not set directly
if not cfg_api_key:
cfg_key_env = str(
task_config.get("key_env") or task_config.get("api_key_env") or ""
).strip()
if cfg_key_env:
cfg_api_key = os.getenv(cfg_key_env, "").strip() or None
cfg_api_mode = str(task_config.get("api_mode", "")).strip() or None
# 'auto' is a sentinel meaning "inherit from main runtime / auto-detect", not
@@ -6907,23 +6900,23 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any:
def call_llm(
task: str = None,
task: str | None = None,
*,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: Optional[float] = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
extra_body: dict = None,
max_tokens: int | None = None,
tools: list | None = None,
timeout: float | None = None,
extra_body: dict | None = None,
reasoning_config: Optional[dict] = None,
api_mode: str = None,
api_mode: str | None = None,
stream: bool = False,
stream_options: dict = None,
stream_options: dict | None = None,
) -> Any:
"""Centralized synchronous LLM call.
@@ -7574,19 +7567,19 @@ def extract_content_or_reasoning(response) -> str:
async def async_call_llm(
task: str = None,
task: str | None = None,
*,
provider: str = None,
model: str = None,
base_url: str = None,
api_key: str = None,
provider: str | None = None,
model: str | None = None,
base_url: str | None = None,
api_key: str | None = None,
main_runtime: Optional[Dict[str, Any]] = None,
messages: list,
temperature: Optional[float] = None,
max_tokens: int = None,
tools: list = None,
timeout: float = None,
extra_body: dict = None,
max_tokens: int | None = None,
tools: list | None = None,
timeout: float | None = None,
extra_body: dict | None = None,
reasoning_config: Optional[dict] = None,
) -> Any:
"""Centralized asynchronous LLM call.
-16
View File
@@ -789,7 +789,6 @@ def stream_converse_with_callbacks(
on_tool_start=None,
on_reasoning_delta=None,
on_interrupt_check=None,
on_event=None,
) -> SimpleNamespace:
"""Process a Bedrock ConverseStream event stream with real-time callbacks.
@@ -809,12 +808,6 @@ def stream_converse_with_callbacks(
on supported models (Claude 4.6+).
on_interrupt_check: Called on each event. Should return True if the
agent has been interrupted and streaming should stop.
on_event: Called once at the top of the loop body for EVERY yielded
Bedrock event (text/tool-input/reasoning/metadata deltas alike),
before any branching. Provides a wire-level liveness signal so an
external watchdog can distinguish "still receiving events" from
"stream wedged with no data". Errors raised by the callback are
swallowed so a liveness hook can never abort the stream.
Returns:
An OpenAI-compatible SimpleNamespace response, identical in shape to
@@ -830,15 +823,6 @@ def stream_converse_with_callbacks(
usage_data: Dict[str, int] = {}
for event in event_stream.get("stream", []):
# Wire-level liveness signal: fire on EVERY yielded event (text, tool
# input, reasoning, metadata) before branching so an external watchdog
# can tell a still-flowing stream from a wedged one. Best-effort — a
# liveness callback must never be able to abort the stream.
if on_event is not None:
try:
on_event()
except Exception:
pass
# Check for interrupt
if on_interrupt_check and on_interrupt_check():
break
-323
View File
@@ -1,323 +0,0 @@
"""Shared dollar-denominated usage model for the billing/subscription surfaces.
The single source of truth behind the ``/usage`` and ``/subscription`` usage
bars (TUI + CLI). User feedback (Jun 2026): the terminal surfaces show
**dollars**, never "credits", and every usage bar must make the monthly
subscription allowance and separately-purchased top-up dollars distinctly
visible.
Data source: the NAS account-info fetch (``NousPortalAccountInfo``), whose
``paid_service_access_info`` carries the three dollar magnitudes we render
(despite the legacy ``*_credits`` field names, these are USD floats):
- ``subscription_credits_remaining`` -> plan dollars left this month
- ``purchased_credits_remaining`` -> top-up dollars left (rolls over)
- ``total_usable_credits`` -> total spendable
plus ``subscription.monthly_credits`` (the plan's monthly $ allowance, the
denominator for the "% used" plan bar) and ``current_period_end`` (renewal).
Design: two SEPARATE bars (decided with the user) rather than one crammed
three-segment bar at terminal widths three same-glyph density segments are
unreadable. The plan bar is "spent vs allowance this month" (carries % used);
the top-up bar is "money you bought, doesn't expire". Each gets full
resolution and a single fill glyph, so the bar is never ambiguous and never
relies on color.
Fail-open everywhere: any missing/non-finite field degrades to fewer bars or a
magnitudes-only view; a logged-out / unreachable portal yields
``available=False`` and the surface shows nothing.
"""
from __future__ import annotations
import logging
import math
import os
from dataclasses import dataclass, field
from typing import Any, Optional
logger = logging.getLogger(__name__)
# Below this TOTAL spendable ($), a paid account is flagged "low" — the alert
# state that nudges top-up/upgrade before a mid-run cutoff. Product threshold
# (user feedback): "any amount below $5 should be an alert status."
LOW_BALANCE_THRESHOLD_USD = 5.0
def _finite(value: Any) -> Optional[float]:
"""Return value as a float iff it's a real finite number (not bool/NaN/Inf)."""
if isinstance(value, bool) or not isinstance(value, (int, float)):
return None
f = float(value)
return f if math.isfinite(f) else None
def _fmt_usd(value: Optional[float]) -> str:
"""``$X.YY`` for display. ``None`` -> ``$0.00`` (callers gate on presence)."""
return f"${(value or 0.0):,.2f}"
def format_renews(value: Optional[str]) -> Optional[str]:
"""Format an ISO date/timestamp as a human date, e.g. ``Jul 24, 2026``.
Accepts ``2026-07-24``, ``2026-07-24T11:05:01.000Z``, etc. Returns the raw
string unchanged if it can't be parsed (never raises), and ``None`` for
empty input.
"""
if not value:
return None
from datetime import datetime
text = str(value).strip()
if not text:
return None
iso = text[:-1] + "+00:00" if text.endswith("Z") else text
try:
dt = datetime.fromisoformat(iso)
except ValueError:
# Fall back to a bare date prefix (YYYY-MM-DD) if present.
try:
dt = datetime.strptime(text[:10], "%Y-%m-%d")
except ValueError:
return text
# %-d isn't portable to Windows; build the day without a leading zero.
return f"{dt.strftime('%b')} {dt.day}, {dt.year}"
@dataclass(frozen=True)
class UsageBar:
"""One full-resolution bar: ``spent`` of ``total``, plus a remaining figure.
``kind`` is ``"plan"`` (monthly allowance, shows % used) or ``"topup"``
(purchased dollars, no denominator ``spent`` is 0 and ``total`` ==
``remaining`` so it renders as a full bar of available balance).
"""
kind: str # "plan" | "topup"
remaining_usd: float
total_usd: float
spent_usd: float = 0.0
@property
def pct_used(self) -> Optional[int]:
if self.kind != "plan" or self.total_usd <= 0:
return None
return max(0, min(100, round(self.spent_usd / self.total_usd * 100)))
@property
def fill_fraction(self) -> float:
"""Fraction of the bar that should read as 'remaining' (filled)."""
if self.total_usd <= 0:
return 0.0
return max(0.0, min(1.0, self.remaining_usd / self.total_usd))
@dataclass(frozen=True)
class UsageModel:
"""Surface-agnostic dollar usage model shared by /usage and /subscription.
``status`` classifies the account for copy selection:
- ``"free"`` : no paid access / no subscription (free models only)
- ``"low"`` : paid, but total spendable < $5 (ALERT)
- ``"healthy"`` : paid, total spendable >= $5
- ``"depleted"`` : paid access lost (balance exhausted)
"""
available: bool
status: str = "free"
plan_name: Optional[str] = None
renews_at: Optional[str] = None
renews_display: Optional[str] = None
subscription_remaining_usd: Optional[float] = None
topup_remaining_usd: Optional[float] = None
total_spendable_usd: Optional[float] = None
plan_bar: Optional[UsageBar] = None
topup_bar: Optional[UsageBar] = None
@property
def has_topup(self) -> bool:
return bool(self.topup_remaining_usd and self.topup_remaining_usd > 0)
def usage_model_from_account(account_info: Any) -> UsageModel:
"""Build a :class:`UsageModel` from a ``NousPortalAccountInfo``. Fail-open.
Returns ``UsageModel(available=False)`` when there's no usable account info
(logged out, no entitlement block). Never raises.
"""
try:
if account_info is None or not getattr(account_info, "logged_in", False):
return UsageModel(available=False)
access = getattr(account_info, "paid_service_access_info", None)
sub = getattr(account_info, "subscription", None)
paid = getattr(account_info, "paid_service_access", None)
sub_remaining = _finite(getattr(access, "subscription_credits_remaining", None)) if access else None
topup_remaining = _finite(getattr(access, "purchased_credits_remaining", None)) if access else None
total_usable = _finite(getattr(access, "total_usable_credits", None)) if access else None
plan_name = getattr(sub, "plan", None) if sub is not None else None
renews_at = getattr(sub, "current_period_end", None) if sub is not None else None
monthly = _finite(getattr(sub, "monthly_credits", None)) if sub is not None else None
has_subscription = bool(plan_name) or (monthly is not None and monthly > 0)
# Total spendable: prefer the server's total; else sum the parts we have.
if total_usable is not None:
total_spendable = total_usable
else:
parts = [v for v in (sub_remaining, topup_remaining) if v is not None]
total_spendable = sum(parts) if parts else None
# Status classification.
if paid is False:
status = "depleted"
elif not has_subscription and not (topup_remaining and topup_remaining > 0):
# No plan and no purchased balance -> free-models-only.
status = "free"
elif total_spendable is not None and total_spendable < LOW_BALANCE_THRESHOLD_USD:
status = "low"
else:
status = "healthy"
# Plan bar — only with a positive monthly allowance AND a remaining we
# can place on it. spent = cap - remaining, clamped (a debt/over-cap
# balance reads as fully spent rather than a nonsensical negative).
plan_bar: Optional[UsageBar] = None
if monthly is not None and monthly > 0 and sub_remaining is not None:
remaining = max(0.0, min(monthly, sub_remaining))
plan_bar = UsageBar(
kind="plan",
remaining_usd=remaining,
total_usd=monthly,
spent_usd=max(0.0, monthly - sub_remaining),
)
# Top-up bar — only when there are purchased dollars to show. No
# denominator (top-up has no monthly cap), so it renders full = balance.
topup_bar: Optional[UsageBar] = None
if topup_remaining is not None and topup_remaining > 0:
topup_bar = UsageBar(
kind="topup",
remaining_usd=topup_remaining,
total_usd=topup_remaining,
spent_usd=0.0,
)
return UsageModel(
available=True,
status=status,
plan_name=plan_name,
renews_at=renews_at,
renews_display=format_renews(renews_at),
subscription_remaining_usd=sub_remaining,
topup_remaining_usd=topup_remaining,
total_spendable_usd=total_spendable,
plan_bar=plan_bar,
topup_bar=topup_bar,
)
except Exception:
logger.debug("usage ▸ model build failed (fail-open)", exc_info=True)
return UsageModel(available=False)
def build_usage_model(*, timeout: float = 10.0) -> UsageModel:
"""Fetch account-info and build the shared usage model. Fail-open.
Dev override: ``HERMES_DEV_CREDITS_FIXTURE`` short-circuits to a fixture so
every usage state is testable without a live account (mirrors the existing
``/usage`` credits-block fixture path).
"""
fixture = _dev_fixture_usage_model()
if fixture is not None:
return fixture
try:
from hermes_cli.auth import get_provider_auth_state
tok = (get_provider_auth_state("nous") or {}).get("access_token")
if not (isinstance(tok, str) and tok.strip()):
return UsageModel(available=False)
except Exception:
return UsageModel(available=False)
try:
import concurrent.futures
from hermes_cli.nous_account import get_nous_portal_account_info
with concurrent.futures.ThreadPoolExecutor(max_workers=1) as pool:
account = pool.submit(get_nous_portal_account_info, force_fresh=True).result(timeout=timeout)
return usage_model_from_account(account)
except Exception:
logger.debug("usage ▸ portal fetch failed (fail-open)", exc_info=True)
return UsageModel(available=False)
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_usage_model() -> Optional[UsageModel]:
"""Map ``HERMES_DEV_CREDITS_FIXTURE`` to a usage model for offline UX work.
Recognized names: ``free | healthy | low | topup | depleted``. Returns
``None`` when the env var is unset (real portal path runs).
"""
name = (os.getenv("HERMES_DEV_CREDITS_FIXTURE") or "").strip().lower()
if not name:
return None
if name == "free":
return UsageModel(available=True, status="free", plan_name=None)
if name in ("healthy", "mid"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
total_spendable_usd=14.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
)
if name in ("topup", "top-up"):
return UsageModel(
available=True,
status="healthy",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=14.0,
topup_remaining_usd=12.0,
total_spendable_usd=26.0,
plan_bar=UsageBar(kind="plan", remaining_usd=14.0, total_usd=20.0, spent_usd=6.0),
topup_bar=UsageBar(kind="topup", remaining_usd=12.0, total_usd=12.0, spent_usd=0.0),
)
if name == "low":
return UsageModel(
available=True,
status="low",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=3.4,
total_spendable_usd=3.4,
plan_bar=UsageBar(kind="plan", remaining_usd=3.4, total_usd=20.0, spent_usd=16.6),
)
if name == "depleted":
return UsageModel(
available=True,
status="depleted",
plan_name="Plus",
renews_at="2026-07-01",
subscription_remaining_usd=0.0,
total_spendable_usd=0.0,
plan_bar=UsageBar(kind="plan", remaining_usd=0.0, total_usd=20.0, spent_usd=20.0),
)
return None
+8 -171
View File
@@ -15,7 +15,6 @@ We keep them as :class:`decimal.Decimal` end-to-end and only format for display.
from __future__ import annotations
import logging
import os
import uuid
from dataclasses import dataclass, field
from decimal import Decimal, InvalidOperation
@@ -65,47 +64,15 @@ def format_money(value: Optional[Decimal]) -> str:
# =============================================================================
# resolvedVia → the human answer to "why THIS card?". Keys are the server's card
# resolution rungs (NAS card-on-file ladder); absent/unknown rungs render no label
# so the display degrades cleanly on servers that don't send resolvedVia yet.
_CARD_PROVENANCE_LABELS = {
"subPin": "the card on your subscription",
"customerDefault": "your default card saved on the portal",
"autoRefill": "your auto-reload card",
}
@dataclass(frozen=True)
class CardInfo:
brand: str
last4: str
# NAS card-on-file field (post card-resolver): which ladder rung found the
# card. Defaults off so pre-resolver payloads parse unchanged.
resolved_via: Optional[str] = None
@property
def masked(self) -> str:
# A Link payment method has no card number (last4 = "") — render the
# brand alone, not "Link ····".
if not self.last4:
return self.brand
return f"{self.brand} ····{self.last4}"
@property
def provenance(self) -> Optional[str]:
"""Human label for why this card was picked, or None (unknown rung /
server too old to say)."""
if self.resolved_via is None:
return None
return _CARD_PROVENANCE_LABELS.get(self.resolved_via)
@property
def display(self) -> str:
"""The one-line card display: ``Visa ····4242 — the card on your
subscription`` (or just the masked card when provenance is unknown)."""
label = self.provenance
return f"{self.masked}{label}" if label else self.masked
@dataclass(frozen=True)
class MonthlyCap:
@@ -114,20 +81,11 @@ class MonthlyCap:
is_default_ceiling: bool = False
@dataclass(frozen=True)
class AutoReloadCard:
kind: str # "canonical" | "distinct" | "none"
payment_method_id: Optional[str] = None
brand: Optional[str] = None
last4: Optional[str] = None
@dataclass(frozen=True)
class AutoReload:
enabled: bool = False
threshold_usd: Optional[Decimal] = None
reload_to_usd: Optional[Decimal] = None
card: Optional[AutoReloadCard] = None
@dataclass(frozen=True)
@@ -142,8 +100,7 @@ class BillingState:
org_id: Optional[str] = None
org_slug: Optional[str] = None
org_name: Optional[str] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
role: Optional[str] = None # "OWNER" | "ADMIN" | "MEMBER"
balance_usd: Optional[Decimal] = None
cli_billing_enabled: bool = False
charge_presets: tuple[Decimal, ...] = ()
@@ -158,33 +115,17 @@ class BillingState:
@property
def is_admin(self) -> bool:
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
"""True for OWNER/ADMIN — the roles that can manage billing."""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
@property
def can_charge(self) -> bool:
"""True when the UI should offer charge/auto-reload actions.
Uses the server-granted plan-change capability (``can_change_plan``,
which itself falls back to the legacy OWNER/ADMIN role check when the
server omits ``canChangePlan``) AND the per-org kill-switch. This lets
the server grant charge capability to non-OWNER/ADMIN roles (e.g.
FINANCE_ADMIN) via ``canChangePlan``, instead of hard-coding the
deprecated 3-role admin check. (The server still enforces; this is
just for graying out actions the user can't take.)
Admin role AND the per-org kill-switch on. (The server still enforces;
this is just for graying out actions the user can't take.)
"""
return self.can_change_plan and self.cli_billing_enabled
return self.is_admin and self.cli_billing_enabled
def _parse_card(raw: Any) -> Optional[CardInfo]:
@@ -192,13 +133,9 @@ def _parse_card(raw: Any) -> Optional[CardInfo]:
return None
brand = raw.get("brand")
last4 = raw.get("last4")
if not (isinstance(brand, str) and isinstance(last4, str)):
return None
# Post-resolver fields — all optional so both payload generations parse.
resolved_via = raw.get("resolvedVia")
if not isinstance(resolved_via, str):
resolved_via = None
return CardInfo(brand=brand, last4=last4, resolved_via=resolved_via)
if isinstance(brand, str) and isinstance(last4, str):
return CardInfo(brand=brand, last4=last4)
return None
def _parse_monthly_cap(raw: Any) -> Optional[MonthlyCap]:
@@ -218,27 +155,6 @@ def _parse_auto_reload(raw: Any) -> Optional[AutoReload]:
enabled=bool(raw.get("enabled")),
threshold_usd=parse_money(raw.get("thresholdUsd")),
reload_to_usd=parse_money(raw.get("reloadToUsd")),
card=_parse_auto_reload_card(raw.get("card")),
)
def _parse_auto_reload_card(raw: Any) -> Optional[AutoReloadCard]:
if not isinstance(raw, dict):
return None
kind = raw.get("kind")
if kind not in ("canonical", "distinct", "none"):
return None
if kind in ("canonical", "none"):
return AutoReloadCard(kind=kind)
payment_method_id = raw.get("paymentMethodId")
brand = raw.get("brand")
last4 = raw.get("last4")
return AutoReloadCard(
kind=kind,
payment_method_id=payment_method_id if isinstance(payment_method_id, str) else None,
brand=brand if isinstance(brand, str) else None,
last4=last4 if isinstance(last4, str) else None,
)
@@ -263,11 +179,6 @@ def billing_state_from_payload(
org_slug=org.get("slug"),
org_name=org.get("name"),
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
balance_usd=parse_money(payload.get("balanceUsd")),
cli_billing_enabled=bool(payload.get("cliBillingEnabled")),
charge_presets=tuple(presets),
@@ -291,15 +202,7 @@ def build_billing_state(*, timeout: float = 15.0) -> BillingState:
Returns ``BillingState(logged_in=False)`` when not logged in. On a portal/HTTP
failure, returns ``logged_in=False`` with ``error`` set so the surface can show
a clear message rather than crashing.
Dev override: ``HERMES_DEV_BILLING_FIXTURE`` short-circuits to a fixture so the
card-on-file / admin / scope states are testable offline (mirrors
``HERMES_DEV_CREDITS_FIXTURE`` for the usage model).
"""
fixture = _dev_fixture_billing_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
@@ -340,72 +243,6 @@ def _fallback_portal_url(base: str) -> str:
return f"{base.rstrip('/')}/billing?topup=open"
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
def _dev_fixture_billing_state() -> Optional[BillingState]:
"""Map ``HERMES_DEV_BILLING_FIXTURE`` to a :class:`BillingState` for offline UX.
Recognized names::
nocard logged in · billing on · admin · NO card on file
card card on file · auto-reload off
card-autoreload card on file · auto-reload on
notadmin logged in · MEMBER role (billing actions disabled)
billing-off logged in · admin · per-org kill-switch OFF
logged-out not logged in
Returns ``None`` when the env var is unset (the real portal path runs).
Mirrors ``HERMES_DEV_CREDITS_FIXTURE``; the usage *bar* still comes from
``HERMES_DEV_CREDITS_FIXTURE`` (set both to pair a bar with a billing state).
"""
name = (os.getenv("HERMES_DEV_BILLING_FIXTURE") or "").strip().lower()
if not name:
return None
# Shared fixture portal host (matches subscription_view._DEV_FIXTURE_PORTAL —
# prod host, not staging; the ?topup=open suffix is the /topup deep-link).
portal = "https://portal.nousresearch.com/billing?topup=open"
common: dict[str, Any] = dict(
org_id="org_acme",
org_slug="acme",
org_name="Acme Inc",
role="OWNER",
balance_usd=Decimal("3.40"),
cli_billing_enabled=True,
charge_presets=(Decimal("10"), Decimal("25"), Decimal("50")),
min_usd=Decimal("5"),
max_usd=Decimal("500"),
portal_url=portal,
)
card = CardInfo(brand="Visa", last4="4242")
autoreload_on = AutoReload(enabled=True, threshold_usd=Decimal("5"), reload_to_usd=Decimal("25"))
if name in ("logged-out", "logged_out", "loggedout"):
return BillingState(logged_in=False)
if name == "nocard":
return BillingState(logged_in=True, card=None, **common)
if name == "card":
return BillingState(logged_in=True, card=card, **common)
if name in ("card-sub", "card_sub"):
# Post-resolver: the card came from the subscription (provenance label).
_sub_card = CardInfo(brand="Visa", last4="4242", resolved_via="subPin")
return BillingState(logged_in=True, card=_sub_card, **common)
if name in ("card-autoreload", "card_autoreload", "autoreload"):
return BillingState(logged_in=True, card=card, auto_reload=autoreload_on, **common)
if name in ("notadmin", "not-admin", "member"):
opts = {**common, "role": "MEMBER"}
return BillingState(logged_in=True, card=card, **opts)
if name in ("billing-off", "billing_off", "off"):
opts = {**common, "cli_billing_enabled": False}
return BillingState(logged_in=True, card=None, **opts)
# Unknown name → logged-out so the misconfiguration is visible.
return BillingState(logged_in=False, error=f"unknown HERMES_DEV_BILLING_FIXTURE: {name}")
# =============================================================================
# Idempotency
# =============================================================================
+75 -425
View File
@@ -30,15 +30,12 @@ from hermes_cli.timeouts import get_provider_request_timeout, get_provider_stale
from hermes_constants import PARTIAL_STREAM_STUB_ID, FINISH_REASON_LENGTH
from agent.error_classifier import FailoverReason
from agent.errors import EmptyStreamError
from agent.turn_context import substitute_api_content
from agent.gemini_native_adapter import is_native_gemini_base_url
from agent.model_metadata import is_local_endpoint
from agent.message_content import flatten_message_text
from agent.message_sanitization import (
_sanitize_surrogates,
_repair_tool_call_arguments,
)
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
from tools.terminal_tool import is_persistent_env
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
@@ -266,106 +263,6 @@ def _check_stale_giveup(agent) -> None:
)
def _derive_stream_stale_timeout(agent, api_kwargs: dict) -> float:
"""Stale-stream patience for a provider that is never a local endpoint.
Mirrors the main streaming path's derivation — provider config → env base
context-size scaling reasoning-model floor minus the local-endpoint
``float('inf')``/900s disable branch, which cannot apply to Bedrock (its
endpoint is always the AWS cloud). Factored so the Bedrock streaming
watchdog shares the exact same patience budget as the OpenAI/Anthropic
stale-stream detector below.
"""
_cfg_stale = get_provider_stale_timeout(agent.provider, agent.model)
if _cfg_stale is not None:
_base = _cfg_stale
else:
_base = env_float("HERMES_STREAM_STALE_TIMEOUT", 180.0)
_est_tokens = estimate_request_context_tokens(api_kwargs)
if _est_tokens > 100_000:
_timeout = max(_base, 300.0)
elif _est_tokens > 50_000:
_timeout = max(_base, 240.0)
else:
_timeout = _base
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
# Resolve the model id from BOTH the OpenAI/Anthropic key (``model``) and
# the Bedrock key (``modelId``). OpenAI/Anthropic wins first via the ``or``
# chain, so those paths are unchanged. Bedrock carries the model as a
# dotted, region-prefixed inference-profile id (e.g.
# ``us.anthropic.claude-opus-4-6-v1:0``) that the floor's start-of-slug
# regex cannot match directly — normalize it to a canonical slug first.
_model_id = api_kwargs.get("model") or api_kwargs.get("modelId") or ""
_reasoning_floor = get_reasoning_stale_timeout_floor(_model_id)
if _reasoning_floor is None and api_kwargs.get("modelId"):
_reasoning_floor = _bedrock_reasoning_stale_floor(api_kwargs["modelId"])
if _reasoning_floor is not None:
_timeout = max(_timeout, _reasoning_floor)
return _timeout
def _bedrock_reasoning_stale_floor(model_id: object) -> "float | None":
"""Map a Bedrock inference-profile id to its reasoning stale-timeout floor.
Bedrock carries the model as a dotted, region-prefixed id such as
``us.anthropic.claude-opus-4-6-v1:0``, whereas
:func:`get_reasoning_stale_timeout_floor` anchors its slug patterns at the
start of a bare slug (``claude-opus-4``). Strip the region prefix
(``us.``/``eu.``/``apac.``/...) and try two candidate slugs against the
floor:
* the segment after the provider namespace (``claude-opus-4-6-v1:0``)
matches Anthropic-style slugs whose floor key excludes the provider
(``claude-opus-4``); and
* the region-stripped id with the provider dot rewritten to a dash
(``deepseek-r1-v1:0``) matches provider-qualified floor keys
(``deepseek-r1``).
The floor's right-anchor (``$`` or ``-``/``.``/``_``) tolerates the
trailing date-stamp / ``-v1:0`` version suffix, so no suffix stripping is
needed. First non-None wins; returns None for unknown models.
The floor table mixes version-separator conventions: some keys are
keyed with a dashed version (``claude-opus-4``) while others embed a
dotted version (``claude-sonnet-4.5``, ``claude-sonnet-4.6``). Bedrock
always dashes the version (``claude-sonnet-4-5-v1:0``), so for every
candidate slug we also try the alternate version-separator form
digit-dash-digit rewritten to digit-dot-digit and vice-versa so a
dashed Bedrock id matches a dotted floor key (and the reverse). The
rewrite only touches version-number separators (a dash/dot flanked by
digits), never other dashes in the slug, so ``claude-sonnet`` is left
intact while ``4-5`` becomes ``4.5``.
"""
from agent.reasoning_timeouts import get_reasoning_stale_timeout_floor
if not model_id or not isinstance(model_id, str):
return None
name = model_id.strip().lower()
for prefix in ("us.", "eu.", "apac.", "ap.", "global.", "jp."):
if name.startswith(prefix):
name = name[len(prefix):]
break
base_candidates = [name]
if "." in name:
base_candidates.append(name.rsplit(".", 1)[1]) # claude-opus-4-6-v1:0
base_candidates.append(name.replace(".", "-", 1)) # deepseek-r1-v1:0
candidates: list[str] = []
for cand in base_candidates:
# Try the slug as-is plus both alternate version-separator forms.
# ``4-5`` <-> ``4.5`` only; a dash/dot not flanked by digits is
# left alone (e.g. ``claude-sonnet`` stays dashed).
dashed_to_dotted = re.sub(r"(?<=\d)-(?=\d)", ".", cand)
dotted_to_dashed = re.sub(r"(?<=\d)\.(?=\d)", "-", cand)
for form in (cand, dashed_to_dotted, dotted_to_dashed):
if form not in candidates:
candidates.append(form)
for cand in candidates:
floor = get_reasoning_stale_timeout_floor(cand)
if floor is not None:
return floor
return None
def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
"""Run one non-streaming LLM request for the active api_mode and return it.
@@ -373,14 +270,13 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
inline path (``direct_api_call``) so the per-api_mode dispatch codex /
anthropic / bedrock / MoA / OpenAI-compatible lives in exactly one place.
``make_client(reason, kind=...)`` builds the per-request client for the
codex / OpenAI-compatible (``kind="openai"``) and anthropic
(``kind="anthropic_messages"``) branches; the worker path uses it to
register the client with its stranger-thread abort machinery, the inline
path uses it to capture the client for its own ``finally`` close. The
bedrock / MoA branches manage their own clients and never call it. All
interrupt, abort, cancellation, and close semantics stay in the callers
this helper only issues the request.
``make_client(reason)`` builds the per-request OpenAI client for the codex
and OpenAI-compatible branches; the worker path uses it to register the
client with its stranger-thread abort machinery, the inline path uses it to
capture the client for its own ``finally`` close. The anthropic / bedrock /
MoA branches manage their own clients and never call it. All interrupt,
abort, cancellation, and close semantics stay in the callers this helper
only issues the request.
"""
if agent.api_mode == "codex_responses":
request_client = make_client("codex_stream_request")
@@ -390,13 +286,7 @@ def _dispatch_nonstreaming_api_request(agent, api_kwargs: dict, *, make_client):
on_first_delta=getattr(agent, "_codex_on_first_delta", None),
)
if agent.api_mode == "anthropic_messages":
# #67142: use a request-local Anthropic client so the stale/interrupt
# watchdog aborts sockets from the stranger thread while the worker
# owns the SDK close — never closing the shared client mid-flight.
request_client = make_client(
"anthropic_messages_request", kind="anthropic_messages"
)
return agent._anthropic_messages_create(api_kwargs, client=request_client)
return agent._anthropic_messages_create(api_kwargs)
if agent.api_mode == "bedrock_converse":
# Bedrock uses boto3 directly — no OpenAI client needed.
# normalize_converse_response produces an OpenAI-compatible
@@ -466,11 +356,7 @@ def direct_api_call(agent, api_kwargs: dict):
if request_client is not None:
agent._abort_request_openai_client(request_client, reason=reason)
def _make_client(reason: str, kind: str = "openai"):
# direct_api_call only runs for OpenAI-wire chat_completions cron
# requests (see should_use_direct_api_call), so the anthropic branch of
# the dispatch — the only caller that passes kind — is never reached
# here; the ``kind`` parameter exists purely for signature parity.
def _make_client(reason: str):
client = agent._create_request_openai_client(reason=reason, api_kwargs=api_kwargs)
with request_client_lock:
request_client_holder["client"] = client
@@ -529,10 +415,6 @@ def interruptible_api_call(agent, api_kwargs: dict):
_check_stale_giveup(agent)
request_client_holder = {"client": None, "owner_tid": None}
# Transport kind of the registered request client ("openai" or
# "anthropic_messages") so _close_request_client_once routes to the right
# abort/close helpers (#67142).
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag. Distinct from agent._interrupt_requested
# because that flag is cleared at run_conversation() turn boundaries, but
@@ -544,10 +426,9 @@ def interruptible_api_call(agent, api_kwargs: dict):
# hang.)
_request_cancelled = {"value": False}
def _set_request_client(client, *, kind: str = "openai"):
def _set_request_client(client):
with request_client_lock:
request_client_holder["client"] = client
request_client_kind["value"] = kind
# #29507: stamp the owning thread so a stranger-thread interrupt
# only shuts the connection down rather than racing the worker
# for FD ownership during ``client.close()``.
@@ -581,34 +462,24 @@ def interruptible_api_call(agent, api_kwargs: dict):
request_client_holder["owner_tid"] = None
if request_client is None:
return
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
if stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
def _call():
try:
# _set_request_client registers each per-request client with the
# stranger-thread abort machinery above; the shared dispatch helper
# builds it via this callback (openai- or anthropic-kind) so the
# interrupt / stale-call detectors can force-close the worker's
# connection without touching the shared client (#67142).
# _set_request_client registers each per-request OpenAI client with
# the stranger-thread abort machinery above; the shared dispatch
# helper builds it via this callback so the interrupt / stale-call
# detectors can force-close the worker's connection.
result["response"] = _dispatch_nonstreaming_api_request(
agent,
api_kwargs,
make_client=lambda reason, kind="openai": _set_request_client(
agent._create_request_anthropic_client(reason=reason)
if kind == "anthropic_messages"
else agent._create_request_openai_client(
make_client=lambda reason: _set_request_client(
agent._create_request_openai_client(
reason=reason, api_kwargs=api_kwargs
),
kind=kind,
)
),
)
except Exception as e:
@@ -920,10 +791,11 @@ def interruptible_api_call(agent, api_kwargs: dict):
f"Aborting call."
)
try:
# #67142: routes by client kind — anthropic now aborts the
# request-local client's sockets from this poll (stranger)
# thread instead of closing the shared _anthropic_client.
_close_request_client_once("stale_call_kill")
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("stale_call_kill")
except Exception:
pass
# Circuit breaker (#58962): count the stale kill. See the
@@ -959,12 +831,13 @@ def interruptible_api_call(agent, api_kwargs: dict):
)
# Force-close the in-flight worker-local HTTP connection to stop
# token generation without poisoning the shared client used to
# seed future retries. #67142: for anthropic this aborts the
# request-local client's sockets from this poll (stranger) thread
# rather than closing the shared _anthropic_client, which could
# release a TLS FD mid-SSL-BIO and corrupt an unrelated SQLite DB.
# seed future retries.
try:
_close_request_client_once("interrupt_abort")
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("interrupt_abort")
except Exception:
pass
raise InterruptedError("Agent interrupted during API call")
@@ -1252,7 +1125,7 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
# reasoning fields are present (some models/providers embed thinking
# directly in the content rather than returning separate API fields).
if not reasoning_text:
content = flatten_message_text(getattr(assistant_message, "content", None))
content = assistant_message.content or ""
think_blocks = re.findall(r'<think>(.*?)</think>', content, flags=re.DOTALL)
if think_blocks:
combined = "\n\n".join(b.strip() for b in think_blocks if b.strip())
@@ -1278,7 +1151,7 @@ def build_assistant_message(agent, assistant_message, finish_reason: str) -> dic
# Sanitize surrogates from API response — some models (e.g. Kimi/GLM via Ollama)
# can return invalid surrogate code points that crash json.dumps() on persist.
_raw_content = flatten_message_text(getattr(assistant_message, "content", None))
_raw_content = assistant_message.content or ""
_san_content = _sanitize_surrogates(_raw_content)
if reasoning_text:
reasoning_text = _sanitize_surrogates(reasoning_text)
@@ -1930,15 +1803,6 @@ def handle_max_iterations(agent, messages: list, api_call_count: int) -> str:
# and every Hermes-internal underscore-prefixed scaffolding key.
for schema_foreign in ("tool_name", "codex_reasoning_items", "codex_message_items", "timestamp"):
api_msg.pop(schema_foreign, None)
# api_content (the persist-what-you-send sidecar) carries the
# exact bytes every main-loop call sent for this message —
# substitute it before dropping the key (Hermes bookkeeping,
# never a provider field), mirroring the loop's api_messages
# build. Popping without substituting would send CLEAN content
# here, diverging the summary request's prefix at the EARLIEST
# sidecar-carrying message and re-prefilling the whole transcript
# at exactly the moment the context is largest.
substitute_api_content(api_msg)
for internal_key in [k for k in api_msg if isinstance(k, str) and k.startswith("_")]:
api_msg.pop(internal_key, None)
if _needs_sanitize:
@@ -2159,11 +2023,6 @@ def cleanup_task_resources(agent, task_id: str) -> None:
``terminal.lifetime_seconds`` is exceeded. Non-persistent backends are
torn down per-turn as before to prevent resource leakage (the original
intent of this hook for the Morph backend, see commit fbd3a2fd).
Skips ``cleanup_browser`` in headed mode so the browser window stays
visible between turns. The inactivity reaper in
``browser_tool._cleanup_inactive_browser_sessions`` still handles
idle sessions.
"""
try:
if is_persistent_env(task_id):
@@ -2178,20 +2037,7 @@ def cleanup_task_resources(agent, task_id: str) -> None:
if agent.verbose_logging:
logger.warning(f"Failed to cleanup VM for task {task_id}: {e}")
try:
headed = False
try:
from tools.browser_tool import _is_headed_mode
headed = _is_headed_mode()
except Exception:
headed = bool(os.environ.get("AGENT_BROWSER_HEADED"))
if headed:
if agent.verbose_logging:
logging.debug(
f"Skipping per-turn cleanup_browser for headed session {task_id}; "
f"idle reaper will handle it."
)
else:
_ra().cleanup_browser(task_id)
_ra().cleanup_browser(task_id)
except Exception as e:
if agent.verbose_logging:
logger.warning(f"Failed to cleanup browser for task {task_id}: {e}")
@@ -2244,24 +2090,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
result = {"response": None, "error": None}
first_delta_fired = {"done": False}
deltas_were_sent = {"yes": False}
# Wire-level liveness for the boto3 converse_stream worker: the worker
# thread blocks inside ``for event in event_stream`` with NO read
# timeout, so a provider that opens the stream then stops yielding
# events wedges the thread forever. on_event stamps this on EVERY
# yielded Bedrock event (text/tool/metadata) — the poll loop below
# trips a watchdog when the gap exceeds the stale timeout.
_bedrock_last_event = {"t": time.time()}
# Region captured for the poll-loop client eviction below. Read
# (not popped) here so the worker's own pop inside _bedrock_call still
# resolves the same value.
_bedrock_region = api_kwargs.get("__bedrock_region__", "us-east-1")
# Same patience budget as the OpenAI/Anthropic stale detector.
_bedrock_stale_timeout = _derive_stream_stale_timeout(agent, api_kwargs)
# Cross-turn stale-stream circuit breaker (#58962): a pre-elevated
# streak from prior wedged turns aborts before we even start — mirrors
# the entry check on the OpenAI/Anthropic path below.
_check_stale_giveup(agent)
def _fire_first():
if not first_delta_fired["done"] and on_first_delta:
@@ -2318,7 +2146,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Claim the delta sink for this bedrock stream (#65991) so a
# superseded attempt's callbacks are fenced by the sink guard.
claim_stream_writer(agent)
agent._claim_stream_writer()
def _on_text(text):
_fire_first()
@@ -2339,7 +2167,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
on_tool_start=_on_tool,
on_reasoning_delta=_on_reasoning if agent.reasoning_callback or agent.stream_delta_callback else None,
on_interrupt_check=lambda: agent._interrupt_requested,
on_event=lambda: _bedrock_last_event.__setitem__("t", time.time()),
)
except Exception as e:
result["error"] = e
@@ -2350,56 +2177,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
t.join(timeout=0.3)
if agent._interrupt_requested:
raise InterruptedError("Agent interrupted during Bedrock API call")
# Liveness watchdog: no Bedrock event for longer than the stale
# timeout means the stream has wedged (open socket, keep-alives but
# no data, or a silently hung provider). Without this the worker
# blocks in ``for event in event_stream`` indefinitely.
_stale_elapsed = time.time() - _bedrock_last_event["t"]
if _stale_elapsed > _bedrock_stale_timeout:
logger.warning(
"Bedrock stream stale for %.0fs (threshold %.0fs) — no events "
"received. region=%s model=%s. Aborting call.",
_stale_elapsed, _bedrock_stale_timeout,
_bedrock_region, api_kwargs.get("modelId", "unknown"),
)
agent._buffer_status(
f"⚠️ No events from Bedrock for {int(_stale_elapsed)}s "
f"(model: {api_kwargs.get('modelId', 'unknown')}). Aborting..."
)
# Count the stale kill in the SAME cross-turn breaker as the
# OpenAI/Anthropic path (#58962).
_bump_stale_streak(agent)
# Best-effort: evict the region's cached bedrock-runtime client
# so the NEXT call reconnects with a fresh pool. NOTE: this does
# NOT abort the in-flight botocore EventStream the worker thread
# is blocked on — botocore exposes no external cancellation for
# it — so the daemon worker keeps reading until its socket read
# ultimately errors. We therefore end THIS call by raising
# below and let the streak+give-up breaker escalate across turns.
try:
from agent.bedrock_adapter import invalidate_runtime_client
invalidate_runtime_client(_bedrock_region)
except Exception as _inval_exc:
logger.debug(
"bedrock: stale client eviction failed: %s", _inval_exc
)
# Reset the timer so a repeated trip (should the worker somehow
# survive) waits a fresh interval rather than re-firing instantly.
_bedrock_last_event["t"] = time.time()
# Escalate across turns: raises RuntimeError once the streak
# crosses HERMES_STREAM_STALE_GIVEUP, so a persistently wedged
# Bedrock provider aborts fast instead of re-waiting the timeout.
_check_stale_giveup(agent)
# Streak still under the give-up threshold: end THIS call with a
# TimeoutError so the outer retry loop / next turn re-evaluates
# and the streak carries forward. Break rather than keep polling
# a worker we cannot abort.
result["error"] = TimeoutError(
f"Bedrock stream produced no events for {int(_stale_elapsed)}s "
f"(threshold {int(_bedrock_stale_timeout)}s) — aborting stalled "
f"stream so the retry/fallback path can recover."
)
break
# Worker exited before the poll loop observed the interrupt flag. The
# Bedrock stream callback breaks out and returns a PARTIAL response
# without raising on interrupt (see bedrock_adapter.py
@@ -2412,11 +2189,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
raise InterruptedError("Agent interrupted during Bedrock API call (post-worker)")
if result["error"] is not None:
raise result["error"]
# Success — clear the cross-turn breaker (#58962): Bedrock proved
# responsive. Mirrors the OpenAI/Anthropic success reset below so a
# recovered provider doesn't carry a stale streak into later turns.
if result["response"] is not None:
_reset_stale_streak(agent)
return result["response"]
result = {"response": None, "error": None, "partial_tool_names": []}
@@ -2427,10 +2199,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
_check_stale_giveup(agent)
request_client_holder = {"client": None, "diag": None, "owner_tid": None}
# Transport kind of the registered request client — see the non-streaming
# variant. Routes _close_request_client_once to anthropic vs openai abort/
# close helpers (#67142).
request_client_kind = {"value": "openai"}
request_client_lock = threading.Lock()
# Request-local cancellation flag — see interruptible_api_call for the full
# rationale. The streaming retry loop is where the 7-minute cascading-
@@ -2441,10 +2209,9 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# exit immediately instead of retrying. (PR #6600.)
_request_cancelled = {"value": False}
def _set_request_client(client, *, kind: str = "openai"):
def _set_request_client(client):
with request_client_lock:
request_client_holder["client"] = client
request_client_kind["value"] = kind
# See #29507 explanation in the non-streaming variant above.
request_client_holder["owner_tid"] = threading.get_ident()
return client
@@ -2467,13 +2234,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
request_client_holder["owner_tid"] = None
if request_client is None:
return
kind = request_client_kind.get("value", "openai")
if kind == "anthropic_messages":
if stranger_thread:
agent._abort_request_anthropic_client(request_client, reason=reason)
else:
agent._close_request_anthropic_client(request_client, reason=reason)
elif stranger_thread:
if stranger_thread:
agent._abort_request_openai_client(request_client, reason=reason)
else:
agent._close_request_openai_client(request_client, reason=reason)
@@ -2492,68 +2253,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# resolved, so the builder degrades to its plain default if it ever runs
# first.
_stream_stale_timeout = None
stream_attempt_lock = threading.Lock()
stream_attempt_state = {
"current": 0,
"cancelled": set(),
"discarded_chunks": 0,
"discarded_bytes": 0,
}
def _start_stream_attempt() -> int:
with stream_attempt_lock:
stream_attempt_state["current"] += 1
return int(stream_attempt_state["current"])
def _cancel_current_stream_attempt(reason: str) -> None:
with stream_attempt_lock:
current = int(stream_attempt_state.get("current") or 0)
if current:
stream_attempt_state["cancelled"].add(current)
if current:
logger.debug(
"Marked stream attempt %s cancelled: %s",
current,
reason,
)
def _stream_attempt_is_active(stream_attempt_id: int) -> bool:
with stream_attempt_lock:
return (
stream_attempt_id == int(stream_attempt_state.get("current") or 0)
and stream_attempt_id not in stream_attempt_state["cancelled"]
)
def _stream_attempt_was_cancelled(stream_attempt_id: int) -> bool:
with stream_attempt_lock:
return stream_attempt_id in stream_attempt_state["cancelled"]
def _discard_stale_stream_chunk(stream_attempt_id: int, chunk) -> None:
try:
chunk_bytes = len(repr(chunk))
except Exception:
chunk_bytes = 0
with stream_attempt_lock:
stream_attempt_state["discarded_chunks"] += 1
stream_attempt_state["discarded_bytes"] += chunk_bytes
discarded_chunks = stream_attempt_state["discarded_chunks"]
discarded_bytes = stream_attempt_state["discarded_bytes"]
if discarded_chunks == 1:
logger.warning(
"Discarding chunk from superseded stream attempt %s "
"(discarded_chunks=%s discarded_bytes=%s)",
stream_attempt_id,
discarded_chunks,
discarded_bytes,
)
else:
logger.debug(
"Discarded stale stream chunk from attempt %s "
"(discarded_chunks=%s discarded_bytes=%s)",
stream_attempt_id,
discarded_chunks,
discarded_bytes,
)
def _fire_first_delta():
if not first_delta_fired["done"] and on_first_delta:
@@ -2563,7 +2262,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
except Exception:
pass
def _call_chat_completions(stream_attempt_id: int):
def _call_chat_completions():
"""Stream a chat completions response."""
import httpx as _httpx
# Per-provider / per-model request_timeout_seconds (from config.yaml)
@@ -2651,7 +2350,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# stream is somehow still alive (a stale-stream reconnect whose socket
# abort raced), this claim supersedes it so its late chunks are fenced
# out of the turn instead of interleaving with ours.
_writer_token = claim_stream_writer(agent)
_writer_token = agent._claim_stream_writer()
# Some OpenAI-compatible adapters (for example copilot-acp, and the MoA
# openai-codex aggregator) accept stream=True but still return a
@@ -2728,7 +2427,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# (#65991): this attempt has been superseded, so it must neither
# fire deltas (incl. the tool-suppressed raw-callback path below)
# nor keep consuming a stream that would interleave into the turn.
if not stream_writer_is_current(agent, _writer_token):
if not agent._stream_writer_is_current(_writer_token):
logger.warning(
"Streaming attempt superseded by a newer stream; stopping "
"consumption to preserve the single-writer invariant "
@@ -2760,10 +2459,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if agent._interrupt_requested:
break
if not _stream_attempt_is_active(stream_attempt_id):
_discard_stale_stream_chunk(stream_attempt_id, chunk)
continue
if not chunk.choices:
if hasattr(chunk, "model") and chunk.model:
model_name = chunk.model
@@ -2889,11 +2584,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
if hasattr(chunk, "usage") and chunk.usage:
usage_obj = chunk.usage
if _stream_attempt_was_cancelled(stream_attempt_id):
raise _httpx.RemoteProtocolError(
f"stream attempt {stream_attempt_id} was superseded"
)
# Build mock response matching non-streaming shape
full_content = "".join(content_parts) or None
mock_tool_calls = None
@@ -3021,18 +2711,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
usage=usage_obj,
)
def _call_anthropic(request_client):
def _call_anthropic():
"""Stream an Anthropic Messages API response.
Fires delta callbacks for real-time token delivery, but returns
the native Anthropic Message object from get_final_message() so
the rest of the agent loop (validation, tool extraction, etc.)
works unchanged.
Uses ``request_client`` (a per-request Anthropic client registered with
the stranger-thread abort machinery) rather than the shared
``_anthropic_client``, so the stale/interrupt watchdog can abort this
stream's socket without closing the shared client mid-flight (#67142).
"""
has_tool_use = False
# Zero-event guard parity with the chat_completions path: track
@@ -3061,7 +2746,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
api_kwargs, log_prefix=getattr(agent, "log_prefix", "")
)
# Use the Anthropic SDK's streaming context manager
with request_client.messages.stream(**api_kwargs) as stream:
with agent._anthropic_client.messages.stream(**api_kwargs) as stream:
# The Anthropic SDK exposes the raw httpx response on
# ``stream.response``. Snapshot diagnostic headers
# immediately so they survive a stream that dies before the
@@ -3074,11 +2759,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
pass
# Claim the delta sink for THIS attempt (#65991) — parity with the
# chat_completions path so a superseded anthropic stream is fenced.
_writer_token = claim_stream_writer(agent)
_writer_token = agent._claim_stream_writer()
for event in stream:
# Bail the instant a newer attempt supersedes this one so a
# stale stream can't interleave tokens into the turn.
if not stream_writer_is_current(agent, _writer_token):
if not agent._stream_writer_is_current(_writer_token):
logger.warning(
"Anthropic streaming attempt superseded by a newer "
"stream; stopping consumption to preserve the "
@@ -3187,7 +2872,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
try:
for _stream_attempt in range(_max_stream_retries + 1):
stream_attempt_id = _start_stream_attempt()
# Check for interrupt before each retry attempt. Without
# this, /stop closes the HTTP connection (outer poll loop),
# but the retry loop opens a FRESH connection — negating the
@@ -3195,22 +2879,13 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# retry can block for the full stream-read timeout (120s+),
# causing multi-minute delays between /stop and response.
if agent._interrupt_requested:
_cancel_current_stream_attempt("interrupt_before_stream_retry")
raise InterruptedError("Agent interrupted before stream retry")
try:
if agent.api_mode == "anthropic_messages":
# #67142: per-request client (credential refresh happens
# inside _create_request_anthropic_client) registered so
# the watchdog aborts its socket, not the shared client.
request_client = _set_request_client(
agent._create_request_anthropic_client(
reason="anthropic_stream_request"
),
kind="anthropic_messages",
)
result["response"] = _call_anthropic(request_client)
agent._try_refresh_anthropic_client_credentials()
result["response"] = _call_anthropic()
else:
result["response"] = _call_chat_completions(stream_attempt_id)
result["response"] = _call_chat_completions()
return # success
except Exception as e:
# If the main poll loop force-closed this request because
@@ -3332,13 +3007,14 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
mid_tool_call=True,
diag=request_client_holder.get("diag"),
)
_cancel_current_stream_attempt("stream_mid_tool_retry_cleanup")
_close_request_client_once("stream_mid_tool_retry_cleanup")
# #67142: anthropic streams on a request-local client,
# already worker-owned-closed by _close_request_client_once
# above; the next attempt builds a fresh one. The shared
# _anthropic_client is never closed from inside a request.
if agent.api_mode != "anthropic_messages":
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
try:
agent._replace_primary_openai_client(
reason="stream_mid_tool_retry_pool_cleanup"
@@ -3395,15 +3071,16 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
diag=request_client_holder.get("diag"),
)
# Close the stale request client before retry
_cancel_current_stream_attempt("stream_retry_cleanup")
_close_request_client_once("stream_retry_cleanup")
# Also rebuild the primary client to purge any dead
# connections from the pool. #67142: anthropic uses a
# request-local client (already worker-owned-closed
# above; next attempt builds fresh), so the shared
# _anthropic_client is never closed from inside a
# request — only the OpenAI-wire primary is refreshed.
if agent.api_mode != "anthropic_messages":
# Also rebuild the primary client to purge
# any dead connections from the pool.
if agent.api_mode == "anthropic_messages":
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
try:
agent._replace_primary_openai_client(
reason="stream_retry_pool_cleanup"
@@ -3518,34 +3195,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
else:
_stream_stale_timeout_base = env_float("HERMES_STREAM_STALE_TIMEOUT", 180.0)
# Local providers (Ollama, oMLX, llama-cpp) can take 300+ seconds
# for prefill on large contexts, so tolerate far longer silence than
# the cloud default — but a wedged local server must EVENTUALLY trip the
# detector rather than hang forever (an infinite timeout meant a crashed
# or deadlocked local endpoint stalled the session indefinitely). 900s
# tolerates slow prefill while still bounding a hung endpoint. Applies
# unless the user explicitly set HERMES_STREAM_STALE_TIMEOUT; override the
# local ceiling with HERMES_LOCAL_STREAM_STALE_TIMEOUT (documented in
# website/docs/reference/environment-variables.md).
# for prefill on large contexts. Disable the stale detector unless
# the user explicitly set HERMES_STREAM_STALE_TIMEOUT.
if _stream_stale_timeout_base == 180.0 and agent.base_url and is_local_endpoint(agent.base_url):
# Read config.yaml ``agent.local_stream_stale_timeout`` (default 900),
# env var ``HERMES_LOCAL_STREAM_STALE_TIMEOUT`` overrides for escape-hatch.
_local_default = 900.0
try:
from hermes_cli.config import load_config
_cfg = load_config()
_agent_cfg = _cfg.get("agent") if isinstance(_cfg, dict) else None
if isinstance(_agent_cfg, dict):
_v = _agent_cfg.get("local_stream_stale_timeout")
if isinstance(_v, (int, float)):
_local_default = float(_v)
except Exception:
pass
_stream_stale_timeout = env_float("HERMES_LOCAL_STREAM_STALE_TIMEOUT", _local_default)
logger.debug(
"Local provider detected (%s) — stale stream timeout set to %.0fs",
agent.base_url, _stream_stale_timeout,
)
_stream_stale_timeout = float("inf")
logger.debug("Local provider detected (%s) — stale stream timeout disabled", agent.base_url)
else:
# Scale the stale timeout for large contexts: slow models (like Opus)
# can legitimately think for minutes before producing the first token
@@ -3633,7 +3287,6 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
f"Reconnecting..."
)
try:
_cancel_current_stream_attempt("stale_stream_kill")
_close_request_client_once("stale_stream_kill")
except Exception:
pass
@@ -3643,14 +3296,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
# Rebuild the primary client too — its connection pool
# may hold dead sockets from the same provider outage.
if agent.api_mode == "anthropic_messages":
# #67142: the stale stream ran on a request-local anthropic
# client, already socket-aborted above via
# _close_request_client_once (which unblocks the worker and
# preserves the #28161 no-hang guarantee). The shared
# _anthropic_client is NOT the in-flight transport, so we must
# not close it from this poll (stranger) thread — that was the
# FD-recycle corruption vector. Nothing further is needed.
pass
try:
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
except Exception:
pass
else:
try:
agent._replace_primary_openai_client(reason="stale_stream_pool_cleanup")
@@ -3678,11 +3328,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
"(not a network error)."
)
try:
_cancel_current_stream_attempt("stream_interrupt_abort")
# #67142: kind-aware — anthropic aborts the request-local
# client's socket from this poll thread; the shared
# _anthropic_client is never closed here.
_close_request_client_once("stream_interrupt_abort")
if agent.api_mode == "anthropic_messages":
agent._anthropic_client.close()
agent._rebuild_anthropic_client()
else:
_close_request_client_once("stream_interrupt_abort")
except Exception:
pass
raise InterruptedError("Agent interrupted during streaming API call")
+22 -64
View File
@@ -23,8 +23,6 @@ import time
from types import SimpleNamespace
from typing import Any, Callable, Dict, List
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
logger = logging.getLogger(__name__)
@@ -456,27 +454,6 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
# even when codex doesn't report durationMs.
started: dict[str, tuple[str, dict, float]] = {}
def _stable_call_id(item: dict, name: str) -> str:
"""Deterministic tool_call id mirroring CodexEventProjector, so a
live TUI tool card correlates with the same tool call after the
session is resumed and history is projected."""
from agent.transports.codex_event_projector import _deterministic_call_id
item_id = item.get("id") or ""
item_type = item.get("type") or ""
if item_type == "commandExecution":
return _deterministic_call_id("exec", item_id)
if item_type == "fileChange":
return _deterministic_call_id("apply_patch", item_id)
if item_type == "mcpToolCall":
server = item.get("server") or "mcp"
tool = item.get("tool") or "unknown"
return _deterministic_call_id(f"mcp__{server}__{tool}", item_id)
if item_type == "dynamicToolCall":
tool = item.get("tool") or "unknown"
return _deterministic_call_id(f"dyn_{tool}", item_id)
return _deterministic_call_id(name, item_id)
def _fire_tool_started(item: dict) -> None:
item_id = item.get("id") or ""
name = _codex_item_to_tool_name(item)
@@ -484,26 +461,15 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
if item_id:
started[item_id] = (name, args, time.monotonic())
cb = getattr(agent, "tool_progress_callback", None)
if cb is not None:
try:
cb("tool.started", name, _codex_item_to_preview(item), args)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.started for %s",
name, exc_info=True,
)
# Authoritative stable-ID tool card (TUI / desktop). Fires
# alongside tool_progress so surfaces that render structured tool
# cards (not just progress bubbles) stay correlated with the
# projected history entry after a resume.
start_cb = getattr(agent, "tool_start_callback", None)
if start_cb is not None:
try:
start_cb(_stable_call_id(item, name), name, args)
except Exception:
logger.debug(
"tool_start_callback raised for %s", name, exc_info=True,
)
if cb is None:
return
try:
cb("tool.started", name, _codex_item_to_preview(item), args)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.started for %s",
name, exc_info=True,
)
def _fire_tool_completed(item: dict) -> None:
item_id = item.get("id") or ""
@@ -521,24 +487,16 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
duration = time.monotonic() - prior[2]
result, is_error = _codex_item_completion_payload(item)
cb = getattr(agent, "tool_progress_callback", None)
if cb is not None:
try:
cb("tool.completed", name, None, None,
duration=duration, is_error=is_error, result=result)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.completed for %s",
name, exc_info=True,
)
complete_cb = getattr(agent, "tool_complete_callback", None)
if complete_cb is not None:
args = prior[1] if prior is not None else _codex_item_to_args(item)
try:
complete_cb(_stable_call_id(item, name), name, args, result)
except Exception:
logger.debug(
"tool_complete_callback raised for %s", name, exc_info=True,
)
if cb is None:
return
try:
cb("tool.completed", name, None, None,
duration=duration, is_error=is_error, result=result)
except Exception:
logger.debug(
"tool_progress_callback raised on tool.completed for %s",
name, exc_info=True,
)
def _fire_text_delta(params: dict) -> None:
text = params.get("delta") or params.get("text") or ""
@@ -593,7 +551,7 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
if method == "item/agentMessage/delta":
_fire_text_delta(params)
return
if method in {"item/reasoning/delta", "item/reasoning/summaryDelta"}:
if method == "item/reasoning/delta":
_fire_reasoning_delta(params)
return
item = params.get("item")
@@ -1232,12 +1190,12 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
# late deltas are fenced out of the turn; conversely, a newer
# attempt supersedes us and the interrupt_check below stops our
# consumption immediately.
_writer_token = claim_stream_writer(agent)
_writer_token = agent._claim_stream_writer()
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
if agent._interrupt_requested:
return True
if not stream_writer_is_current(agent, _tok):
if not agent._stream_writer_is_current(_tok):
logger.warning(
"Codex streaming attempt superseded by a newer stream; "
"stopping consumption to preserve the single-writer "
+9 -85
View File
@@ -33,7 +33,6 @@ from agent.model_metadata import (
estimate_messages_tokens_rough,
)
from agent.redact import redact_sensitive_text
from agent.turn_context import drop_stale_api_content
logger = logging.getLogger(__name__)
@@ -299,7 +298,6 @@ _FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
_AUTO_FOCUS_MAX_CHARS = 700
_ACTIVE_TASK_MAX_CHARS = 1400
# Keep a short run of recent messages verbatim even when the token budget is
# already exhausted. The public ``protect_last_n`` default is intentionally
# high for small/light tails, but using all 20 as a hard floor here would bring
@@ -323,9 +321,6 @@ _PATH_MENTION_RE = re.compile(r"(?:/|~/?|[A-Za-z]:\\)[^\s`'\")\]}<>]+")
# the summary, the downstream model may re-emit it as an active directive on
# the next turn, triggering bogus attachment sends (#14665).
_MEDIA_DIRECTIVE_RE = re.compile(r"MEDIA:\S+")
_HISTORICAL_TASK_SECTION_RE = re.compile(
rf"(?ms)^{re.escape(HISTORICAL_TASK_HEADING)}\s*\n.*?(?=^## |\Z)"
)
def _dedupe_append(items: list[str], value: str, *, limit: int) -> None:
@@ -660,9 +655,6 @@ def _strip_historical_media(messages: List[Dict[str, Any]]) -> List[Dict[str, An
continue
new_msg = msg.copy()
new_msg["content"] = _strip_images_from_content(content)
# Content rewritten → the api_content sidecar (exact bytes previously
# sent) is stale; drop it so replay can't resend the pre-rewrite bytes.
drop_stale_api_content(new_msg)
result.append(new_msg)
changed = True
@@ -2150,9 +2142,9 @@ Summary generation was unavailable, so this is a best-effort deterministic fallb
_template_sections = f"""{HISTORICAL_TASK_HEADING}
[THE SINGLE MOST IMPORTANT FIELD. Capture the user's most recent unfulfilled
input verbatim the exact words they used. This includes:
- Explicit task assignments ("<specific user task>")
- Questions awaiting an answer ("<specific user question>")
- Decisions awaiting input ("<option A or B?>")
- Explicit task assignments ("refactor the auth module")
- Questions awaiting an answer ("waarom staat X op Y?", "wat zijn de volgende stappen?")
- Decisions awaiting input ("optie A of B?")
- Ongoing discussions where the assistant owes the next substantive reply
A conversation where the user just asked a question IS an active task the
task is "answer that question with full context". Do NOT write "None" merely
@@ -2160,15 +2152,15 @@ because the user did not issue an imperative command; reserve "None" for the
rare case where the last exchange was fully resolved and the user said
something like "thanks, that's all".
If multiple items are outstanding, list only the ones NOT yet completed.
This historical snapshot must identify the latest unresolved user input precisely. Examples:
"User asked: '<exact latest user request>'"
"User asked: '<exact latest user question>' — needs investigation + answer"
"User chose <option>; awaiting implementation of <specific next step>"
Continuation should pick up exactly here. Examples:
"User asked: 'Now refactor the auth module to use JWT instead of sessions'"
"User asked: 'Waarom stond provider ineens op openrouter?' — needs investigation + answer"
"User chose option A; awaiting implementation of step 2"
If the user's most recent message was a reverse signal (stop, undo, roll
back, never mind, just verify, change of topic) that supersedes earlier
work, write the reverse signal verbatim and DO NOT carry forward the
cancelled task. Example: "User asked: '<exact reverse signal>' — earlier
in-flight work is cancelled."
cancelled task. Example: "User asked: 'Stop the i18n refactor and just
verify the current diff' — earlier i18n in-flight work is cancelled."
If no outstanding task exists, write "None."]
## Goal
@@ -2330,7 +2322,6 @@ This compaction should PRIORITISE preserving all information related to the focu
# Redact the summary output as well — the summarizer LLM may
# ignore prompt instructions and echo back secrets verbatim.
summary = redact_sensitive_text(content.strip())
summary = self._ground_historical_task_snapshot(summary, turns_to_summarize)
# Store for iterative updates on next compaction
self._previous_summary = summary
self._clear_compression_failure_cooldown()
@@ -2602,69 +2593,6 @@ This compaction should PRIORITISE preserving all information related to the focu
focus = focus[: _AUTO_FOCUS_MAX_CHARS - 1].rstrip() + ""
return focus
@classmethod
def _latest_user_task_snapshot(
cls,
messages: List[Dict[str, Any]],
) -> Optional[str]:
"""Return a deterministic task-snapshot line from the newest real user turn.
The LLM summarizer is allowed to compress prose, but it must not invent
the "what is the active task?" anchor from a prompt example or stale
prior summary. This helper extracts the anchor locally from the exact
compacted turns so the summary can be grounded before it becomes live
context.
"""
# Reuse the runtime's real-user predicate so the deterministic
# snapshot can never anchor on user-role scaffolding (todo
# snapshots, truncation notices, background-process reports) —
# the exact class of turn this grounding exists to bypass.
from agent.conversation_compression import _is_real_user_message
for msg in reversed(messages):
if msg.get("role") != "user":
continue
if not _is_real_user_message(msg):
continue
content = msg.get("content")
text = redact_sensitive_text(_content_text_for_contains(content).strip())
if not text:
continue
text = re.sub(r"\s+", " ", text)
if len(text) > _ACTIVE_TASK_MAX_CHARS:
text = text[: _ACTIVE_TASK_MAX_CHARS - 15].rstrip() + " ...[truncated]"
return (
f"User asked (deterministic, from compacted turns): {text!r}\n"
"Historical only; newer protected-tail messages after this summary win."
)
return None
@classmethod
def _ground_historical_task_snapshot(
cls,
summary: str,
messages: List[Dict[str, Any]],
) -> str:
"""Force the task snapshot section to match a real user turn when possible."""
snapshot = cls._latest_user_task_snapshot(messages)
if not snapshot:
return summary
body = cls._strip_summary_prefix(summary)
# Keep the section terminated with a blank line: re.sub consumes the
# section's trailing newlines, and without restoring them the next
# "## " heading is glued onto the snapshot line — corrupting the
# markdown and making the heading invisible to this same regex on the
# next iterative compaction (which would then delete every following
# section via the \Z branch).
replacement = f"{HISTORICAL_TASK_HEADING}\n{snapshot}\n\n"
if _HISTORICAL_TASK_SECTION_RE.search(body):
grounded = _HISTORICAL_TASK_SECTION_RE.sub(
lambda _m: replacement, body, count=1
)
return grounded.strip()
return f"{replacement}{body}".strip()
@classmethod
def _find_latest_context_summary(
cls,
@@ -3536,10 +3464,6 @@ This compaction should PRIORITISE preserving all information related to the focu
# Mark the merged message so frontends can identify it as
# containing a compression summary prefix.
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
# Content rewritten → the api_content sidecar (exact bytes
# previously sent) is stale; drop it so replay can't resend
# the pre-merge bytes without the summary.
drop_stale_api_content(msg)
_merge_summary_into_tail = False
compressed.append(msg)
+31 -171
View File
@@ -415,147 +415,43 @@ def conversation_history_after_compression(agent: Any, messages: list) -> Option
return None
_SYNTHETIC_USER_PREFIXES = (
"[System: Your previous response was truncated",
"[System: The previous response was cut off",
"[System: Your previous tool call",
"[Your active task list was preserved across context compression]",
"[IMPORTANT: Background process ",
)
def _message_text(message: Any) -> str:
content = message.get("content") if isinstance(message, dict) else None
if isinstance(content, str):
return content
if isinstance(content, list):
return "\n".join(
str(part.get("text") or part.get("content") or "")
for part in content
if isinstance(part, dict)
)
return ""
_SYNTHETIC_USER_FLAGS = (
"_todo_snapshot_synthetic",
"_empty_recovery_synthetic",
"_verification_stop_synthetic",
"_pre_verify_synthetic",
)
def _is_real_user_message(message: Any) -> bool:
"""Distinguish human intent from user-role runtime scaffolding.
A compaction summary pinned to ``role="user"`` (the compressor flips the
summary role to preserve alternation when the tail starts with an
assistant message) is scaffolding too: treating it as human intent would
short-circuit anchor restoration with a message the model is explicitly
told NOT to act on.
"""
if not isinstance(message, dict) or message.get("role") != "user":
return False
if any(message.get(flag) for flag in _SYNTHETIC_USER_FLAGS):
return False
text = _message_text(message).strip()
if not text:
return False
if text.startswith(_SYNTHETIC_USER_PREFIXES):
return False
from agent.context_compressor import ContextCompressor
return not ContextCompressor._is_context_summary_content(text)
def _merge_anchor_into_user_message(target: dict, anchor: dict) -> None:
"""Fold the human anchor into an existing user-role scaffolding turn.
Used only when every insertion slot would create two consecutive
user-role messages. The anchor text leads (it is the active task), the
scaffolding content is preserved after it, and the synthetic flags are
cleared because the merged turn now carries real human intent.
"""
anchor_content = anchor.get("content")
target_content = target.get("content")
if isinstance(anchor_content, list) or isinstance(target_content, list):
anchor_parts = (
list(anchor_content)
if isinstance(anchor_content, list)
else [{"type": "text", "text": str(anchor_content or "")}]
)
target_parts = (
list(target_content)
if isinstance(target_content, list)
else [{"type": "text", "text": str(target_content or "")}]
)
target["content"] = anchor_parts + target_parts
else:
merged = f"{anchor_content or ''}\n\n{target_content or ''}".strip()
target["content"] = merged
for flag in _SYNTHETIC_USER_FLAGS:
target.pop(flag, None)
def _insert_real_user_anchor(messages: list, anchor: dict) -> None:
"""Insert the latest human turn without breaking role alternation."""
def _role(msg: Any) -> Optional[str]:
return msg.get("role") if isinstance(msg, dict) else None
# Preferred: the summary boundary — before the first assistant message
# not already preceded by a user turn. The left neighbour is then
# non-user by construction and the right neighbour is an assistant.
for index, message in enumerate(messages):
if _role(message) != "assistant":
continue
previous_role = _role(messages[index - 1]) if index > 0 else None
if previous_role != "user":
messages.insert(index, anchor)
return
# Every assistant is user-preceded (or there are none). Appending is
# safe whenever the transcript does not already end with a user turn.
if not messages or _role(messages[-1]) != "user":
messages.append(anchor)
return
# The transcript ends with a user-role message and no slot avoids
# user/user adjacency.
from agent.context_compressor import ContextCompressor
if ContextCompressor._is_context_summary_content(
_message_text(messages[-1])
):
# Never merge into a compaction summary: the summary prefix must
# stay at the start of its message for downstream summary detection.
# Appending after it makes the anchor "the latest user message after
# the summary" — exactly what the handoff prefix instructs — and the
# adjacent user turns are merged summary-first by
# repair_message_sequence before the next API call.
messages.append(anchor)
return
# Trailing user-role scaffolding (e.g. the todo snapshot): merge instead
# of inserting a consecutive same-role message (#55677 strict templates).
_merge_anchor_into_user_message(messages[-1], anchor)
def _ensure_compressed_has_user_turn(original_messages: list, compressed: list) -> None:
"""Preserve human intent, not merely a synthetic user-role placeholder."""
if any(_is_real_user_message(message) for message in compressed):
"""Preserve a real user turn when a compressor returns assistant/tool-only context.
On repeated compaction the protected head decays to the system prompt only,
the middle summary can land as ``role="assistant"``, and a tool-heavy tail
can be all assistant/tool so the compacted transcript can legitimately
contain zero user messages. Strict chat templates (LM Studio / llama.cpp
Jinja) then fail with "No user query found in messages" (#55677).
The restored turn is appended at the END: the guard only runs when
``compressed`` currently ends with an assistant/tool message (any existing
user turn including a todo-snapshot append short-circuits the
``any()`` check), so appending a user message never creates consecutive
same-role messages. ``_fresh_compaction_message_copy`` copies the message
and strips the ``_db_persisted`` marker so the rotation/in-place flush
still persists the restored row to the new session (#57491).
If the pre-compression transcript itself carried no user turn at all
(near-impossible every real conversation opens with a user request
but kept as a defensive backstop), a minimal continuation marker is
appended instead so strict templates still see a user message.
"""
if any(isinstance(msg, dict) and msg.get("role") == "user" for msg in compressed):
return
from agent.context_compressor import _fresh_compaction_message_copy
for message in reversed(original_messages):
if _is_real_user_message(message):
_insert_real_user_anchor(
compressed,
_fresh_compaction_message_copy(message),
)
return
for msg in reversed(original_messages):
if not isinstance(msg, dict) or msg.get("role") != "user":
continue
compressed.append(_fresh_compaction_message_copy(msg))
return
compressed.append({
"role": "user",
"content": (
"Continue from the compressed conversation context above. "
"This marker exists because no human user turn was available."
"This marker exists because the compacted transcript contained "
"no preserved user turn."
),
})
@@ -884,25 +780,6 @@ def compress_context(
_release_lock()
return messages, _existing_sp
if not compressed:
logger.error(
"context compression returned an empty transcript; refusing to "
"rotate session=%s so the parent remains resumable",
agent.session_id or "none",
)
try:
agent._emit_warning(
"⚠ Compression returned an empty transcript. "
"No session split was performed; conversation continues unchanged."
)
except Exception:
pass
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_release_lock()
return messages, _existing_sp
try:
summary_error = getattr(agent.context_compressor, "_last_summary_error", None)
if summary_error:
@@ -932,11 +809,7 @@ def compress_context(
todo_snapshot = agent._todo_store.format_for_injection()
if todo_snapshot:
compressed.append({
"role": "user",
"content": todo_snapshot,
"_todo_snapshot_synthetic": True,
})
compressed.append({"role": "user", "content": todo_snapshot})
_ensure_compressed_has_user_turn(messages, compressed)
agent._invalidate_system_prompt()
@@ -1088,20 +961,7 @@ def compress_context(
# refresh the stored system prompt and reset the flush cursor so the
# next turn re-bases its append diff.
agent._session_db.update_system_prompt(agent.session_id, new_system_prompt)
if in_place:
agent._last_flushed_db_idx = 0
else:
# A headless turn can be killed before its finalizer. Persist
# the rotated child's compacted handoff at the boundary so
# the new session is immediately resumable.
agent._session_db.replace_messages(agent.session_id, compressed)
agent._last_flushed_db_idx = len(compressed)
agent._flushed_db_message_session_id = agent.session_id
agent._flushed_db_message_ids = {
id(message)
for message in compressed
if isinstance(message, dict)
}
agent._last_flushed_db_idx = 0
except Exception as e:
# If the rotation rolled back to the parent (orphan-avoidance
# above), agent.session_id is the still-indexed parent and
+21 -122
View File
@@ -32,12 +32,9 @@ from agent.conversation_compression import conversation_history_after_compressio
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 (
build_turn_context,
compose_user_api_content,
reanchor_current_turn_user_idx,
)
from agent.turn_context import build_turn_context
from agent.turn_retry_state import TurnRetryState
from agent.memory_manager import build_memory_context_block
from agent.message_sanitization import (
close_interrupted_tool_sequence,
_repair_tool_call_arguments,
@@ -81,25 +78,6 @@ logger = logging.getLogger(__name__)
# to treat it as cancellation metadata rather than assistant prose.
INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model response ("
# Modules that indicate a deterministic local processing error when they
# appear in an exception traceback WITHOUT any API-call module. Used by the
# outer-loop error classifier to avoid retrying bugs that will fail
# identically every time (e.g. TypeError from passing list content into a
# regex helper). IMPORTANT: do NOT include "conversation_loop" or
# "run_agent" here — those are the container modules for the try/except
# itself, so every exception passes through them, which would make
# _hit_local always True and misclassify transient API/network errors as
# non-retryable local bugs. (#66267)
_LOCAL_PROCESSING_MODULES = frozenset({
"agent_runtime_helpers",
"message_content",
"message_sanitization",
"chat_completion_helpers", # only local when NOT also an API-call module
})
_API_CALL_MODULES = frozenset({
"chat_completion_helpers",
})
def _image_error_max_dimension(error: Exception) -> Optional[int]:
"""Extract a provider-reported image dimension ceiling, if present."""
@@ -632,8 +610,8 @@ def run_conversation(
# ── Per-turn setup (the prologue) ──
# All once-per-turn setup — stdio guarding, retry-counter resets, user
# message sanitization, todo/nudge hydration, system-prompt restore-or-
# build, preflight compression, the ``pre_llm_call`` plugin hook,
# external-memory prefetch, and crash-resilience persistence — lives in
# build, crash-resilience persistence, preflight compression, the
# ``pre_llm_call`` plugin hook, and external-memory prefetch — lives in
# ``build_turn_context``. It mutates ``agent`` exactly as the inline code
# did and returns the locals the loop below reads back. See
# ``agent/turn_context.py``.
@@ -653,9 +631,6 @@ def run_conversation(
set_session_context=set_session_context,
set_current_write_origin=set_current_write_origin,
ra=_ra,
# MoA turns append per-call aggregated context to the API copy of the
# user message, so no byte-stable api_content sidecar can be stamped.
moa_active=bool(moa_config),
)
user_message = _ctx.user_message
original_user_message = _ctx.original_user_message
@@ -864,51 +839,23 @@ def run_conversation(
for idx, msg in enumerate(messages):
api_msg = msg.copy()
# api_content is the persistence sidecar carrying the exact bytes
# sent to the API for this message when they differ from the clean
# stored content (see compose_user_api_content in turn_context).
# It is bookkeeping, never a provider field — pop it from EVERY
# outgoing copy.
_api_content = api_msg.pop("api_content", None)
# Inject ephemeral context into the current turn's user message.
# Sources: memory manager prefetch + plugin pre_llm_call hooks
# with target="user_message" (the default). Both are
# API-call-time only — the original message in `messages` is
# never mutated beyond the api_content stamp, so nothing leaks
# into the clean transcript content.
# never mutated, so nothing leaks into session persistence.
if idx == current_turn_user_idx and msg.get("role") == "user":
if isinstance(_api_content, str) and _api_content:
# Stamped by the prologue from the same composition —
# reuse it so the persisted sidecar and the wire cannot
# drift, and so every pass this turn sends identical
# bytes (composed from msg["content"], never from a
# previously-injected copy).
api_msg["content"] = _api_content
else:
# Callers that bypass the prologue stamping: compose live.
_composed = compose_user_api_content(
api_msg.get("content", ""),
_ext_prefetch_cache,
_plugin_user_context,
)
if _composed is not None:
api_msg["content"] = _composed
elif (
isinstance(_api_content, str)
and _api_content
and msg.get("role") in ("user", "assistant")
):
# Historical message: replay the exact bytes sent when it was
# live, so the provider prompt-cache prefix stays byte-stable
# instead of diverging at the injection point and
# re-prefilling everything after it. User rows carry the
# prefetch/plugin injection sidecar; user AND assistant rows
# can carry a sanitize-divergence sidecar (content that
# ``get_messages_as_conversation``'s sanitize_context/strip
# would rewrite on reload — see the capture in
# ``_flush_messages_to_session_db``).
api_msg["content"] = _api_content
_injections = []
if _ext_prefetch_cache:
_fenced = build_memory_context_block(_ext_prefetch_cache)
if _fenced:
_injections.append(_fenced)
if _plugin_user_context:
_injections.append(_plugin_user_context)
if _injections:
_base = api_msg.get("content", "")
if isinstance(_base, str):
api_msg["content"] = _base + "\n\n" + "\n\n".join(_injections)
# For ALL assistant messages, pass reasoning back to the API
# This ensures multi-turn reasoning context is preserved
@@ -4386,16 +4333,6 @@ def run_conversation(
# to fit the context window.
retry_count += 1
_retry.restart_with_compressed_messages = False
# In-loop compression rebuilt `messages` with fresh compaction
# copies, so the pre-compression current-turn index is stale.
# Re-anchor exactly like the prologue does: a stale index that
# lands on a historical user message would make the live-compose
# fallback inject this turn's prefetch into that message on the
# wire only, diverging the next turn's replayed prefix there.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
continue
if _retry.restart_with_rebuilt_messages:
@@ -5660,36 +5597,7 @@ def run_conversation(
break
except Exception as e:
# Phase-aware error classification. The huge outer try/except spans
# both the actual API request and all local post-processing of the
# returned assistant message. Deterministic local bugs (e.g.
# passing a multimodal content list into a regex helper after a
# vision turn or context compaction) should not be retried: they
# will fail identically on every iteration and only burn the
# iteration budget. We classify an error as local by inspecting the
# traceback: if the exception propagated through any of the known
# local post-processing helpers and never entered the interruptible
# API-call helpers, it is almost certainly a local processing bug.
# (#66267)
tb_module_names: set[str] = set()
_tb = e.__traceback__
while _tb is not None:
_fname = os.path.splitext(os.path.basename(_tb.tb_frame.f_code.co_filename))[0]
tb_module_names.add(_fname)
_tb = _tb.tb_next
_hit_local = bool(tb_module_names & _LOCAL_PROCESSING_MODULES)
_hit_api = bool(tb_module_names & _API_CALL_MODULES)
_is_local_processing_error = _hit_local and not _hit_api
if _is_local_processing_error:
error_msg = (
f"Error during local message processing after "
f"OpenAI-compatible API call #{api_call_count}: {str(e)}"
)
else:
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
error_msg = f"Error during OpenAI-compatible API call #{api_call_count}: {str(e)}"
try:
print(f"{error_msg}")
except (OSError, ValueError):
@@ -5736,19 +5644,10 @@ def run_conversation(
# message pollutes history, burns tokens, and risks violating
# role-alternation invariants.
# If we're near the limit, break to avoid infinite loops.
# Local processing errors are deterministic — stop immediately
# rather than retrying until the budget is exhausted.
if (
_is_local_processing_error
or api_call_count >= agent.max_iterations - 1
):
if _is_local_processing_error:
_turn_exit_reason = f"local_processing_error({error_msg[:80]})"
final_response = f"I apologize, but I encountered an error while processing the model response: {error_msg}"
else:
_turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})"
final_response = f"I apologize, but I encountered repeated errors: {error_msg}"
# If we're near the limit, break to avoid infinite loops
if api_call_count >= agent.max_iterations - 1:
_turn_exit_reason = f"error_near_max_iterations({error_msg[:80]})"
final_response = f"I apologize, but I encountered repeated errors: {error_msg}"
# Append as assistant so the history stays valid for
# session resume (avoids consecutive user messages).
messages.append({"role": "assistant", "content": final_response})
+3 -11
View File
@@ -43,19 +43,11 @@ logger = logging.getLogger(__name__)
def _load_config_safe() -> Optional[dict]:
"""Load config.yaml read-only, returning None on any error.
Uses ``load_config_readonly()``: every consumer in this module only reads
(``get_pool_strategy``, ``_iter_custom_providers``, the model-config seed),
and the deepcopy that ``load_config()`` pays per call is what made
credential-pool checks the dominant cost of ``model.options`` the picker
calls ``load_pool()`` once per provider row, each of which loaded (and
deep-copied) the full config again.
"""
"""Load config.yaml, returning None on any error."""
try:
from hermes_cli.config import load_config_readonly
from hermes_cli.config import load_config
return load_config_readonly()
return load_config()
except Exception:
return None
+1 -1
View File
@@ -355,7 +355,7 @@ def evaluate_credits_notices(
if show_depleted and "credits.depleted" not in active:
to_show.append(
AgentNotice(
text="✕ Credit access paused · run /topup to top up",
text="✕ Credit access paused · run /credits to top up",
level="error",
kind=CREDITS_NOTICE_KIND,
key="credits.depleted",
+1 -6
View File
@@ -98,12 +98,7 @@ def _backup_cron_jobs_into(dest: Path) -> Dict[str, Any]:
info["reason"] = "no cron/jobs.json present"
return info
try:
# utf-8-sig: same dialect as cron/jobs.load_jobs — a UTF-8 BOM left
# by Windows editors otherwise survives decoding as U+FEFF, breaks
# json.loads below, and misreports jobs_count as 0 with a spurious
# parse warning. The BOM-less text is also what gets written to the
# backup, so a later rollback restores a loadable file.
raw = src.read_text(encoding="utf-8-sig")
raw = src.read_text(encoding="utf-8")
except OSError as e:
logger.debug("Failed to read cron/jobs.json for backup: %s", e)
info["reason"] = f"read error: {e}"
-46
View File
@@ -645,52 +645,6 @@ def verb_drops_preview(tool_name: str) -> bool:
return tool_name in _TOOL_VERBS_NO_PREVIEW
def build_status_phrase(tool_name: str, args: dict | None, max_len: int = 49) -> str | None:
"""Build a short present-tense status phrase for platform status surfaces.
Used by text-rendering "typing" indicators (Slack's
``assistant.threads.setStatus`` line) to show what the agent is doing
right now: ``is running scripts/run_tests.sh`` instead of a static
``is thinking...``. The phrase is phrased to follow the bot's display
name ("Hermes is running …"), so it starts lowercase with "is".
Pass ``args=None`` for a verb-only phrase (``is running``) used when
``display.live_status`` is ``verb`` to keep argument previews out of
shared channels.
Returns None for the ``_thinking`` pseudo-tool and when friendly labels
are disabled (callers fall back to their static default). ``max_len``
caps the total phrase length; Slack truncates its status line around 50
characters, so the default stays just under that.
"""
if not tool_name or tool_name == "_thinking":
return None
if not _friendly_tool_labels:
return None
verb = _TOOL_VERBS.get(tool_name)
if verb:
head = f"is {verb[0].lower()}{verb[1:]}"
else:
# Custom / plugin / MCP tools: generic but still informative.
head = f"is using {tool_name}"
phrase = head
if args and verb and tool_name not in _TOOL_VERBS_NO_PREVIEW:
preview = build_tool_preview(tool_name, args, max_len=None)
if preview:
# Previews can contain newlines (terminal commands); keep the
# status to the first line.
preview = preview.splitlines()[0].strip()
phrase = f"{head}{tool_verb_connector(tool_name)}{preview}"
if len(phrase) > max_len - 1:
phrase = phrase[: max_len - 2].rstrip() + ""
else:
phrase = phrase + ""
return phrase
def build_tool_label(tool_name: str, args: dict, max_len: int | None = None) -> str | None:
"""Build a human-phrased status label for a tool call.
+2 -62
View File
@@ -269,11 +269,6 @@ _CONTEXT_OVERFLOW_PATTERNS = [
"context window",
"prompt is too long",
"prompt exceeds max length",
# NOTE: bare "max_tokens" is load-bearing — the output-cap-retry path keys
# off it (e.g. "max_tokens: 65536 > context_window: 200000 ..."). Do NOT
# remove it. Provider empty-response advisories also contain "very low
# max_tokens", but those are intercepted by _EMPTY_PROVIDER_RESPONSE_PATTERNS
# BEFORE this list is consulted, so they never mis-route into compression.
"max_tokens",
"maximum number of tokens",
# vLLM / local inference server patterns
@@ -431,19 +426,6 @@ _THINKING_SIG_PATTERNS = [
# the exception type is generic (e.g. RuntimeError from a local shim that
# wraps a subprocess timeout). Checked before the type-based transport
# heuristics so custom-provider "timed out" errors don't fall through to
# Provider empty-response advisories (OpenRouter / nano-gpt / similar).
# Checked before context-overflow matching because the advisory text often
# mentions "max_tokens" as a possible cause, which historically sat in
# _CONTEXT_OVERFLOW_PATTERNS and sent healthy sessions into a compression
# death spiral ending in "Cannot compress further".
_EMPTY_PROVIDER_RESPONSE_PATTERNS = [
"returned an empty response",
"empty response despite retries",
"provider returned an empty response",
"model returning empty responses",
"empty response stream",
]
# the unknown bucket and get misreported as empty responses.
_TIMEOUT_MESSAGE_PATTERNS = [
"timed out",
@@ -793,14 +775,6 @@ def classify_api_error(
if classified is not None:
return classified
# Local MoA config drift is deterministic: a persisted session can retain
# a preset name that was later renamed/deleted. Retrying the same lookup
# cannot recover and makes a clear config error look like an API outage.
from agent.errors import MoAPresetNotFoundError
if isinstance(error, MoAPresetNotFoundError):
return _result(FailoverReason.model_not_found, retryable=False)
# ── 3. Error code classification ────────────────────────────────
if error_code:
@@ -1095,14 +1069,6 @@ def _classify_by_status(
# remaining explicit context-overflow signal routes into the
# compression-and-retry path (mirroring _classify_400) instead of
# blind server_error retries that exhaust and drop the turn.
# Empty-response advisories that mention "max_tokens" must not enter
# that compression path.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
@@ -1116,12 +1082,6 @@ def _classify_by_status(
# Cloudflare/Tailscale hop relabeling the status). Route explicit
# overflow bodies into compression; otherwise treat as transient
# overload and retry.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
FailoverReason.context_overflow,
@@ -1247,8 +1207,8 @@ def _classify_400(
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which historically
# sat in _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
@@ -1270,17 +1230,6 @@ def _classify_400(
should_fallback=True,
)
# Empty-provider-response advisories must not enter compression. They
# often mention "max_tokens" as a possible cause and used to match the
# bare overflow pattern, then thrash compress until "Cannot compress
# further" on an otherwise healthy session (custom endpoints / nano-gpt).
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
@@ -1484,15 +1433,6 @@ def _classify_by_message(
should_fallback=True,
)
# Empty-provider-response advisories (often mention "max_tokens") must
# retry without compression — see the matching 400-path guard above.
if any(p in error_msg for p in _EMPTY_PROVIDER_RESPONSE_PATTERNS):
return result_fn(
FailoverReason.server_error,
retryable=True,
should_compress=False,
)
# Context overflow patterns
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
-4
View File
@@ -7,7 +7,3 @@ class EmptyStreamError(RuntimeError):
"""Raised when a provider closes a stream without yielding a response."""
pass
class MoAPresetNotFoundError(ValueError):
"""Raised when a persisted MoA preset no longer exists in config."""
-31
View File
@@ -58,14 +58,6 @@ def _scan_context_content(content: str, filename: str) -> str:
BLOCKED at this layer because the file would otherwise enter the
system prompt verbatim and the user has no chance to intervene.
"""
# Editors (Windows Notepad, PowerShell Out-File without -Encoding
# utf8NoBOM, some VS Code profiles) prefix a UTF-8 BOM as an encoding
# artifact, not a prompt injection. Strip a leading U+FEFF silently so a
# context file (SOUL.md, AGENTS.md, ...) is not blocked wholesale; BOMs
# elsewhere in the content remain subject to the threat scan below.
if content.startswith("\ufeff"):
content = content[1:]
findings = _scan_for_threats(content, scope="context")
if findings:
logger.warning("Context file %s blocked: %s", filename, ", ".join(findings))
@@ -557,29 +549,6 @@ def computer_use_guidance(platform_name: Optional[str] = None) -> str:
"4. After any state-changing action, re-capture to verify. You can "
"pass `capture_after=true` to get the follow-up screenshot in one "
"round-trip.\n\n"
"## Verify → escalate ladder (background-first, NOT background-only)\n"
"Background delivery is the DEFAULT and the co-work path, but it is "
"the first rung, not the only one. Read each action's structured "
"result and climb only when the driver tells you to:\n"
"- `effect: 'confirmed'` + `verified: true` — the driver read the "
"result back. Done.\n"
"- `effect: 'unverifiable'` — the input was delivered but the driver "
"can't confirm it. Re-capture and check the screenshot/tree yourself "
"before deciding it worked.\n"
"- `effect: 'suspected_noop'`, `code: 'background_unavailable'`, or an "
"`escalation.recommended` field — the action did NOT land. Follow "
"`escalation.recommended`:\n"
" - `'px'` → re-issue addressing the target by `coordinate=[x,y]` "
"read off the screenshot instead of `element`.\n"
" - `'foreground'` (or a pixel click still didn't land) → re-issue "
"the SAME action with `delivery_mode='foreground'`. This briefly "
"raises the window; it needs its own approval and is only appropriate "
"when the user isn't actively working. Common for Electron/Chromium "
"consent dialogs, DirectInput games, and raw-input canvases.\n"
"- Escalate to foreground as a REACTION to a returned signal, never "
"as a prediction from the app being Electron/Chromium/GTK. Do not "
"silently retry the same rung expecting a different result, and do "
"not conclude 'cua-driver can't drive this app' — climb the ladder.\n\n"
"## Background mode rules\n"
"- Do NOT use `raise_window=true` on `focus_app` unless the user "
"explicitly asked you to bring a window to front. Input routing to "
-6
View File
@@ -22,7 +22,6 @@ from typing import Any, Dict, List
from agent.tool_dispatch_helpers import make_tool_result_message
from agent.tool_result_classification import tool_may_have_side_effect
from agent.turn_context import drop_stale_api_content
logger = logging.getLogger(__name__)
@@ -312,11 +311,6 @@ def strip_stale_dangerous_confirmations(
)
redacted = dict(msg)
redacted["content"] = _EXPIRED_CONFIRMATION_SENTINEL
# Drop the api_content sidecar: it carries the exact bytes
# previously sent — i.e. the dangerous confirmation this
# redaction exists to expire. Replaying it verbatim would
# undo the redaction on the wire.
drop_stale_api_content(redacted)
cleaned.append(redacted)
continue
cleaned.append(msg)
-70
View File
@@ -1,70 +0,0 @@
"""Best-effort accessors for the single-writer stream fence (#65991).
The fence itself lives on ``AIAgent`` (``_claim_stream_writer`` /
``_stream_writer_is_current`` in ``run_agent.py``), but the streaming code paths
that use it live in *other* modules ``chat_completion_helpers`` (chat /
anthropic / bedrock) and ``codex_runtime`` (codex responses). Calling the fence
directly as ``agent._claim_stream_writer()`` from those modules makes them
hard-depend on the method being present on whatever object is passed in as
``agent``.
That coupling is a latent crash: a partially-updated checkout (the streaming
helper module newer than ``run_agent``), a hot-reloaded gateway, a duck-typed
agent, or a test double without the method turns an *additive* safety net into a
fatal ``AttributeError`` that aborts the whole turn. A cron job died exactly
this way with ``'AIAgent' object has no attribute '_claim_stream_writer'``.
The fence is only ever allowed to drop a *provably* superseded stream never
the sole legitimate writer. So when the guard is unavailable (or raises), the
correct degradation is "no fence": keep streaming. These helpers make the
claim/check best-effort to guarantee that.
"""
from __future__ import annotations
import logging
from typing import Any
logger = logging.getLogger(__name__)
def claim_stream_writer(agent: Any) -> int:
"""Claim the delta sink for the calling stream attempt, best-effort.
Returns the agent's monotonic writer token when the fence is available, or
``0`` when the agent doesn't expose it (or the claim raised). A ``0`` token
pairs with :func:`stream_writer_is_current` always returning ``True``, so a
guard-less agent is simply never fenced instead of crashing the turn.
"""
claim = getattr(agent, "_claim_stream_writer", None)
if callable(claim):
try:
return int(claim())
except Exception:
logger.debug(
"stream single-writer: claim failed; proceeding unfenced",
exc_info=True,
)
return 0
def stream_writer_is_current(agent: Any, token: int) -> bool:
"""True when ``token`` is still the active writer, best-effort.
A falsy token (from a claim that no-oped) or an agent without the fence
means we cannot prove supersession, so the stream is treated as current and
never fenced. This preserves the single-writer invariant's one-way promise:
only a demonstrably stale writer is ever stopped.
"""
if not token:
return True
is_current = getattr(agent, "_stream_writer_is_current", None)
if callable(is_current):
try:
return bool(is_current(token))
except Exception:
logger.debug(
"stream single-writer: is_current check failed; treating as current",
exc_info=True,
)
return True
-421
View File
@@ -1,421 +0,0 @@
"""Surface-agnostic core for the ``/subscription`` TUI screen.
Companion to :mod:`agent.billing_view` same fail-open philosophy: when not
logged in or the portal is unreachable, return a struct with ``logged_in=False``
and let the surface degrade gracefully (never crash). Money is decimal end-to-end
(server emits decimal strings); we only format for display.
The TUI ``SubscriptionOverlay`` drives the plan change in-terminal (V3): it
previews the effect, then schedules a downgrade / cancellation / resume
(chargeless) or applies an upgrade (charges the card on the subscription). The
portal deep-link (built locally from ``portal_url`` + ``org_id``) remains the
fallback for an upgrade that needs 3DS / was declined.
WS1 dependency: ``GET /api/billing/subscription`` is a NAS endpoint (WS1 Phase A).
Until it ships, the fail-open contract handles 404s the builder returns
``logged_in=False`` and the surface degrades gracefully.
"""
from __future__ import annotations
import logging
import os
from dataclasses import dataclass
from decimal import Decimal
from typing import Any, Optional
from agent.billing_view import parse_money
logger = logging.getLogger(__name__)
# =============================================================================
# Parsed sub-structures
# =============================================================================
@dataclass(frozen=True)
class CurrentSubscription:
"""The user's active subscription. ``None`` (not this object) = no plan.
When present, ``tier_id`` / ``tier_name`` / ``monthly_credits`` /
``cycle_ends_at`` are always set (NAS guarantees a present ``current`` is a
fully-populated plan). Only ``credits_remaining`` and the cancel/downgrade
fields are optional.
"""
tier_id: Optional[str] = None
tier_name: Optional[str] = None
monthly_credits: Optional[Decimal] = None
credits_remaining: Optional[Decimal] = None
cycle_ends_at: Optional[str] = None # ISO
pending_downgrade_tier_name: Optional[str] = None
pending_downgrade_at: Optional[str] = None # ISO
cancel_at_period_end: bool = False
cancellation_effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionTier:
"""A selectable plan in the catalog — one row of the in-terminal tier picker.
Mirrors NAS's ``SubscriptionTierOption``. ``is_current`` marks the active plan
(shown but not selectable); ``is_enabled=False`` is a grandfathered tier the
user is on but that can no longer be selected. ``tier_order`` sorts the picker
and drives the upgrade-vs-downgrade direction hint.
"""
tier_id: str
name: str
tier_order: int = 0
dollars_per_month: Optional[Decimal] = None
monthly_credits: Optional[Decimal] = None
is_current: bool = False
is_enabled: bool = True
@dataclass(frozen=True)
class SubscriptionChangePreview:
"""Parsed ``POST /api/billing/subscription/preview`` — what a change would do.
``effect`` is the disposition the commit would take:
- ``charge_now`` an upgrade; ``amount_due_now_cents`` is the prorated charge.
- ``scheduled`` a downgrade / same-price change at ``effective_at`` (period end).
- ``no_op`` already on the target tier.
- ``blocked`` the commit would be refused; ``reason`` says why.
"""
effect: str
reason: Optional[str] = None
current_tier_id: Optional[str] = None
current_tier_name: Optional[str] = None
target_tier_id: Optional[str] = None
target_tier_name: Optional[str] = None
monthly_credits_delta: Optional[Decimal] = None
amount_due_now_cents: Optional[int] = None
effective_at: Optional[str] = None # ISO
@dataclass(frozen=True)
class SubscriptionState:
"""Parsed ``GET /api/billing/subscription`` — the overview screen's data.
Fail-open: ``logged_in=False`` (and empty fields) when not logged in or the
portal is unreachable.
"""
logged_in: bool
org_name: Optional[str] = None
org_id: Optional[str] = None # org.id from the NAS response
role: Optional[str] = None # "OWNER" | "ADMIN" | "FINANCE_ADMIN" | "SECURITY_ADMIN" | "MEMBER"
can_change_plan_raw: Optional[bool] = None
context: str = "personal" # "personal" | "team"
current: Optional[CurrentSubscription] = None
tiers: tuple[SubscriptionTier, ...] = () # selectable catalog (picker)
portal_url: Optional[str] = None
# When the fetch failed (vs cleanly not-logged-in), the message for the surface.
error: Optional[str] = None
@property
def is_admin(self) -> bool:
"""Deprecated/display only — a legacy OWNER/ADMIN check.
NOT a capability check; use :attr:`can_change_plan` for gating billing
plan-change actions.
"""
return (self.role or "").upper() in ("OWNER", "ADMIN")
@property
def can_change_plan(self) -> bool:
"""Server capability when supplied; otherwise the legacy role fallback."""
if self.can_change_plan_raw is not None:
return self.can_change_plan_raw
return self.is_admin
# =============================================================================
# Payload parsing
# =============================================================================
def _parse_current(raw: Any) -> Optional[CurrentSubscription]:
# "No plan" is wire-represented as current:null (free personal OR team) —
# the old all-null-object shape is gone. A present current is a real plan,
# so guard on a real tier id and return None otherwise.
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return CurrentSubscription(
tier_id=tier_id,
tier_name=raw.get("tierName") or raw.get("name"),
monthly_credits=parse_money(raw.get("monthlyCredits")),
credits_remaining=parse_money(raw.get("creditsRemaining")),
cycle_ends_at=raw.get("cycleEndsAt"),
pending_downgrade_tier_name=raw.get("pendingDowngradeTierName"),
pending_downgrade_at=raw.get("pendingDowngradeAt"),
cancel_at_period_end=bool(raw.get("cancelAtPeriodEnd")),
cancellation_effective_at=raw.get("cancellationEffectiveAt") or None,
)
def _coalesce(*vals: Any) -> Any:
"""First non-``None`` value (preserves a legit ``0``/``0.0``, unlike ``or``).
NAS sends ``0`` for the free tier's ``tierOrder`` / ``dollarsPerMonth``; a plain
``x or default`` would drop those, so coalesce on ``None`` specifically.
"""
for v in vals:
if v is not None:
return v
return None
def _parse_tier(raw: Any) -> Optional[SubscriptionTier]:
"""Map one NAS ``SubscriptionTierOption`` dict into a :class:`SubscriptionTier`."""
if not isinstance(raw, dict):
return None
tier_id = raw.get("tierId") or raw.get("id")
if not tier_id:
return None
return SubscriptionTier(
tier_id=tier_id,
name=raw.get("name") or "",
tier_order=int(_coalesce(raw.get("tierOrder"), 0)),
dollars_per_month=parse_money(raw.get("dollarsPerMonthDisplay")),
monthly_credits=parse_money(raw.get("monthlyCredits")),
is_current=bool(raw.get("isCurrent")),
is_enabled=bool(_coalesce(raw.get("isEnabled"), True)),
)
def subscription_change_preview_from_payload(
payload: dict[str, Any],
) -> SubscriptionChangePreview:
"""Map a raw ``/subscription/preview`` JSON dict into :class:`SubscriptionChangePreview`."""
effect = payload.get("effect")
cents = payload.get("amountDueNowCents")
return SubscriptionChangePreview(
# An unrecognized/missing effect is treated as ``blocked`` — fail safe, never
# charge on a malformed quote.
effect=effect if isinstance(effect, str) else "blocked",
reason=payload.get("reason") or None,
current_tier_id=payload.get("currentTierId"),
current_tier_name=payload.get("currentTierName"),
target_tier_id=payload.get("targetTierId"),
target_tier_name=payload.get("targetTierName"),
monthly_credits_delta=parse_money(payload.get("monthlyCreditsDelta")),
amount_due_now_cents=int(cents) if isinstance(cents, (int, float)) else None,
effective_at=payload.get("effectiveAt") or None,
)
def subscription_state_from_payload(
payload: dict[str, Any], *, portal_url: Optional[str] = None
) -> SubscriptionState:
"""Map a raw ``/api/billing/subscription`` JSON dict into :class:`SubscriptionState`."""
raw_org = payload.get("org")
org: dict[str, Any] = raw_org if isinstance(raw_org, dict) else {}
raw_context = payload.get("context")
context = raw_context if raw_context in ("personal", "team") else "personal"
raw_tiers = payload.get("tiers")
tiers = (
tuple(t for t in (_parse_tier(x) for x in raw_tiers) if t is not None)
if isinstance(raw_tiers, list)
else ()
)
return SubscriptionState(
logged_in=True,
org_name=org.get("name"),
org_id=org.get("id") or None,
role=org.get("role"),
can_change_plan_raw=(
payload.get("canChangePlan")
if isinstance(payload.get("canChangePlan"), bool)
else None
),
context=context,
current=_parse_current(payload.get("current")),
tiers=tiers,
portal_url=portal_url,
)
# =============================================================================
# Fail-open builders (the surface front doors)
# =============================================================================
def build_subscription_state(*, timeout: float = 15.0) -> SubscriptionState:
"""Fetch + parse ``GET /api/billing/subscription``. Fail-open.
Returns ``SubscriptionState(logged_in=False)`` when not logged in. On a
portal/HTTP failure, returns ``logged_in=False`` with ``error`` set so the
surface can show a clear message rather than crashing.
Dev override: when ``HERMES_DEV_SUBSCRIPTION_FIXTURE`` names a fixture state,
``/subscription`` renders from that fixture instead of the real portal so
every plan/cancel/downgrade/team/not-admin state is testable on both
the CLI and TUI without a live account. Throwaway scaffolding; see
:func:`dev_fixture_subscription_state`.
"""
fixture = dev_fixture_subscription_state()
if fixture is not None:
return fixture
try:
from hermes_cli.nous_billing import (
BillingAuthError,
BillingError,
_absolutize_portal_url,
get_subscription_state,
resolve_portal_base_url,
)
except Exception:
return SubscriptionState(logged_in=False, error="billing client unavailable")
try:
payload = get_subscription_state(timeout=timeout)
except BillingAuthError:
return SubscriptionState(logged_in=False)
except BillingError as exc:
logger.debug("subscription ▸ /state fetch failed (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error=str(exc))
except Exception:
logger.debug("subscription ▸ /state unexpected error (fail-open)", exc_info=True)
return SubscriptionState(logged_in=False, error="could not load subscription state")
raw_portal = payload.get("portalUrl") if isinstance(payload, dict) else None
portal_url = _absolutize_portal_url(raw_portal) if raw_portal else None
if not portal_url:
try:
portal_url = resolve_portal_base_url()
except Exception:
portal_url = None
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.
Mirrors the TUI's ``buildManageUrl`` (``subscription.ts``): the deep-link
target is NAS's OWN ``/manage-subscription`` page (NOT the Stripe Billing
Portal decided Jun 23), which routes upgradeCheckout / downgradescheduled
internally. ``org_id`` pins the page to the right account in multi-org
situations. Returns ``None`` when no portal URL is resolvable.
"""
from urllib.parse import urlencode, urlsplit, urlunsplit
if not state.portal_url:
return None
try:
parts = urlsplit(state.portal_url)
except Exception:
return None
if not parts.scheme or not parts.netloc:
return None
query = urlencode({"org_id": state.org_id}) if state.org_id else ""
return urlunsplit((parts.scheme, parts.netloc, "/manage-subscription", query, ""))
# =============================================================================
# Dev fixtures (throwaway scaffolding — env-var driven, no live portal)
# =============================================================================
_DEV_FIXTURE_PORTAL = "https://portal.nousresearch.com/billing"
def _dev_current(**over: Any) -> CurrentSubscription:
base: dict[str, Any] = dict(
tier_id="plus",
tier_name="Plus",
monthly_credits=Decimal("1000"),
credits_remaining=Decimal("420"),
cycle_ends_at="2026-07-01",
)
base.update(over)
return CurrentSubscription(**base)
def _dev_tiers(current_id: Optional[str]) -> tuple[SubscriptionTier, ...]:
"""A sample plan catalog for fixtures (marks ``current_id`` as the active tier)."""
specs = (
("free", "Free", 0, "0", "0"),
("plus", "Plus", 1, "20", "1000"),
("super", "Super", 2, "40", "3000"),
("ultra", "Ultra", 3, "80", "7000"),
)
return tuple(
SubscriptionTier(
tier_id=tid,
name=name,
tier_order=order,
dollars_per_month=parse_money(dpm),
monthly_credits=parse_money(mc),
is_current=(tid == current_id),
is_enabled=True,
)
for tid, name, order, dpm, mc in specs
)
def dev_fixture_subscription_state() -> Optional[SubscriptionState]:
"""Return a fixture :class:`SubscriptionState` for ``HERMES_DEV_SUBSCRIPTION_FIXTURE``.
Lets every CLI/TUI subscription state be exercised without a live portal:
free | mid | top | not-admin | downgrade | cancel | team |
logged-out
Returns ``None`` when the env var is unset/empty (the real portal path runs).
Throwaway scaffolding mirrors ``HERMES_DEV_CREDITS_FIXTURE``.
"""
name = (os.getenv("HERMES_DEV_SUBSCRIPTION_FIXTURE") or "").strip().lower()
if not name:
return None
common = dict(org_name="Acme Inc", org_id="org_acme", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
if name in ("logged-out", "logged_out", "loggedout"):
return SubscriptionState(logged_in=False)
if name == "free":
return SubscriptionState(logged_in=True, current=None, tiers=_dev_tiers(None), **common)
if name in ("mid", "mid-tier"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **common)
if name in ("top", "top-tier"):
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="ultra", tier_name="Ultra", monthly_credits=Decimal("7000"), credits_remaining=Decimal("5000")),
tiers=_dev_tiers("ultra"),
**common,
)
if name in ("not-admin", "member"):
return SubscriptionState(logged_in=True, current=_dev_current(), tiers=_dev_tiers("plus"), **{**common, "role": "MEMBER"})
if name == "downgrade":
return SubscriptionState(
logged_in=True,
current=_dev_current(tier_id="super", tier_name="Super", monthly_credits=Decimal("3000"), credits_remaining=Decimal("1500"), pending_downgrade_tier_name="Plus", pending_downgrade_at="2026-07-15"),
tiers=_dev_tiers("super"),
**common,
)
if name == "cancel":
return SubscriptionState(
logged_in=True,
current=_dev_current(cancel_at_period_end=True, cancellation_effective_at="2026-07-01"),
tiers=_dev_tiers("plus"),
**common,
)
if name == "team":
return SubscriptionState(logged_in=True, context="team", current=None, org_name="Acme Engineering", org_id="org_eng", role="OWNER", portal_url=_DEV_FIXTURE_PORTAL)
# Unknown name → behave as logged-out so the misconfiguration is visible.
return SubscriptionState(logged_in=False, error=f"unknown HERMES_DEV_SUBSCRIPTION_FIXTURE: {name}")
+4 -5
View File
@@ -46,7 +46,6 @@ from agent.prompt_builder import (
drain_truncation_warnings,
)
from agent.runtime_cwd import resolve_context_cwd
from hermes_constants import get_hermes_home
from utils import is_truthy_value
@@ -396,7 +395,7 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
if active_profile == "default":
stable_parts.append(
"Active Hermes profile: default. Other profiles (if any) live "
"under " + str(get_hermes_home()) + "/profiles/<name>/. Each profile has its own "
"under ~/.hermes/profiles/<name>/. Each profile has its own "
"skills/, plugins/, cron/, and memories/ that affect a different "
"session than this one. Do not modify another profile's "
"skills/plugins/cron/memories unless the user explicitly directs "
@@ -405,9 +404,9 @@ def build_system_prompt_parts(agent: Any, system_message: Optional[str] = None)
else:
stable_parts.append(
f"Active Hermes profile: {active_profile}. This session reads "
f"and writes {get_hermes_home()}/profiles/{active_profile}/. The default "
f"profile's data lives at {get_hermes_home()}/skills/, {get_hermes_home()}/plugins/, "
f"{get_hermes_home()}/cron/, {get_hermes_home()}/memories/ — those belong to a "
f"and writes ~/.hermes/profiles/{active_profile}/. The default "
f"profile's data lives at ~/.hermes/skills/, ~/.hermes/plugins/, "
f"~/.hermes/cron/, ~/.hermes/memories/ — those belong to a "
f"different session run from a different shell. Do NOT modify "
f"another profile's skills/plugins/cron/memories unless the user "
f"explicitly directs you to. The cross-profile write guard will "
+1 -5
View File
@@ -472,8 +472,4 @@ def _positive_int(value: Any, default: int) -> int:
def _sha256(value: str) -> str:
# surrogatepass: tool results scraped from the web can carry unpaired
# UTF-16 surrogates (e.g. half of a mathematical-bold pair); a strict
# encode raises and takes down the whole conversation loop. The hash only
# needs deterministic bytes, not valid UTF-8.
return hashlib.sha256(value.encode("utf-8", "surrogatepass")).hexdigest()
return hashlib.sha256(value.encode("utf-8")).hexdigest()
-3
View File
@@ -187,7 +187,6 @@ class ChatCompletionsTransport(ProviderTransport):
or "tool_name" in msg
or "effect_disposition" in msg
or "timestamp" in msg # #47868 — strict providers reject this
or "api_content" in msg # persist-what-you-send sidecar
):
needs_sanitize = True
break
@@ -230,7 +229,6 @@ class ChatCompletionsTransport(ProviderTransport):
or "tool_name" in msg
or "effect_disposition" in msg
or "timestamp" in msg # #47868 — leak into strict providers
or "api_content" in msg # persist-what-you-send sidecar
):
out_msg = mutable_msg()
out_msg.pop("codex_reasoning_items", None)
@@ -238,7 +236,6 @@ class ChatCompletionsTransport(ProviderTransport):
out_msg.pop("tool_name", None)
out_msg.pop("effect_disposition", None)
out_msg.pop("timestamp", None) # #47868 — leak into strict providers
out_msg.pop("api_content", None) # persist-what-you-send sidecar
# Drop all Hermes-internal scaffolding markers (``_``-prefixed).
+2 -46
View File
@@ -13,20 +13,6 @@ from agent.transports.base import ProviderTransport
from agent.transports.types import NormalizedResponse, ToolCall
def _bounded_prompt_cache_key(value: Any) -> Optional[str]:
"""Return a provider-safe cache key without changing session identity."""
if value is None:
return None
key = str(value).strip()
if not key:
return None
if len(key) <= 64:
return key
# Match _content_cache_key's compact, collision-resistant routing-key shape.
digest = hashlib.sha256(key.encode("utf-8", errors="replace")).hexdigest()[:24]
return f"pck_{digest}"
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
"""Content-address the prompt cache key from the static request prefix.
@@ -318,13 +304,6 @@ class ResponsesApiTransport(ProviderTransport):
if request_overrides:
kwargs.update(request_overrides)
if "prompt_cache_key" in kwargs:
bounded_cache_key = _bounded_prompt_cache_key(kwargs["prompt_cache_key"])
if bounded_cache_key:
kwargs["prompt_cache_key"] = bounded_cache_key
else:
kwargs.pop("prompt_cache_key", None)
# xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not
# supported: service_tier") — hit when ``/fast`` priority-processing
# mode lingers from a prior model in the same session, or when a
@@ -358,7 +337,7 @@ class ResponsesApiTransport(ProviderTransport):
# remain high. Send session_id / x-client-request-id as HTTP
# headers while keeping ``prompt_cache_key`` in the body for
# standard OpenAI routing as a belt-and-braces fallback.
cache_scope_id = _bounded_prompt_cache_key(session_id)
cache_scope_id = str(session_id or "").strip()
if cache_scope_id:
existing_extra_headers = kwargs.get("extra_headers")
merged_extra_headers: Dict[str, str] = {}
@@ -403,14 +382,6 @@ class ResponsesApiTransport(ProviderTransport):
merged_extra_body.setdefault("prompt_cache_key", cache_key)
kwargs["extra_body"] = merged_extra_body
extra_body = kwargs.get("extra_body")
if isinstance(extra_body, dict) and "prompt_cache_key" in extra_body:
bounded_cache_key = _bounded_prompt_cache_key(extra_body["prompt_cache_key"])
if bounded_cache_key:
extra_body["prompt_cache_key"] = bounded_cache_key
else:
extra_body.pop("prompt_cache_key", None)
return kwargs
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
@@ -499,26 +470,11 @@ class ResponsesApiTransport(ProviderTransport):
Normalizes input items, strips unsupported fields, validates structure.
"""
from agent.codex_responses_adapter import _preflight_codex_api_kwargs
normalized = _preflight_codex_api_kwargs(
return _preflight_codex_api_kwargs(
api_kwargs,
allow_stream=allow_stream,
is_github_responses=is_github_responses,
)
if "prompt_cache_key" in normalized:
bounded = _bounded_prompt_cache_key(normalized["prompt_cache_key"])
if bounded:
normalized["prompt_cache_key"] = bounded
else:
normalized.pop("prompt_cache_key", None)
extra_body = normalized.get("extra_body")
if isinstance(extra_body, dict) and "prompt_cache_key" in extra_body:
bounded = _bounded_prompt_cache_key(extra_body["prompt_cache_key"])
if bounded:
extra_body["prompt_cache_key"] = bounded
else:
extra_body.pop("prompt_cache_key", None)
return normalized
def map_finish_reason(self, raw_reason: str) -> str:
"""Map Codex response.status to OpenAI finish_reason.
+17 -231
View File
@@ -3,10 +3,8 @@
``run_conversation`` opened with ~470 lines of straight-line setup before the
tool-calling loop ever started: stdio guarding, runtime-main wiring, retry-counter
resets, user-message sanitization, todo/nudge-counter hydration, system-prompt
restore-or-build, session-row creation (before compression, whose DB writes
reference the row), preflight context compression, the ``pre_llm_call`` plugin
hook, external-memory prefetch, and crash-resilience persistence (last, so the
user row is written once with its final ``api_content`` sidecar).
restore-or-build, crash-resilience persistence, preflight context compression, the
``pre_llm_call`` plugin hook, and external-memory prefetch.
All of that is *prologue* it runs once per turn, has no back-references into the
loop, and produces a fixed set of values the loop then consumes. ``TurnContext``
@@ -28,11 +26,10 @@ import logging
import threading
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional
from typing import Any, Dict, List, Optional
from agent.conversation_compression import conversation_history_after_compression
from agent.iteration_budget import IterationBudget
from agent.memory_manager import build_memory_context_block
from agent.model_metadata import (
estimate_messages_tokens_rough,
estimate_request_tokens_rough,
@@ -41,112 +38,6 @@ from agent.model_metadata import (
logger = logging.getLogger(__name__)
def compose_user_api_content(
content: Any,
ext_prefetch_cache: str,
plugin_user_context: str,
) -> Optional[str]:
"""Compose the API-bound content of the current turn's user message.
Sources: memory-manager prefetch + ``pre_llm_call`` plugin context with
target="user_message" (the default). Both are appended to the *API copy*
of the user message only the stored content stays clean.
This is the single source of that composition. The prologue stamps the
result onto the live message as ``api_content`` (persisted alongside the
clean content) and the ``api_messages`` build in ``conversation_loop``
sends the same helper's output, so the persisted sidecar can never drift
from the bytes on the wire which is the whole prompt-cache invariant:
what turn N sends must be what turn N+1 replays.
Returns ``None`` when nothing is injected (multimodal/non-string content,
or no ephemeral context), meaning the message is sent as-is.
"""
if not isinstance(content, str):
return None
injections = []
if ext_prefetch_cache:
fenced = build_memory_context_block(ext_prefetch_cache)
if fenced:
injections.append(fenced)
if plugin_user_context:
injections.append(plugin_user_context)
if not injections:
return None
return content + "\n\n" + "\n\n".join(injections)
def substitute_api_content(api_msg: Dict[str, Any]) -> Optional[str]:
"""Pop the ``api_content`` sidecar and substitute it into ``content``.
Used at every API-bound message-build site (the ``api_messages`` build in
``conversation_loop``, the max-iterations summary in
``chat_completion_helpers``, the chat-completions transport). The sidecar
carries the exact bytes previously sent to the API for this message when
they differ from the clean stored content; substituting it here keeps the
provider prompt-cache prefix byte-stable across turns.
Returns the popped sidecar string (for callers that need the value for
current-turn composition logic) or ``None`` when absent.
"""
sidecar = api_msg.pop("api_content", None)
if (
isinstance(sidecar, str)
and sidecar
and api_msg.get("role") in ("user", "assistant")
):
api_msg["content"] = sidecar
return sidecar
def drop_stale_api_content(msg: Dict[str, Any]) -> None:
"""Drop the ``api_content`` sidecar from a message whose content was rewritten.
Called from every content-rewrite path (historical image strip,
merge-summary-into-tail, consecutive-user repair merge, stale-confirmation
redaction). Replaying the pre-rewrite sidecar would resend exactly what
the rewrite removed, so it must be dropped the cost is one cache
boundary miss, never wrong content.
"""
msg.pop("api_content", None)
def extract_api_content_sidecar(msg: Mapping[str, Any]) -> Optional[str]:
"""Extract the ``api_content`` sidecar from a message dict for persistence.
Shared by the gateway/branch forwarding sites that copy the sidecar into a
new row. Returns the string sidecar or ``None`` when absent/non-string.
"""
v = msg.get("api_content")
return v if isinstance(v, str) else None
def reanchor_current_turn_user_idx(messages: List[Any], user_message: Any) -> int:
"""Locate this turn's user message after compaction rebuilt ``messages``.
Compression replaces list entries with fresh copies (and may append a
todo-snapshot user message or a restored user turn AFTER the surviving
copy of the current turn's message), so a pre-compression index is
meaningless. Prefer the LAST user message whose content exactly matches
this turn's text — the surviving copy in the common case — so the
injection stamp and the #48677 persist override can't land on a
todo-snapshot or historical row. Fall back to the last user message when
no exact match survives (merge-summary-into-tail rewrites the content but
the trackers still need a live anchor). Returns -1 when the list has no
user message at all.
"""
fallback = -1
for i in range(len(messages) - 1, -1, -1):
msg = messages[i]
if not (isinstance(msg, dict) and msg.get("role") == "user"):
continue
if fallback < 0:
fallback = i
if msg.get("content") == user_message:
return i
return fallback
def _compression_made_progress(
orig_len: int, new_len: int, orig_tokens: int, new_tokens: int
) -> bool:
@@ -242,7 +133,6 @@ def build_turn_context(
set_session_context,
set_current_write_origin,
ra,
moa_active: bool = False,
) -> TurnContext:
"""Run the once-per-turn setup and return the loop's input context.
@@ -489,34 +379,31 @@ def build_turn_context(
# Create the DB session row now that _cached_system_prompt is populated, so
# the persisted snapshot is written non-NULL on the first turn (Issue
# #45499). Idempotent: _ensure_db_session() no-ops once the row exists.
# Must run BEFORE preflight compression: in-place compaction inserts
# message rows referencing this session (archive_and_compact), and
# rotation creates a child with parent_session_id pointing at it — with
# PRAGMA foreign_keys=ON, a missing parent row fails both INSERTs on a
# fresh oversized first turn. The user-turn crash persist itself runs
# LATER (after memory prefetch / pre_llm_call), so the row is written
# once with its final api_content — both steps take the same per-agent
# persist lock as CLI close persistence.
# #45499). Keep row creation and the marker-based append in the same
# per-agent critical section as CLI close persistence.
persist_lock = getattr(agent, "_session_persist_lock", None)
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
# Crash-resilience: persist the inbound user turn as soon as the session row exists.
try:
if persist_lock is None:
agent._ensure_db_session()
_ensure_and_persist()
else:
with persist_lock:
agent._ensure_db_session()
_ensure_and_persist()
except Exception:
logger.warning(
"Turn-start session row creation failed for session=%s",
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Clear the staged CLI input eagerly (as the pre-refactor code did)
# so a crash in preflight compression — which runs between this row
# create and the late crash-persist below — doesn't leave a stale
# _pending_cli_user_message that the next turn would mistake for a
# fresh staged input.
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
@@ -524,7 +411,6 @@ def build_turn_context(
# Gate the (expensive) full token estimate behind a cheap pre-check.
# 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
if agent.compression_enabled and _should_run_preflight_estimate(
messages,
agent.context_compressor.protect_first_n,
@@ -592,7 +478,6 @@ def build_turn_context(
getattr(agent, "codex_app_server_auto_compaction", "native"),
)
elif _compressor.should_compress(_preflight_tokens):
_preflight_compressed = True
logger.info(
"Preflight compression: ~%s tokens >= %s threshold (model %s, ctx %s)",
f"{_preflight_tokens:,}",
@@ -636,19 +521,6 @@ def build_turn_context(
if not _compressor.should_compress(_preflight_tokens):
break
if _preflight_compressed:
# Compression rebuilt the list (tail messages are fresh compaction
# copies), so the pre-compression index of this turn's user message
# is stale. Re-anchor both index trackers: the api_content stamp
# below, the loop's injection site, and the flush's persist-override
# row (#48677) must all target the surviving dict, not a stale
# position. Exact-content match first so a todo-snapshot user message
# appended after the tail can't steal the anchor.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
# Plugin hook: pre_llm_call (context injected into user message, not system prompt).
plugin_user_context = ""
try:
@@ -738,92 +610,6 @@ def build_turn_context(
except Exception:
pass
# ── api_content sidecar: persist what you send ──
# The prefetch/plugin context above is injected into the API copy of this
# turn's user message, never into the stored content — so on the next
# turn the message would replay WITHOUT the injection, diverging the
# request prefix at this point and re-prefilling everything after it
# (the whole previous turn's assistant/tool chain). Stamp the exact
# API-bound bytes on the live dict, only when they differ from the clean
# content, so the crash persist below writes both in the same row and
# replay can reproduce the sent prefix byte-for-byte. Guarded by the
# same predicate the api_messages build uses, so the stamped bytes are
# exactly the bytes the loop sends. codex_app_server turns bypass the
# api_messages build entirely (the codex thread gets the plain user
# message), so stamping there would persist bytes that were never sent.
# MoA turns append per-call aggregated reference context to the same API
# copy AFTER this composition, so the stamped bytes would never match the
# wire either — skip the stamp rather than persist provably wrong "exact
# sent bytes" (MoA keeps its pre-sidecar cache behavior).
if (
not moa_active
and getattr(agent, "api_mode", None) != "codex_app_server"
and 0 <= current_turn_user_idx < len(messages)
and messages[current_turn_user_idx].get("role") == "user"
):
_turn_user_msg = messages[current_turn_user_idx]
_api_content = compose_user_api_content(
_turn_user_msg.get("content", ""), ext_prefetch_cache, plugin_user_context
)
if _api_content is not None and _api_content != _turn_user_msg.get("content"):
_turn_user_msg["api_content"] = _api_content
# In-place preflight compaction has ALREADY inserted this turn's
# user row (archive_and_compact runs before prefetch/pre_llm_call
# can compose the sidecar), and the crash persist below identity-
# skips every compacted dict (they are all in the rebound
# conversation_history) — so the stamp would never reach the DB.
# Backfill it onto the freshly-inserted row directly. Rotation
# mode needs nothing here: its compacted copies flush to the
# child session after this stamp.
if _preflight_compressed and bool(
getattr(agent, "_last_compaction_in_place", False)
):
_db = getattr(agent, "_session_db", None)
if _db is not None:
try:
_db.set_latest_user_api_content(
agent.session_id,
_turn_user_msg.get("content"),
_api_content,
)
except Exception:
logger.warning(
"in-place compaction api_content backfill failed "
"for session=%s",
agent.session_id or "none",
exc_info=True,
)
# Crash-resilience: persist the inbound user turn before the first LLM
# call. Runs after preflight compression (which rewrites history anyway)
# and after prefetch/pre_llm_call, so the user row is written once with
# its final api_content instead of being re-written mid-turn.
# Keep row creation and the marker-based append in the same per-agent
# critical section as CLI close persistence, and retry the row create if
# the pre-compression attempt above failed transiently.
def _ensure_and_persist() -> None:
agent._ensure_db_session()
agent._persist_session(messages, conversation_history)
try:
if persist_lock is None:
_ensure_and_persist()
else:
with persist_lock:
_ensure_and_persist()
except Exception:
logger.warning(
"Early turn-start session persistence failed for session=%s",
agent.session_id or "none",
exc_info=True,
)
finally:
# Keep an unmarked staged input available to a later close retry if the
# normal persistence attempt failed. Once the marker is present, the
# close path must no longer treat it as a pre-worker UI input.
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
return TurnContext(
user_message=user_message,
original_user_message=original_user_message,
+2 -37
View File
@@ -25,21 +25,6 @@ from __future__ import annotations
import os
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.message_content import flatten_message_text
def _is_pure_tool_call_tail(msg: dict) -> bool:
"""An assistant row with ``tool_calls`` but no visible text content of its own.
Such a row satisfies the role check (``tail role == "assistant"``) while
carrying none of the delivered answer see the #43849/#44100 invariant
block in :func:`finalize_turn`. Uses :func:`flatten_message_text` so that
multimodal (list-type) content is evaluated by its text parts, not just
its type.
"""
if not msg.get("tool_calls"):
return False
return not flatten_message_text(msg.get("content")).strip()
def finalize_turn(
@@ -237,31 +222,11 @@ def finalize_turn(
# holds regardless of which path produced it. (#43849 / #44100)
if final_response and not interrupted:
try:
_tail = messages[-1] if messages else None
_tail_role = messages[-1].get("role") if messages else None
except Exception:
_tail = None
_tail_role = _tail.get("role") if isinstance(_tail, dict) else None
_tail_role = None
if _tail_role != "assistant":
messages.append({"role": "assistant", "content": final_response})
elif isinstance(_tail, dict) and _is_pure_tool_call_tail(_tail):
# The tail IS an assistant row, but a *pure tool-call turn*:
# tool_calls with no text of its own. The role check alone
# leaves the #43849/#44100 invariant unmet — the user saw a
# response that never reached the transcript, and the next turn
# replays the user backlog and re-answers it (the very symptom
# this block was added for). Fill that row's empty content
# instead of appending, so the durable turn ends with the answer
# without disturbing the tool-call structure or creating an
# assistant→assistant pair.
_tail["content"] = final_response
# The row may have already been flushed to SQLite by the
# incremental tool-call persist (conversation_loop.py:4990),
# which stamps ``_DB_PERSISTED_MARKER`` so subsequent flushes
# skip it. Pop the marker so the next ``_persist_session``
# re-writes the filled content to the durable store —
# otherwise ``/resume`` reloads ``content=""`` and the bug
# resurfaces cross-session.
_tail.pop("_db_persisted", None)
# The model has completed its request, so replace API-local
# voice/model/skill guidance with the clean user input before writing the
@@ -733,13 +733,6 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
"HERMES_HOME".to_string(),
hermes_home.as_os_str().to_os_string(),
)];
// `hermes update` is a Python CLI writing to a pipe here, so CPython
// block-buffers its stdout: nothing reaches run_streamed (and the live
// log UI) until 8 KB accumulate or the process exits. Long quiet steps —
// the pre-update backup can zip multi-GB archives for minutes — render as
// a frozen stage, and users cancel a healthy update. Force line-by-line
// output instead.
envs.push(("PYTHONUNBUFFERED".to_string(), OsString::from("1")));
if let Some(path) = path_with_prepended_entries(&[
hermes_home.join("node").join("bin"),
venv_bin_dir(install_root),
@@ -1053,16 +1046,6 @@ mod tests {
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
}
#[test]
fn update_child_env_forces_unbuffered_python() {
let envs = update_child_env(Path::new("/x/hermes-agent"));
assert!(
envs.iter()
.any(|(k, v)| k == "PYTHONUNBUFFERED" && v.to_str() == Some("1")),
"update children must run unbuffered so long steps stream to the live log"
);
}
#[test]
fn lock_probe_paths_include_desktop_app_payload() {
let root = Path::new("/x/hermes-agent");
@@ -1073,12 +1056,7 @@ mod tests {
"venv shim remains part of the update lock probe"
);
assert!(
// Windows/Linux payloads live under `resources/`, the macOS bundle
// under `Contents/Resources/` — Path::ends_with is case-sensitive.
probes.iter().any(|p| {
p.ends_with(Path::new("resources/app.asar"))
|| p.ends_with(Path::new("Resources/app.asar"))
}),
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
);
}
+7 -411
View File
@@ -5,7 +5,6 @@ import http from 'node:http'
import https from 'node:https'
import os from 'node:os'
import path from 'node:path'
import tls from 'node:tls'
import { pathToFileURL } from 'node:url'
import {
@@ -113,7 +112,6 @@ import {
SESSION_WINDOW_MIN_HEIGHT,
SESSION_WINDOW_MIN_WIDTH
} from './session-windows'
import { ensureSpawnHelperExecutable } from './spawn-helper-perms'
import { nativeOverlayWidth as computeNativeOverlayWidth, macTitleBarOverlayHeight } from './titlebar-overlay-width'
import { resolveBehindCount, shouldCountCommits } from './update-count'
import { readLiveUpdateMarker, writeUpdateMarker } from './update-marker'
@@ -144,21 +142,6 @@ import {
getVenvSitePackagesEntries,
resolveVenvHermesCommand
} from './windows-hermes-path'
import {
alreadyHasNoSandbox,
buildNoSandboxRelaunchArgs,
decideWindowsSandboxLaunch,
fallbackMarker,
grantAllApplicationPackagesAcl,
markerAfterSuccessfulBoot,
readSandboxMarker,
type SandboxFallbackReason,
shouldAttemptAclRepair,
shouldRelaunchForGpuSandboxCrash,
shouldRelaunchForRendererSandboxCrashLoop,
writeSandboxMarker
} from './windows-sandbox-fallback'
import { installWindowsSystemCaTrust } from './windows-system-ca'
import { readWindowsUserEnvVar } from './windows-user-env'
import { isPackagedInstallPath as isPackagedInstallPathUnderRoots } from './workspace-cwd'
import { readWslWindowsClipboardImage } from './wsl-clipboard-image'
@@ -218,107 +201,6 @@ if (IS_WSL && !REMOTE_DISPLAY_REASON && fs.existsSync('/dev/dxg')) {
console.log('[hermes] WSL GPU passthrough (/dev/dxg) detected; enabling GPU acceleration')
}
// Windows sandbox / GPU breakpoint crash recovery (#38216).
//
// Some hosts (AMD RX 6000 drivers, orphan AppContainer SIDs under %LOCALAPPDATA%,
// missing S-1-15-2-2 ACEs) kill Chromium's sandboxed GPU/renderer children with
// 0x80000003. After enough GPU deaths the browser process FATAL-exits before the
// UI is usable. Must run before app `ready` so `--no-sandbox` applies to child
// processes. The sticky marker recovers Start Menu / shortcut launches that
// never go through `hermes desktop`; it is version-scoped so an app update
// re-probes the sandbox instead of degrading forever.
//
// `windowsSandboxFallbackActive` = this process runs without the Chromium
// sandbox (any cause, including a manual --no-sandbox flag) — guards the
// relaunch handlers. `windowsSandboxFallbackSticky` = the fallback machinery
// engaged and the marker must stay `fallback` after a successful boot; a
// manual flag alone is honored but never made sticky.
let windowsSandboxFallbackActive = false
let windowsSandboxFallbackSticky = false
let windowsSandboxFallbackReason: SandboxFallbackReason = 'boot-loop'
let windowsNoSandboxRelaunchAttempted = false
if (IS_WINDOWS) {
const windowsUserData = app.getPath('userData')
const priorMarker = readSandboxMarker(windowsUserData)
// Best-effort ACL repair, only when the last boot aborted or the fallback is
// engaged — icacls /T recurses the whole install tree, so healthy launches
// skip it (the installer already granted the ACE at install time). Repair
// targets the install dir only: granting AppContainer read on userData would
// expose Hermes sessions/config to every packaged app on the machine.
if (shouldAttemptAclRepair(priorMarker)) {
const exeDir = path.dirname(process.execPath)
const acl = grantAllApplicationPackagesAcl(exeDir, { execFileSync })
if (acl.ok) {
console.log(`[hermes] granted ALL APPLICATION PACKAGES RX on ${exeDir} (#38216)`)
} else if (acl.error && acl.error !== 'missing-target-or-exec') {
console.warn(`[hermes] AppContainer ACL grant failed on ${exeDir}: ${acl.error}`)
}
}
const sandboxDecision = decideWindowsSandboxLaunch({
argv: process.argv,
env: process.env,
marker: priorMarker,
appVersion: app.getVersion()
})
windowsSandboxFallbackActive = sandboxDecision.enable
windowsSandboxFallbackSticky = sandboxDecision.nextMarker.state === 'fallback'
if (sandboxDecision.nextMarker.state === 'fallback' && sandboxDecision.nextMarker.reason) {
windowsSandboxFallbackReason = sandboxDecision.nextMarker.reason
}
if (sandboxDecision.enable && sandboxDecision.reason !== 'already-enabled') {
app.commandLine.appendSwitch('no-sandbox')
process.env.ELECTRON_DISABLE_SANDBOX = '1'
console.log(
`[hermes] Windows sandbox fallback enabled (${sandboxDecision.reason}); launching with --no-sandbox (#38216)`
)
}
writeSandboxMarker(windowsUserData, sandboxDecision.nextMarker)
// Catch the first GPU breakpoint death and relaunch before Chromium's
// "GPU process isn't usable" FATAL abort ends the process with no recovery.
app.on('child-process-gone', (_event, details) => {
if (
!shouldRelaunchForGpuSandboxCrash({
details,
alreadyNoSandbox: windowsSandboxFallbackActive || alreadyHasNoSandbox(process.argv, process.env),
relaunchAttempted: windowsNoSandboxRelaunchAttempted
})
) {
return
}
windowsNoSandboxRelaunchAttempted = true
windowsSandboxFallbackActive = true
windowsSandboxFallbackSticky = true
windowsSandboxFallbackReason = 'gpu-breakpoint'
try {
writeSandboxMarker(app.getPath('userData'), fallbackMarker('gpu-breakpoint', app.getVersion()))
} catch {
void 0
}
console.warn(
`[hermes] Windows GPU sandbox crashed (exit=${details?.exitCode}); relaunching once with --no-sandbox (#38216)`
)
try {
app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) })
app.exit(0)
} catch (error) {
console.error(`[hermes] --no-sandbox relaunch failed: ${error?.message || error}`)
}
})
}
ipcMain.handle('hermes:get-remote-display-reason', () => REMOTE_DISPLAY_REASON)
// Keep the renderer running at full speed while the window is in the background
@@ -2077,33 +1959,6 @@ function persistWindowState() {
// resized/moved fire many times mid-drag on Linux; debounce to one write.
const schedulePersistWindowState = debounce(persistWindowState, 250)
// Zoom's primary store is a main-process JSON file. The renderer localStorage
// mirror lives under Electron's cache/storage folders, which crash recovery
// can move or recreate — wiping the zoom setting exactly when the user just
// recovered from a crash (#56726). JSON survives; localStorage is kept as a
// secondary mirror so pre-JSON installs migrate transparently on first read.
const DESKTOP_ZOOM_STATE_PATH = path.join(app.getPath('userData'), 'zoom-state.json')
function readZoomState() {
try {
const raw = JSON.parse(fs.readFileSync(DESKTOP_ZOOM_STATE_PATH, 'utf8'))
const level = Number(raw?.zoomLevel)
return Number.isFinite(level) ? level : null
} catch {
return null
}
}
function writeZoomState(zoomLevel) {
try {
fs.mkdirSync(path.dirname(DESKTOP_ZOOM_STATE_PATH), { recursive: true })
writeFileAtomic(DESKTOP_ZOOM_STATE_PATH, JSON.stringify({ zoomLevel }, null, 2))
} catch (error) {
rememberLog(`[zoom] json persist failed: ${error?.message || error}`)
}
}
// Match the backend's source resolution but bias toward a real git checkout.
// Dev → SOURCE_REPO_ROOT. Packaged/CLI install → ACTIVE_HERMES_ROOT.
// HERMES_DESKTOP_HERMES_ROOT always wins so devs can pin a worktree.
@@ -2860,13 +2715,8 @@ async function applyUpdatesPosixInApp(opts: any) {
// Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s
// npm build can find them on a machine with no system Node. Windows portable
// Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin.
// PYTHONUNBUFFERED: `hermes update` writes to a pipe here, so CPython
// block-buffers stdout and long quiet steps (the pre-update backup can zip
// multi-GB archives for minutes) stream nothing to the progress UI — users
// read the silence as a hang and cancel a healthy update.
const env: Record<string, string> = {
HERMES_HOME,
PYTHONUNBUFFERED: '1',
PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin'))
}
@@ -4911,11 +4761,6 @@ function setAndPersistZoomLevel(window, zoomLevel) {
// Apply + notify in one funnel so the settings UI stays in sync, including
// changes made via the keyboard shortcuts or the View menu.
const next = applyZoomLevel(window.webContents, zoomLevel)
// Primary store: main-process JSON (survives crash recovery — #56726).
writeZoomState(next)
// Secondary mirror: renderer localStorage (legacy store; kept in sync so a
// downgrade or JSON read failure still finds a sane value).
window.webContents
.executeJavaScript(
`try { localStorage.setItem(${JSON.stringify(ZOOM_STORAGE_KEY)}, ${JSON.stringify(String(next))}) } catch {}`
@@ -4928,19 +4773,6 @@ function restorePersistedZoomLevel(window) {
return
}
// Prefer the JSON file — it survives crash recovery wiping Electron's
// cache/storage folders (#56726). applyZoomLevel notifies the renderer so
// the Appearance UI Scale control stays in sync.
const saved = readZoomState()
if (saved != null) {
applyZoomLevel(window.webContents, saved)
return
}
// Fall back to localStorage for installs that predate zoom-state.json,
// migrating the value into the JSON store on first read.
window.webContents
.executeJavaScript(
`(() => { try { return localStorage.getItem(${JSON.stringify(ZOOM_STORAGE_KEY)}) } catch { return null } })()`
@@ -4952,8 +4784,7 @@ function restorePersistedZoomLevel(window) {
// Notify the renderer too — otherwise the Appearance UI Scale control
// can stay stuck at 100% even though the window zoom was restored.
const applied = applyZoomLevel(window.webContents, Number(stored))
writeZoomState(applied)
applyZoomLevel(window.webContents, Number(stored))
})
.catch(error => rememberLog(`[zoom] restore failed: ${error?.message || error}`))
}
@@ -4967,47 +4798,23 @@ function installZoomShortcuts(window) {
window.webContents.on('before-input-event', (event, input) => {
const mod = IS_MAC ? input.meta : input.control
if (!mod || input.alt) {
if (!mod || input.alt || input.shift) {
return
}
const key = input.key
if (key === '0') {
if (input.shift) {
return // Ctrl/Cmd+Shift+0 is not a zoom chord — leave it alone
}
event.preventDefault()
setAndPersistZoomLevel(window, 0)
} else if (key === '=' || key === '+') {
// Zoom-in must accept the shift modifier: on US layouts Plus is
// physically Shift+=, so Cmd+Plus arrives as Cmd+Shift+'+' (or '='
// depending on platform). The old blanket shift guard silently
// dropped keyboard zoom-in on macOS (#43517).
event.preventDefault()
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + ZOOM_STEP)
} else if (key === '-') {
if (input.shift) {
return // Shift+'-' is '_' territory on most layouts, not zoom-out
}
event.preventDefault()
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() - ZOOM_STEP)
}
})
// Ctrl/Cmd + mouse wheel — the standard desktop/browser zoom gesture
// (#40295). Chromium surfaces it as the main-process 'zoom-changed' event
// (wheel events are DOM-side, so before-input-event never sees them).
// Route through the same persist+notify funnel as the keyboard shortcuts
// so wheel zoom survives restarts and the settings Scale control stays in
// sync, and use the same half step for consistency.
window.webContents.on('zoom-changed', (event, zoomDirection) => {
event.preventDefault()
const delta = zoomDirection === 'in' ? ZOOM_STEP : -ZOOM_STEP
setAndPersistZoomLevel(window, window.webContents.getZoomLevel() + delta)
})
}
function installContextMenu(window) {
@@ -7197,14 +7004,11 @@ function wireCommonWindowHandlers(win, { zoom = true }: { zoom?: boolean } = {})
if (zoom) {
installZoomShortcuts(win)
// Re-apply persisted zoom on show/restore/resize/cross-display move
// (Chromium can drop webContents zoom after these window transitions) and
// on EVERY full load — not once. The crash-recovery path calls
// webContents.reload(), which fires did-finish-load again after a `once`
// listener is spent, so zoom was silently lost on renderer crash
// recovery and any in-place reload/navigation (#46429).
// Re-apply persisted zoom on show/restore/cross-display move (Windows can
// drop webContents zoom after minimize or a monitor-scale change) and on
// first load (reloads / crash recovery).
installZoomReassertOnWindowEvents(win, () => restorePersistedZoomLevel(win))
win.webContents.on('did-finish-load', () => restorePersistedZoomLevel(win))
win.webContents.once('did-finish-load', () => restorePersistedZoomLevel(win))
}
installContextMenu(win)
@@ -7513,29 +7317,6 @@ function createWindow() {
if (mainWindow && !mainWindow.isDestroyed()) {
mainWindow.show()
}
// Persist geometry as soon as the window is visible so a crash before the
// first clean resize/move/close still captures the restored bounds (#56726).
schedulePersistWindowState()
// #38216: clear the mid-boot marker only after a window is actually usable.
// Keep sticky `fallback` when we launched with --no-sandbox so the next
// Start Menu click does not re-enter the GPU FATAL crash loop. The marker
// records the app version so the next update re-probes the sandbox.
if (IS_WINDOWS) {
try {
writeSandboxMarker(
app.getPath('userData'),
markerAfterSuccessfulBoot({
fallbackActive: windowsSandboxFallbackSticky,
reason: windowsSandboxFallbackReason,
appVersion: app.getVersion()
})
)
} catch (error) {
rememberLog(`[sandbox] marker update after ready-to-show failed: ${error?.message || error}`)
}
}
})
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
@@ -7577,40 +7358,6 @@ function createWindow() {
`[renderer] suppressing reload: ${rendererReloadTimes.length} crashes within ${RENDERER_RELOAD_WINDOW_MS}ms (likely a crash loop)`
)
// #38216 renderer flavor (same recovery as #56726, credit @Sahil-SS9):
// a deterministic Windows renderer crash loop with the sandbox
// breakpoint signature gets one --no-sandbox relaunch instead of a
// dead window. Gated on the exit code so unrelated crash loops don't
// silently drop the sandbox.
if (
shouldRelaunchForRendererSandboxCrashLoop({
reason: details?.reason,
exitCode: details?.exitCode,
alreadyNoSandbox: windowsSandboxFallbackActive || alreadyHasNoSandbox(process.argv, process.env),
relaunchAttempted: windowsNoSandboxRelaunchAttempted
})
) {
windowsNoSandboxRelaunchAttempted = true
windowsSandboxFallbackActive = true
windowsSandboxFallbackSticky = true
windowsSandboxFallbackReason = 'renderer-crash-loop'
try {
writeSandboxMarker(app.getPath('userData'), fallbackMarker('renderer-crash-loop', app.getVersion()))
} catch {
void 0
}
rememberLog('[renderer] Windows sandbox crash loop detected; relaunching once with --no-sandbox (#38216)')
try {
app.relaunch({ args: buildNoSandboxRelaunchArgs(process.argv.slice(1)) })
app.exit(0)
} catch (err) {
rememberLog(`[renderer] --no-sandbox relaunch failed: ${err?.message || err}`)
}
}
return
}
@@ -7655,20 +7402,12 @@ function createWindow() {
mainWindow.loadURL(pathToFileURL(resolveRendererIndex()).toString())
}
// Start the Python backend NOW, in parallel with the renderer load — not on
// did-finish-load. The backend cold boot (spawn → port announce → /api/status)
// is the dominant startup cost, and serializing it behind Chromium's load
// added the whole renderer load time to first-usable-composer. The promise is
// shared (backendConnectionState), so the renderer's getConnection() joins
// this in-flight boot instead of duplicating it; early boot-progress events
// the renderer misses are recovered by its getBootProgress() pull on mount.
startHermes().catch(error => rememberLog(error.stack || error.message))
mainWindow.webContents.once('did-finish-load', () => {
// Zoom restore is handled by wireCommonWindowHandlers (shared with session
// windows); no need to reapply it here.
broadcastBootProgress()
sendWindowStateChanged()
startHermes().catch(error => rememberLog(error.stack || error.message))
})
}
@@ -8089,71 +7828,6 @@ async function interceptSessionRequestForRemote(request) {
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
}
// Batched sidebar slices. With no remote profiles the local batched endpoint
// (one DB open per profile) serves it directly — take the fast path. When
// remotes exist, fan the three slices back out to the per-slice
// /api/profiles/sessions path (which already merges remote rows correctly) and
// reassemble; local profiles fall back to three primary reads there, but
// remote correctness is preserved.
if (method === 'GET' && pathname === '/api/profiles/sessions/sidebar') {
const remoteProfiles = configuredRemoteProfileNames()
if (remoteProfiles.length === 0) {
return undefined // local fast path → batched endpoint's single DB open
}
const recentsProfile = (searchParams.get('recents_profile') || 'all').trim() || 'all'
const sliceParams = (limitKey, defaultLimit, extra) => {
const sp = new URLSearchParams({
limit: searchParams.get(limitKey) || defaultLimit,
offset: '0',
min_messages: '1',
archived: 'exclude',
order: 'recent',
...extra
})
return sp
}
const recentsSp = sliceParams('recents_limit', '20', { profile: recentsProfile })
const recentsExclude = searchParams.get('recents_exclude')
if (recentsExclude) {
recentsSp.set('exclude_sources', recentsExclude)
}
const cronSp = sliceParams('cron_limit', '50', { profile: 'all', source: 'cron' })
const messagingSp = sliceParams('messaging_limit', '100', { profile: 'all' })
const messagingExclude = searchParams.get('messaging_exclude')
if (messagingExclude) {
messagingSp.set('exclude_sources', messagingExclude)
}
const [recents, cron, messaging] = await Promise.all([
fetchProfilesSessionSlice(recentsSp, remoteProfiles),
fetchProfilesSessionSlice(cronSp, remoteProfiles),
fetchProfilesSessionSlice(messagingSp, remoteProfiles)
])
return {
recents: {
sessions: rowsOf(recents),
total: Number(recents?.total) || 0,
profile_totals: recents?.profile_totals || {}
},
cron: { sessions: rowsOf(cron) },
messaging: {
sessions: rowsOf(messaging),
total: Number(messaging?.total) || rowsOf(messaging).length
},
errors: []
}
}
// Per-session read/mutation. Owner is in ?profile= (reads) or request.profile
// (mutations). Two remote shapes:
// - per-profile override: route to that profile's own remote, sans profile
@@ -8218,30 +7892,6 @@ async function remoteSessionList(profile, searchParams) {
return { ...(data as any), sessions: rowsOf(data) }
}
// Resolve one /api/profiles/sessions slice with remote profiles spliced in —
// the same branch logic as the GET /api/profiles/sessions intercept, but always
// returns data (never `undefined`) so a batched caller can compose slices. A
// specific local profile reads from the local primary; a remote-override profile
// reads from its remote; 'all' merges every remote into the primary aggregate.
async function fetchProfilesSessionSlice(searchParams, remoteProfiles) {
const requested = (searchParams.get('profile') || 'all').trim() || 'all'
if (requested !== 'all') {
if (profileHasRemoteOverride(requested)) {
return remoteSessionList(requested, searchParams)
}
const primary = await ensureBackend(null)
return fetchJson(`${primary.baseUrl}/api/profiles/sessions?${searchParams}`, primary.token, {
method: 'GET',
timeoutMs: DEFAULT_FETCH_TIMEOUT_MS
}).catch(() => ({ sessions: [], total: 0, profile_totals: {} }))
}
return mergeRemoteProfileSessions(searchParams, remoteProfiles)
}
// Unified list: primary's local aggregate, with each remote profile's stale local
// rows/totals swapped for the remote's real ones, re-sorted by recency and
// re-windowed to the requested page. A dead remote contributes nothing rather
@@ -9006,39 +8656,7 @@ ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
}
})
// node-pty's published tarball ships the POSIX `spawn-helper` without an exec
// bit; the dev flow resolves node-pty straight from node_modules (nothing
// chmods it there), so the first terminal spawn dies with `posix_spawnp
// failed`. Restore the bit once, lazily, right before the first spawn. Packaged
// builds already stage an executable copy, so this is a no-op there.
let _spawnHelperEnsured = false
function ensureNodePtySpawnHelper() {
if (_spawnHelperEnsured || IS_WINDOWS) {
return
}
_spawnHelperEnsured = true
try {
const nodePtyRoot = path.dirname(require.resolve('node-pty/package.json'))
const { fixed, errors } = ensureSpawnHelperExecutable(nodePtyRoot)
for (const helperPath of fixed) {
rememberLog(`[terminal] restored +x on node-pty spawn-helper: ${helperPath}`)
}
for (const failure of errors) {
rememberLog(`[terminal] could not chmod spawn-helper ${failure.path}: ${failure.error}`)
}
} catch (error) {
rememberLog(`[terminal] spawn-helper exec check skipped: ${error instanceof Error ? error.message : String(error)}`)
}
}
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
ensureNodePtySpawnHelper()
const id = crypto.randomUUID()
const { args, command, name } = terminalShellCommand()
const cwd = safeTerminalCwd(payload?.cwd)
@@ -9531,16 +9149,6 @@ app.on('open-url', (event, url) => {
})
app.whenReady().then(() => {
const systemCa = installWindowsSystemCaTrust(tls)
if (systemCa.applied) {
rememberLog(
`[tls] trusting ${systemCa.systemCertificateCount} Windows system CA certificate(s) for backend connections`
)
} else if (systemCa.error) {
rememberLog(`[tls] could not load Windows system CA certificates: ${systemCa.error}`)
}
if (IS_MAC) {
Menu.setApplicationMenu(buildApplicationMenu())
} else {
@@ -9599,18 +9207,6 @@ function configureSpellChecker() {
}
app.on('before-quit', () => {
// Clean quit mid-boot should not trip next-launch --no-sandbox (#38216).
// FATAL GPU aborts skip before-quit, leaving the `booting` marker in place.
// Keyed on sticky (not active): a manual --no-sandbox run still records a
// clean quit, while an engaged fallback keeps its sticky marker.
if (IS_WINDOWS && !windowsSandboxFallbackSticky) {
try {
writeSandboxMarker(app.getPath('userData'), markerAfterSuccessfulBoot({ fallbackActive: false }))
} catch {
void 0
}
}
// The always-on-top overlay isn't a "real" app window; close it so a stray
// pet can't keep the process alive or float over a quit app.
closePetOverlay()
@@ -1,134 +0,0 @@
import assert from 'node:assert/strict'
import { join } from 'node:path'
import { test } from 'vitest'
import {
ensureSpawnHelperExecutable,
needsExecBit,
spawnHelperCandidates,
type SpawnHelperFs,
withExecBits
} from './spawn-helper-perms'
interface FakeFile {
mode: number
statThrows?: boolean
chmodThrows?: boolean
}
function fakeFs(
files: Record<string, FakeFile>,
dirs: Record<string, string[]> = {}
): SpawnHelperFs & { chmods: { path: string; mode: number }[] } {
const chmods: { path: string; mode: number }[] = []
return {
chmods,
existsSync(path) {
return path in files || path in dirs
},
readdirSync(path) {
return dirs[path] ?? []
},
statSync(path) {
const file = files[path]
if (!file || file.statThrows) {
throw new Error(`stat failed: ${path}`)
}
return { mode: file.mode }
},
chmodSync(path, mode) {
const file = files[path]
if (file?.chmodThrows) {
throw new Error(`chmod failed: ${path}`)
}
chmods.push({ path, mode })
if (file) {
file.mode = mode
}
}
}
}
test('needsExecBit / withExecBits treat any missing exec bit as non-executable', () => {
assert.equal(needsExecBit(0o644), true)
assert.equal(needsExecBit(0o755), false)
// Partial exec bits (owner only) still count as needing repair.
assert.equal(needsExecBit(0o744), true)
// Preserves read/write bits while adding exec for all three classes.
assert.equal(withExecBits(0o644), 0o755)
assert.equal(withExecBits(0o600), 0o711)
})
test('candidates cover every prebuild dir plus build/Release', () => {
const root = '/pkg/node-pty'
const fs = fakeFs({}, { [join(root, 'prebuilds')]: ['darwin-arm64', 'darwin-x64', 'linux-x64'] })
assert.deepEqual(spawnHelperCandidates(root, fs), [
join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper'),
join(root, 'prebuilds', 'darwin-x64', 'spawn-helper'),
join(root, 'prebuilds', 'linux-x64', 'spawn-helper'),
join(root, 'build', 'Release', 'spawn-helper')
])
})
test('chmods only the non-executable spawn-helpers, leaving 0755 copies alone', () => {
const root = '/pkg/node-pty'
const arm = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper')
const x64 = join(root, 'prebuilds', 'darwin-x64', 'spawn-helper')
const fs = fakeFs(
{
[arm]: { mode: 0o644 },
[x64]: { mode: 0o755 }
},
{ [join(root, 'prebuilds')]: ['darwin-arm64', 'darwin-x64'] }
)
const result = ensureSpawnHelperExecutable(root, fs)
assert.deepEqual(result.fixed, [arm])
assert.deepEqual(result.errors, [])
assert.deepEqual(fs.chmods, [{ path: arm, mode: 0o755 }])
})
test('missing spawn-helpers are skipped without error', () => {
const root = '/pkg/node-pty'
const fs = fakeFs({}, { [join(root, 'prebuilds')]: ['darwin-arm64'] })
const result = ensureSpawnHelperExecutable(root, fs)
assert.deepEqual(result.fixed, [])
assert.deepEqual(result.errors, [])
assert.deepEqual(fs.chmods, [])
})
test('chmod failures are collected, not thrown', () => {
const root = '/pkg/node-pty'
const arm = join(root, 'prebuilds', 'darwin-arm64', 'spawn-helper')
const fs = fakeFs({ [arm]: { mode: 0o644, chmodThrows: true } }, { [join(root, 'prebuilds')]: ['darwin-arm64'] })
const result = ensureSpawnHelperExecutable(root, fs)
assert.deepEqual(result.fixed, [])
assert.equal(result.errors.length, 1)
assert.equal(result.errors[0].path, arm)
})
test('no prebuilds dir (Windows layout) is a clean no-op', () => {
const root = '/pkg/node-pty'
const fs = fakeFs({}, {})
const result = ensureSpawnHelperExecutable(root, fs)
assert.deepEqual(result.fixed, [])
assert.deepEqual(result.errors, [])
})
-113
View File
@@ -1,113 +0,0 @@
// node-pty ships its POSIX `spawn-helper` inside the published npm tarball with
// mode 0644 (no exec bit). node-pty `posix_spawnp`s that helper on macOS/Linux,
// so a non-executable helper fails every terminal spawn with
// `Error: posix_spawnp failed.`. Packaged builds are covered because
// stage-native-deps.mjs chmods the staged copy, but the dev flow
// (`npm run dev` → `electron .`) resolves node-pty straight from
// `node_modules/`, which nobody chmods. This restores the exec bits at runtime,
// best-effort, so both dev and any environment that stripped the bit keep
// working. Idempotent: files that are already executable are left untouched.
import {
chmodSync as realChmodSync,
existsSync as realExistsSync,
readdirSync as realReaddirSync,
statSync as realStatSync
} from 'node:fs'
import { join } from 'node:path'
const EXEC_BITS = 0o111
export interface SpawnHelperFs {
existsSync(path: string): boolean
readdirSync(path: string): string[]
statSync(path: string): { mode: number }
chmodSync(path: string, mode: number): void
}
export interface EnsureSpawnHelperResult {
fixed: string[]
errors: { path: string; error: string }[]
}
const defaultFs: SpawnHelperFs = {
existsSync: realExistsSync,
readdirSync: (path: string) => realReaddirSync(path),
statSync: (path: string) => realStatSync(path),
chmodSync: realChmodSync
}
// True when any of the owner/group/other execute bits are missing.
export function needsExecBit(mode: number): boolean {
return (mode & EXEC_BITS) !== EXEC_BITS
}
// Preserve existing permission bits, adding execute for owner/group/other.
export function withExecBits(mode: number): number {
return mode | EXEC_BITS
}
// Every place a `spawn-helper` can live under a node-pty package root: one per
// bundled prebuild (`prebuilds/<platform>-<arch>/`) plus a locally compiled
// `build/Release/` copy. Windows layouts have no spawn-helper, so the list is
// naturally empty there.
export function spawnHelperCandidates(
nodePtyRoot: string,
fs: Pick<SpawnHelperFs, 'existsSync' | 'readdirSync'> = defaultFs
): string[] {
const candidates: string[] = []
const prebuilds = join(nodePtyRoot, 'prebuilds')
if (fs.existsSync(prebuilds)) {
for (const entry of fs.readdirSync(prebuilds)) {
candidates.push(join(prebuilds, entry, 'spawn-helper'))
}
}
candidates.push(join(nodePtyRoot, 'build', 'Release', 'spawn-helper'))
return candidates
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error)
}
// Best-effort: ensure every existing spawn-helper under `nodePtyRoot` is
// executable. Never throws — missing files are skipped, and chmod/stat failures
// are collected so the caller can log them without breaking terminal startup.
export function ensureSpawnHelperExecutable(
nodePtyRoot: string,
fs: SpawnHelperFs = defaultFs
): EnsureSpawnHelperResult {
const result: EnsureSpawnHelperResult = { fixed: [], errors: [] }
for (const path of spawnHelperCandidates(nodePtyRoot, fs)) {
if (!fs.existsSync(path)) {
continue
}
let mode: number
try {
mode = fs.statSync(path).mode
} catch (error) {
result.errors.push({ path, error: errorMessage(error) })
continue
}
if (!needsExecBit(mode)) {
continue
}
try {
fs.chmodSync(path, withExecBits(mode))
result.fixed.push(path)
} catch (error) {
result.errors.push({ path, error: errorMessage(error) })
}
}
return result
}
@@ -1,366 +0,0 @@
import assert from 'node:assert/strict'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { test } from 'vitest'
import {
ALL_APPLICATION_PACKAGES_SID,
alreadyHasNoSandbox,
BOOT_ABORTS_BEFORE_FALLBACK,
buildIcaclsGrantArgs,
buildNoSandboxRelaunchArgs,
decideWindowsSandboxLaunch,
fallbackMarker,
grantAllApplicationPackagesAcl,
isWindowsSandboxBreakpointExit,
markerAfterSuccessfulBoot,
parseSandboxMarker,
readSandboxMarker,
sandboxMarkerPath,
shouldAttemptAclRepair,
shouldRelaunchForGpuSandboxCrash,
shouldRelaunchForRendererSandboxCrashLoop,
WINDOWS_SANDBOX_BREAKPOINT_EXIT,
WINDOWS_SANDBOX_MARKER_FILENAME,
writeSandboxMarker
} from './windows-sandbox-fallback'
test('isWindowsSandboxBreakpointExit recognizes signed and unsigned STATUS_BREAKPOINT', () => {
assert.equal(isWindowsSandboxBreakpointExit(WINDOWS_SANDBOX_BREAKPOINT_EXIT), true)
assert.equal(isWindowsSandboxBreakpointExit(-2147483645), true)
assert.equal(isWindowsSandboxBreakpointExit(0x80000003), true)
assert.equal(isWindowsSandboxBreakpointExit(1), false)
assert.equal(isWindowsSandboxBreakpointExit('nope'), false)
})
test('alreadyHasNoSandbox honors argv and ELECTRON_DISABLE_SANDBOX', () => {
assert.equal(alreadyHasNoSandbox(['--foo', '--no-sandbox'], {}), true)
assert.equal(alreadyHasNoSandbox([], { ELECTRON_DISABLE_SANDBOX: '1' }), true)
assert.equal(alreadyHasNoSandbox([], { ELECTRON_DISABLE_SANDBOX: 'true' }), true)
assert.equal(alreadyHasNoSandbox(['--disable-gpu'], {}), false)
})
test('decideWindowsSandboxLaunch stays off outside Windows and on clean markers', () => {
assert.equal(decideWindowsSandboxLaunch({ platform: 'linux', marker: { state: 'booting' } }).enable, false)
const cleanOk = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'ok' },
argv: [],
env: {}
})
assert.equal(cleanOk.enable, false)
assert.deepEqual(cleanOk.nextMarker, { state: 'booting' })
const noMarker = decideWindowsSandboxLaunch({ platform: 'win32', marker: null, argv: [], env: {} })
assert.equal(noMarker.enable, false)
assert.deepEqual(noMarker.nextMarker, { state: 'booting' })
})
test('a single mid-boot abort does NOT drop the sandbox (two-strike rule)', () => {
// First abort: prior launch left `booting` with no abort count. Could be a
// task-manager kill or power loss — sandbox stays ON, strike recorded.
const first = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'booting' },
argv: [],
env: {}
})
assert.equal(first.enable, false)
assert.deepEqual(first.nextMarker, { state: 'booting', bootAborts: 1 })
// Second consecutive abort: deterministic crash loop → fallback engages.
const second = decideWindowsSandboxLaunch({
platform: 'win32',
marker: first.nextMarker,
argv: [],
env: {},
appVersion: '1.2.3'
})
assert.equal(second.enable, true)
assert.equal(second.reason, 'boot-loop')
assert.deepEqual(second.nextMarker, { state: 'fallback', reason: 'boot-loop', version: '1.2.3' })
assert.equal(BOOT_ABORTS_BEFORE_FALLBACK, 2)
})
test('sticky fallback persists within one app version', () => {
const decision = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'fallback', reason: 'gpu-breakpoint', version: '1.2.3' },
argv: [],
env: {},
appVersion: '1.2.3'
})
assert.equal(decision.enable, true)
assert.equal(decision.reason, 'sticky-fallback')
assert.equal(decision.nextMarker.state, 'fallback')
assert.equal(decision.nextMarker.reason, 'gpu-breakpoint')
})
test('an app update re-probes the sandbox once instead of degrading forever', () => {
// Version changed since the fallback engaged → probe with sandbox ON.
const reprobe = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'fallback', reason: 'boot-loop', version: '1.2.3' },
argv: [],
env: {},
appVersion: '1.3.0'
})
assert.equal(reprobe.enable, false)
assert.equal(reprobe.nextMarker.state, 'booting')
assert.equal(reprobe.nextMarker.reprobe, true)
// The re-probe boot aborted → straight back to fallback, no second strike.
const failedReprobe = decideWindowsSandboxLaunch({
platform: 'win32',
marker: reprobe.nextMarker,
argv: [],
env: {},
appVersion: '1.3.0'
})
assert.equal(failedReprobe.enable, true)
assert.equal(failedReprobe.reason, 'reprobe-failed')
assert.equal(failedReprobe.nextMarker.state, 'fallback')
assert.equal(failedReprobe.nextMarker.version, '1.3.0')
// A legacy fallback marker without a version stays sticky (no re-probe).
const legacy = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'fallback' },
argv: [],
env: {},
appVersion: '1.3.0'
})
assert.equal(legacy.enable, true)
assert.equal(legacy.reason, 'sticky-fallback')
})
test('manual --no-sandbox is honored but never made sticky', () => {
const manual = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'ok' },
argv: ['--no-sandbox'],
env: {}
})
assert.equal(manual.enable, true)
assert.equal(manual.reason, 'already-enabled')
assert.equal(manual.nextMarker.state, 'booting')
// But a relaunch-written fallback marker is preserved through the flagged boot.
const relaunched = decideWindowsSandboxLaunch({
platform: 'win32',
marker: { state: 'fallback', reason: 'gpu-breakpoint', version: '1.2.3' },
argv: ['--no-sandbox'],
env: {},
appVersion: '1.2.3'
})
assert.equal(relaunched.enable, true)
assert.equal(relaunched.nextMarker.state, 'fallback')
})
test('marker transitions after a successful boot', () => {
assert.deepEqual(markerAfterSuccessfulBoot({ fallbackActive: false }), { state: 'ok' })
assert.deepEqual(markerAfterSuccessfulBoot({ fallbackActive: true, reason: 'gpu-breakpoint', appVersion: '1.2.3' }), {
state: 'fallback',
reason: 'gpu-breakpoint',
version: '1.2.3'
})
})
test('shouldAttemptAclRepair only fires on evidence of trouble', () => {
assert.equal(shouldAttemptAclRepair(null), false)
assert.equal(shouldAttemptAclRepair({ state: 'ok' }), false)
assert.equal(shouldAttemptAclRepair({ state: 'booting' }), true)
assert.equal(shouldAttemptAclRepair({ state: 'fallback' }), true)
})
test('sandbox marker round-trips through the userData file', () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'hermes-sandbox-marker-'))
try {
assert.equal(sandboxMarkerPath(dir), path.join(dir, WINDOWS_SANDBOX_MARKER_FILENAME))
assert.equal(readSandboxMarker(dir), null)
writeSandboxMarker(dir, { state: 'booting', bootAborts: 1 })
assert.deepEqual(readSandboxMarker(dir), { state: 'booting', bootAborts: 1 })
writeSandboxMarker(dir, fallbackMarker('renderer-crash-loop', '1.2.3'))
assert.deepEqual(readSandboxMarker(dir), {
state: 'fallback',
reason: 'renderer-crash-loop',
version: '1.2.3'
})
assert.equal(parseSandboxMarker({ state: 'fallback' })?.state, 'fallback')
assert.equal(parseSandboxMarker({ state: 'nope' }), null)
// Unknown reason strings and junk fields are dropped, not fatal.
assert.deepEqual(parseSandboxMarker({ state: 'fallback', reason: 'weird', bootAborts: -3 }), {
state: 'fallback'
})
} finally {
fs.rmSync(dir, { recursive: true, force: true })
}
})
test('buildIcaclsGrantArgs targets ALL APPLICATION PACKAGES with inherited RX', () => {
assert.deepEqual(buildIcaclsGrantArgs('C:\\Hermes\\win-unpacked'), [
'C:\\Hermes\\win-unpacked',
'/grant',
`*${ALL_APPLICATION_PACKAGES_SID}:(OI)(CI)(RX)`,
'/T',
'/C',
'/Q'
])
})
test('grantAllApplicationPackagesAcl is a no-op off Windows and reports exec failures', () => {
assert.deepEqual(grantAllApplicationPackagesAcl('C:\\x', { platform: 'darwin' }), { ok: false })
const calls: Array<{ file: string; args: readonly string[] }> = []
const ok = grantAllApplicationPackagesAcl('C:\\Hermes', {
platform: 'win32',
execFileSync(file, args) {
calls.push({ file, args })
return Buffer.alloc(0)
}
})
assert.deepEqual(ok, { ok: true })
assert.equal(calls.length, 1)
assert.equal(calls[0]?.file, 'icacls')
assert.deepEqual(calls[0]?.args, buildIcaclsGrantArgs('C:\\Hermes'))
const failed = grantAllApplicationPackagesAcl('C:\\Hermes', {
platform: 'win32',
execFileSync() {
throw new Error('access denied')
}
})
assert.equal(failed.ok, false)
assert.match(String(failed.error), /access denied/)
})
test('shouldRelaunchForGpuSandboxCrash only fires once for GPU breakpoint deaths', () => {
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'win32',
details: { type: 'GPU', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: false,
relaunchAttempted: false
}),
true
)
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'win32',
details: { type: 'GPU', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: true,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'win32',
details: { type: 'GPU', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: false,
relaunchAttempted: true
}),
false
)
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'win32',
details: { type: 'renderer', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForGpuSandboxCrash({
platform: 'linux',
details: { type: 'GPU', exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT },
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
})
test('renderer crash-loop relaunch requires the sandbox breakpoint signature', () => {
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'win32',
reason: 'crashed',
exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT,
alreadyNoSandbox: false,
relaunchAttempted: false
}),
true
)
// Unrelated renderer crash loops (plain crash, OOM churn) keep the sandbox.
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'win32',
reason: 'crashed',
exitCode: 1,
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'win32',
reason: 'oom',
exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT,
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'win32',
reason: 'crashed',
exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT,
alreadyNoSandbox: true,
relaunchAttempted: false
}),
false
)
assert.equal(
shouldRelaunchForRendererSandboxCrashLoop({
platform: 'linux',
reason: 'crashed',
exitCode: WINDOWS_SANDBOX_BREAKPOINT_EXIT,
alreadyNoSandbox: false,
relaunchAttempted: false
}),
false
)
})
test('buildNoSandboxRelaunchArgs appends a single --no-sandbox flag', () => {
assert.deepEqual(buildNoSandboxRelaunchArgs(['--foo', '--no-sandbox', 'hermes://x']), [
'--foo',
'hermes://x',
'--no-sandbox'
])
})
@@ -1,394 +0,0 @@
/**
* Windows Chromium/Electron sandbox recovery for #38216.
*
* On some Windows hosts the GPU/renderer sandboxes die with STATUS_BREAKPOINT
* (`0x80000003` / exit `-2147483645`). Chromium then FATAL-exits
* ("GPU process isn't usable. Goodbye.") before the UI is usable.
*
* Recovery ladder, all scoped to win32:
*
* 1. ACL repair (first line): grant `S-1-15-2-2` (ALL APPLICATION PACKAGES)
* RX on the install tree. A missing ACE plus orphan AppContainer SIDs is a
* known Chromium CHECK failure (electron/electron#51761). Runs at install
* time, and again at launch ONLY when the marker shows a prior aborted
* boot never on healthy launches (icacls /T recursion is not free).
* 2. `--no-sandbox` (second line): enabled only on strong evidence
* a signature-confirmed GPU/renderer breakpoint death, or TWO consecutive
* mid-boot aborts (a single abort can be a task-manager kill or power
* loss; the reported failure mode is a deterministic 100% crash loop).
* 3. The fallback is sticky per app version, not forever: after an update
* the sandbox is re-probed once (a new Electron or an installer-applied
* ACL grant may have fixed the host). If the re-probe boot aborts, the
* next launch goes straight back to `--no-sandbox`.
*
* Pure helpers stay injectable so tests never boot Electron or touch real ACLs.
*/
import fs from 'node:fs'
import path from 'node:path'
export const WINDOWS_SANDBOX_MARKER_FILENAME = 'windows-sandbox-fallback.json'
/** Well-known SID for "ALL APPLICATION PACKAGES". */
export const ALL_APPLICATION_PACKAGES_SID = 'S-1-15-2-2'
/** STATUS_BREAKPOINT as a signed Win32 exit code (WER / Chromium). */
export const WINDOWS_SANDBOX_BREAKPOINT_EXIT = -2147483645
/** Consecutive mid-boot aborts required before enabling --no-sandbox. */
export const BOOT_ABORTS_BEFORE_FALLBACK = 2
export type SandboxMarkerState = 'booting' | 'fallback' | 'ok'
export type SandboxFallbackReason = 'gpu-breakpoint' | 'renderer-crash-loop' | 'boot-loop'
export interface SandboxMarker {
state: SandboxMarkerState
/** Why the fallback engaged (state === 'fallback'). */
reason?: SandboxFallbackReason
/** App version that entered fallback — a version change triggers a re-probe. */
version?: string
/** Consecutive aborted boots observed so far (state === 'booting'). */
bootAborts?: number
/** This boot is a sandbox re-probe after an app update; an abort returns
* straight to fallback instead of restarting the two-strike count. */
reprobe?: boolean
}
export function sandboxMarkerPath(userDataDir: string): string {
return path.join(String(userDataDir || ''), WINDOWS_SANDBOX_MARKER_FILENAME)
}
export function isWindowsSandboxBreakpointExit(exitCode: unknown): boolean {
const n = Number(exitCode)
if (!Number.isFinite(n)) {
return false
}
// Signed STATUS_BREAKPOINT, or the same 32-bit pattern as unsigned.
return n === WINDOWS_SANDBOX_BREAKPOINT_EXIT || n >>> 0 === 0x80000003
}
export function alreadyHasNoSandbox(argv: readonly string[] = [], env: NodeJS.ProcessEnv = process.env): boolean {
if (Array.isArray(argv) && argv.some(arg => arg === '--no-sandbox')) {
return true
}
const disable = String(env.ELECTRON_DISABLE_SANDBOX || '')
.trim()
.toLowerCase()
return disable === '1' || disable === 'true' || disable === 'yes' || disable === 'on'
}
const FALLBACK_REASONS: readonly string[] = ['gpu-breakpoint', 'renderer-crash-loop', 'boot-loop']
export function parseSandboxMarker(raw: unknown): SandboxMarker | null {
if (!raw || typeof raw !== 'object') {
return null
}
const record = raw as Record<string, unknown>
const state = record.state
if (state !== 'booting' && state !== 'fallback' && state !== 'ok') {
return null
}
const marker: SandboxMarker = { state }
if (typeof record.reason === 'string' && FALLBACK_REASONS.includes(record.reason)) {
marker.reason = record.reason as SandboxFallbackReason
}
if (typeof record.version === 'string' && record.version) {
marker.version = record.version
}
const aborts = Number(record.bootAborts)
if (Number.isInteger(aborts) && aborts > 0) {
marker.bootAborts = aborts
}
if (record.reprobe === true) {
marker.reprobe = true
}
return marker
}
export function readSandboxMarker(userDataDir: string, { readFileSync = fs.readFileSync } = {}): SandboxMarker | null {
try {
const raw = JSON.parse(readFileSync(sandboxMarkerPath(userDataDir), 'utf8'))
return parseSandboxMarker(raw)
} catch {
return null
}
}
export function writeSandboxMarker(
userDataDir: string,
marker: SandboxMarker,
{
mkdirSync = fs.mkdirSync,
writeFileSync = fs.writeFileSync
}: {
mkdirSync?: typeof fs.mkdirSync
writeFileSync?: typeof fs.writeFileSync
} = {}
): void {
const dir = String(userDataDir || '')
if (!dir) {
return
}
mkdirSync(dir, { recursive: true })
writeFileSync(sandboxMarkerPath(dir), `${JSON.stringify(marker)}\n`, 'utf8')
}
export interface SandboxLaunchDecision {
enable: boolean
reason: string | null
/** Marker to persist immediately, before GPU/sandbox children start. */
nextMarker: SandboxMarker
}
/**
* Single launch-time transition: decide whether this Windows launch disables
* the Chromium sandbox AND what the marker becomes for crash-detection on the
* next launch.
*
* - `booting` left behind the prior launch aborted mid-boot. One abort is
* tolerated (could be a kill/power loss); the SECOND consecutive abort or
* a single abort during a post-update re-probe engages the fallback.
* - `fallback` is sticky within one app version. A version change re-probes
* the sandbox once so a fixed host (new Electron, installer ACL repair)
* returns to full sandboxing instead of degrading forever.
* - A manual `--no-sandbox` / ELECTRON_DISABLE_SANDBOX launch is honored but
* NOT made sticky: the marker keeps its normal lifecycle so the flag's
* removal restores the sandbox.
*/
export function decideWindowsSandboxLaunch(
options: {
platform?: NodeJS.Platform | string
argv?: readonly string[]
env?: NodeJS.ProcessEnv
marker?: SandboxMarker | null
appVersion?: string
} = {}
): SandboxLaunchDecision {
const appVersion = String(options.appVersion || '')
if ((options.platform ?? process.platform) !== 'win32') {
return { enable: false, reason: null, nextMarker: { state: 'booting' } }
}
const argv = options.argv ?? process.argv
const env = options.env ?? process.env
const marker = options.marker ?? null
if (alreadyHasNoSandbox(argv, env)) {
// Honor the explicit flag; keep the marker lifecycle unchanged. When the
// relaunch path set the flag, the fallback marker it wrote is preserved.
const nextMarker: SandboxMarker = marker?.state === 'fallback' ? marker : { state: 'booting' }
return { enable: true, reason: 'already-enabled', nextMarker }
}
if (marker?.state === 'fallback') {
if (marker.version && appVersion && marker.version !== appVersion) {
// App updated since the fallback engaged — re-probe the sandbox once.
return {
enable: false,
reason: null,
nextMarker: { state: 'booting', reprobe: true, bootAborts: 0 }
}
}
return {
enable: true,
reason: 'sticky-fallback',
nextMarker: { ...marker, version: marker.version || appVersion || undefined }
}
}
if (marker?.state === 'booting') {
const abortsObserved = (marker.bootAborts ?? 0) + 1
if (marker.reprobe) {
// The one post-update sandboxed re-probe aborted → back to fallback.
return {
enable: true,
reason: 'reprobe-failed',
nextMarker: fallbackMarker('boot-loop', appVersion)
}
}
if (abortsObserved >= BOOT_ABORTS_BEFORE_FALLBACK) {
return {
enable: true,
reason: 'boot-loop',
nextMarker: fallbackMarker('boot-loop', appVersion)
}
}
return {
enable: false,
reason: null,
nextMarker: { state: 'booting', bootAborts: abortsObserved }
}
}
// No marker, or a clean `ok` from the previous run.
return { enable: false, reason: null, nextMarker: { state: 'booting' } }
}
export function fallbackMarker(reason: SandboxFallbackReason, appVersion?: string): SandboxMarker {
const marker: SandboxMarker = { state: 'fallback', reason }
if (appVersion) {
marker.version = appVersion
}
return marker
}
/**
* After the main window reaches ready-to-show: keep the sticky fallback when
* we launched with `--no-sandbox`, otherwise mark a clean boot so future
* launches trust the sandbox again.
*/
export function markerAfterSuccessfulBoot(options: {
fallbackActive: boolean
reason?: SandboxFallbackReason
appVersion?: string
}): SandboxMarker {
if (!options.fallbackActive) {
return { state: 'ok' }
}
return fallbackMarker(options.reason ?? 'boot-loop', options.appVersion)
}
/**
* ACL repair is not free (`icacls /T` recurses the whole install tree), so it
* only runs when there is evidence of trouble: a prior launch aborted
* mid-boot, or the fallback already engaged. Healthy hosts never pay for it
* the installer already granted the ACE at install time.
*/
export function shouldAttemptAclRepair(marker: SandboxMarker | null | undefined): boolean {
return marker?.state === 'booting' || marker?.state === 'fallback'
}
/**
* Build `icacls` argv that grants ALL APPLICATION PACKAGES RX with inheritance.
* `/T` applies to existing children (win-unpacked DLLs); `/C` continues on
* errors; `/Q` stays quiet for installer logs.
*/
export function buildIcaclsGrantArgs(targetDir: string): string[] {
return [String(targetDir), '/grant', `*${ALL_APPLICATION_PACKAGES_SID}:(OI)(CI)(RX)`, '/T', '/C', '/Q']
}
export function grantAllApplicationPackagesAcl(
targetDir: string,
{
platform = process.platform,
execFileSync
}: {
platform?: NodeJS.Platform | string
execFileSync?: (file: string, args: readonly string[], options?: object) => Buffer | string
} = {}
): { ok: boolean; error?: string } {
if (platform !== 'win32') {
return { ok: false }
}
const dir = String(targetDir || '').trim()
if (!dir || typeof execFileSync !== 'function') {
return { ok: false, error: 'missing-target-or-exec' }
}
try {
execFileSync('icacls', buildIcaclsGrantArgs(dir), {
windowsHide: true,
timeout: 30_000,
stdio: 'ignore'
})
return { ok: true }
} catch (error) {
return {
ok: false,
error: error instanceof Error ? error.message : String(error)
}
}
}
/**
* True when a GPU child died with the #38216 breakpoint signature and we
* should one-shot relaunch with `--no-sandbox` before Chromium FATAL-exits.
*/
export function shouldRelaunchForGpuSandboxCrash(options: {
platform?: NodeJS.Platform | string
details?: { type?: string; exitCode?: number | string } | null
alreadyNoSandbox?: boolean
relaunchAttempted?: boolean
}): boolean {
if ((options.platform ?? process.platform) !== 'win32') {
return false
}
if (options.alreadyNoSandbox || options.relaunchAttempted) {
return false
}
const type = String(options.details?.type || '').toLowerCase()
if (type !== 'gpu') {
return false
}
return isWindowsSandboxBreakpointExit(options.details?.exitCode)
}
/**
* True when a renderer crash loop carries the sandbox breakpoint signature
* and a one-shot `--no-sandbox` relaunch should replace the dead window
* (#38216 renderer flavor; same recovery as #56726). Gated on the breakpoint
* exit code so unrelated renderer crash loops (bad extension, OOM churn)
* don't silently drop the sandbox.
*/
export function shouldRelaunchForRendererSandboxCrashLoop(options: {
platform?: NodeJS.Platform | string
reason?: string
exitCode?: number | string
alreadyNoSandbox?: boolean
relaunchAttempted?: boolean
}): boolean {
if ((options.platform ?? process.platform) !== 'win32') {
return false
}
if (options.alreadyNoSandbox || options.relaunchAttempted) {
return false
}
if (String(options.reason || '') !== 'crashed') {
return false
}
return isWindowsSandboxBreakpointExit(options.exitCode)
}
export function buildNoSandboxRelaunchArgs(argv: readonly string[]): string[] {
const args = (Array.isArray(argv) ? argv : []).filter(arg => arg !== '--no-sandbox')
args.push('--no-sandbox')
return args
}
@@ -1,96 +0,0 @@
import assert from 'node:assert/strict'
import { test } from 'vitest'
import { installWindowsSystemCaTrust, type NodeTlsCaApi } from './windows-system-ca'
function fakeTlsApi(
defaults: string[] = ['bundled-ca', 'extra-ca'],
system: string[] = ['windows-root-ca']
): NodeTlsCaApi & { installed: string[][] } {
const installed: string[][] = []
return {
installed,
getCACertificates(type = 'default') {
return type === 'system' ? [...system] : [...defaults]
},
setDefaultCACertificates(certificates) {
installed.push([...certificates])
}
}
}
test('installs Windows system CAs without dropping existing defaults', () => {
const tlsApi = fakeTlsApi(['mozilla-root', 'extra-ca'], ['machine-root', 'user-root'])
const result = installWindowsSystemCaTrust(tlsApi, 'win32')
assert.deepEqual(tlsApi.installed, [['mozilla-root', 'extra-ca', 'machine-root', 'user-root']])
assert.deepEqual(result, {
applied: true,
systemCertificateCount: 2,
totalCertificateCount: 4
})
})
test('does not inspect or replace CAs outside Windows', () => {
let reads = 0
const tlsApi: NodeTlsCaApi = {
getCACertificates() {
reads += 1
return []
},
setDefaultCACertificates() {
throw new Error('should not install')
}
}
const result = installWindowsSystemCaTrust(tlsApi, 'darwin')
assert.equal(reads, 0)
assert.deepEqual(result, {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 0
})
})
test('leaves the existing defaults untouched when Windows has no system CAs', () => {
const tlsApi = fakeTlsApi(['mozilla-root'], [])
const result = installWindowsSystemCaTrust(tlsApi, 'win32')
assert.deepEqual(tlsApi.installed, [])
assert.deepEqual(result, {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 1
})
})
test('fails open when the runtime cannot load the Windows certificate store', () => {
const tlsApi: NodeTlsCaApi = {
getCACertificates(type = 'default') {
if (type === 'system') {
throw new Error('certificate store unavailable')
}
return ['mozilla-root']
},
setDefaultCACertificates() {
throw new Error('should not install')
}
}
const result = installWindowsSystemCaTrust(tlsApi, 'win32')
assert.deepEqual(result, {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 0,
error: 'certificate store unavailable'
})
})
@@ -1,53 +0,0 @@
interface NodeTlsCaApi {
getCACertificates(type?: 'default' | 'system'): string[]
setDefaultCACertificates(certificates: string[]): void
}
interface WindowsSystemCaResult {
applied: boolean
systemCertificateCount: number
totalCertificateCount: number
error?: string
}
function installWindowsSystemCaTrust(tlsApi: NodeTlsCaApi, platform = process.platform): WindowsSystemCaResult {
if (platform !== 'win32') {
return {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 0
}
}
try {
const defaultCertificates = tlsApi.getCACertificates('default')
const systemCertificates = tlsApi.getCACertificates('system')
if (systemCertificates.length === 0) {
return {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: defaultCertificates.length
}
}
const certificates = [...defaultCertificates, ...systemCertificates]
tlsApi.setDefaultCACertificates(certificates)
return {
applied: true,
systemCertificateCount: systemCertificates.length,
totalCertificateCount: certificates.length
}
} catch (error) {
return {
applied: false,
systemCertificateCount: 0,
totalCertificateCount: 0,
error: error instanceof Error ? error.message : String(error)
}
}
}
export { installWindowsSystemCaTrust }
export type { NodeTlsCaApi, WindowsSystemCaResult }
+8 -56
View File
@@ -6,17 +6,16 @@
import assert from 'node:assert/strict'
import { test, vi } from 'vitest'
import { test } from 'vitest'
import {
applyZoomLevel,
clampZoomLevel,
installZoomReassertOnWindowEvents,
percentToZoomLevel,
ZOOM_RESIZE_REASSERT_DELAY_MS,
ZOOM_REASSERT_WINDOW_EVENTS,
ZOOM_STORAGE_KEY,
zoomLevelToPercent,
zoomReassertWindowEvents,
zoomWiringForWindowKind
} from './zoom'
@@ -65,7 +64,7 @@ test('extreme percentages clamp to the level bounds', () => {
assert.equal(percentToZoomLevel(1_000_000), 9)
})
test('installZoomReassertOnWindowEvents wires show, restore, resize, and cross-display moves on macOS and Windows', () => {
test('installZoomReassertOnWindowEvents wires show, restore, and cross-display moves', () => {
const handlers = new Map()
const win = {
@@ -76,62 +75,15 @@ test('installZoomReassertOnWindowEvents wires show, restore, resize, and cross-d
}
let calls = 0
installZoomReassertOnWindowEvents(
win,
() => {
calls += 1
},
'win32'
)
installZoomReassertOnWindowEvents(win, () => {
calls += 1
})
assert.deepEqual([...handlers.keys()], zoomReassertWindowEvents('win32'))
assert.deepEqual([...handlers.keys()], [...ZOOM_REASSERT_WINDOW_EVENTS])
handlers.get('show')()
handlers.get('restore')()
handlers.get('resized')()
handlers.get('moved')()
assert.equal(calls, 4)
})
test('installZoomReassertOnWindowEvents debounces Linux resize and move events at the trailing edge', () => {
vi.useFakeTimers()
try {
const handlers = new Map()
let destroyed = false
const win = {
isDestroyed: () => destroyed,
on(event, listener) {
handlers.set(event, listener)
}
}
let calls = 0
installZoomReassertOnWindowEvents(
win,
() => {
calls += 1
},
'linux'
)
assert.deepEqual([...handlers.keys()], zoomReassertWindowEvents('linux'))
handlers.get('resize')()
vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2)
handlers.get('move')()
vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2)
assert.equal(calls, 0)
vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS / 2)
assert.equal(calls, 1)
handlers.get('resize')()
destroyed = true
vi.advanceTimersByTime(ZOOM_RESIZE_REASSERT_DELAY_MS)
assert.equal(calls, 1)
} finally {
vi.useRealTimers()
}
assert.equal(calls, 3)
})
test('installZoomReassertOnWindowEvents skips destroyed windows', () => {
+7 -26
View File
@@ -48,42 +48,23 @@ export function applyZoomLevel(webContents, level) {
return clamped
}
// Chromium can drop webContents zoom when a BrowserWindow is resized, minimized
// and restored, or crosses onto a monitor with different display scaling. macOS
// and Windows provide trailing `resized`/`moved` events; Linux only provides the
// noisy `resize`/`move` pair, so debounce those fallbacks before re-applying the
// persisted level.
export const ZOOM_RESIZE_REASSERT_DELAY_MS = 100
// Chromium on Windows can drop webContents zoom when a BrowserWindow is minimized
// and restored or crosses onto a monitor with different display scaling. Re-apply
// the persisted level after each completed lifecycle transition.
export const ZOOM_REASSERT_WINDOW_EVENTS = ['show', 'restore', 'moved']
export function zoomReassertWindowEvents(platform = process.platform) {
return platform === 'linux' ? ['show', 'restore', 'resize', 'move'] : ['show', 'restore', 'resized', 'moved']
}
export function installZoomReassertOnWindowEvents(win, reassert, platform = process.platform) {
export function installZoomReassertOnWindowEvents(win, reassert) {
if (!win?.on) {
return
}
let resizeTimer
for (const event of zoomReassertWindowEvents(platform)) {
for (const event of ZOOM_REASSERT_WINDOW_EVENTS) {
win.on(event, () => {
if (win.isDestroyed?.()) {
return
}
if (event !== 'resize' && event !== 'move') {
reassert()
return
}
clearTimeout(resizeTimer)
resizeTimer = setTimeout(() => {
if (!win.isDestroyed?.()) {
reassert()
}
}, ZOOM_RESIZE_REASSERT_DELAY_MS)
reassert()
})
}
}
@@ -1,159 +0,0 @@
// Measure a profile switch end-to-end: click a profile square in the rail,
// then break the wall time into the phases the renderer can observe:
// - getConnection IPC (Electron: pool backend spawn / reuse + readiness)
// - gateway WS connect
// - swap-target clear ($gatewaySwapTarget → sidebar loader gone)
// - sidebar session rows for the new profile painted
//
// Instruments window.hermesDesktop.getConnection + WebSocket to timestamp the
// phases without touching app code.
//
// Usage:
// node apps/desktop/scripts/measure-profile-switch.mjs <profileName> [settleTimeoutMs]
const CDP_HTTP = 'http://127.0.0.1:9222'
const PROFILE = process.argv[2]
const SETTLE_TIMEOUT = Number(process.argv[3] || 60000)
if (!PROFILE) {
console.error('usage: measure-profile-switch.mjs <profileName>')
process.exit(1)
}
class CDP {
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
static async open(url) {
const ws = new WebSocket(url)
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
const cdp = new CDP(ws)
ws.addEventListener('message', (ev) => {
const m = JSON.parse(ev.data.toString())
if (m.id != null && cdp.pending.has(m.id)) {
const { resolve, reject } = cdp.pending.get(m.id)
cdp.pending.delete(m.id)
if (m.error) reject(new Error(m.error.message))
else resolve(m.result)
}
})
ws.addEventListener('close', () => {
for (const { reject } of cdp.pending.values()) reject(new Error('CDP socket closed'))
cdp.pending.clear()
})
return cdp
}
send(method, params) {
const id = ++this.id
return new Promise((res, rej) => {
this.pending.set(id, { resolve: res, reject: rej })
this.ws.send(JSON.stringify({ id, method, params }))
})
}
async eval(expr) {
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval failed')
return r.result.value
}
close() { this.ws.close() }
}
async function main() {
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
if (!target) { console.error('renderer not found on 9222'); process.exit(1) }
const cdp = await CDP.open(target.webSocketDebuggerUrl)
// Instrument getConnection + WebSocket once.
await cdp.eval(`(() => {
if (window.__PROFILE_SWITCH_OBS__) return 'already'
const obs = { events: [] }
const mark = (name, extra) => obs.events.push({ name, t: performance.now(), ...(extra || {}) })
window.__PROFILE_SWITCH_OBS__ = obs
window.__psMark = mark
const desktop = window.hermesDesktop
if (desktop && desktop.getConnection) {
const orig = desktop.getConnection.bind(desktop)
desktop.getConnection = async (profile) => {
mark('getConnection:start', { profile })
try {
const res = await orig(profile)
mark('getConnection:done', { profile })
return res
} catch (e) {
mark('getConnection:error', { profile, error: String(e).slice(0, 120) })
throw e
}
}
}
const OrigWS = window.WebSocket
window.WebSocket = function (url, ...rest) {
const ws = new OrigWS(url, ...rest)
if (String(url).includes('/api/ws')) {
mark('ws:new', { url: String(url).replace(/token=[^&]+/, 'token=…').slice(0, 90) })
ws.addEventListener('open', () => mark('ws:open'))
}
return ws
}
window.WebSocket.prototype = OrigWS.prototype
Object.assign(window.WebSocket, OrigWS)
return 'installed'
})()`)
const before = await cdp.eval(`(() => {
const rail = document.querySelector('[data-slot="profile-rail"]')
return {
railButtons: rail ? [...rail.querySelectorAll('[role="tab"], button')].map(b => (b.getAttribute('aria-label') || b.title || b.textContent || '').slice(0, 30)) : [],
sessions: document.querySelectorAll('[data-slot="sidebar-session-row"], [data-session-id]').length
}
})()`)
console.log('rail buttons:', JSON.stringify(before.railButtons))
const clicked = await cdp.eval(`(() => {
window.__psMark('click', { profile: ${JSON.stringify(PROFILE)} })
const rail = document.querySelector('[data-slot="profile-rail"]')
if (!rail) return 'no-rail'
const target = [...rail.querySelectorAll('button, [role="tab"]')].find(b =>
((b.getAttribute('aria-label') || '') + ' ' + (b.title || '') + ' ' + (b.textContent || '')).toLowerCase().includes(${JSON.stringify(PROFILE.toLowerCase())}))
if (!target) return 'not-found'
target.click()
return 'clicked'
})()`)
console.log('click:', clicked)
if (clicked !== 'clicked') { cdp.close(); process.exit(2) }
// Poll until the swap settles: loader gone + session rows painted (or empty
// list settled) + active profile pill shows the target.
const t0 = Date.now()
let settled = null
while (Date.now() - t0 < SETTLE_TIMEOUT) {
await new Promise((r) => setTimeout(r, 100))
const s = await cdp.eval(`(() => {
// The swap overlay stays mounted at opacity-0 after the swap — check the
// computed opacity of the container that holds the "Waking up …" label.
const label = [...document.querySelectorAll('div[aria-hidden]')].find(el => /waking up/i.test(el.textContent || ''))
const overlayVisible = label ? Number(getComputedStyle(label).opacity) > 0.05 : false
return {
t: performance.now(),
overlayVisible,
sessions: document.querySelectorAll('[data-slot="row-button"]').length
}
})()`)
if (!s.overlayVisible && s.sessions > 0) { settled = s; break }
}
await new Promise((r) => setTimeout(r, 400))
const obs = await cdp.eval('window.__PROFILE_SWITCH_OBS__')
const events = obs.events
const click = events.find((e) => e.name === 'click' && e.profile === PROFILE)
console.log('\n=== PHASES (ms after click) ===')
for (const e of events) {
if (e.t < click.t - 5) continue
console.log(`${(e.t - click.t).toFixed(0).padStart(7)} ${e.name}${e.profile ? ' [' + e.profile + ']' : ''}${e.error ? ' ' + e.error : ''}${e.url ? ' ' + e.url : ''}`)
}
console.log(settled ? `\nsettled (loader gone + rows painted) at ~${Date.now() - t0} ms wall` : '\nTIMEOUT waiting for settle')
cdp.close()
}
main().catch((e) => { console.error(e); process.exit(1) })
@@ -10,7 +10,7 @@
import { writeFileSync } from 'node:fs'
const CDP_HTTP = process.env.CDP_HTTP || 'http://127.0.0.1:9222'
const CDP_HTTP = 'http://127.0.0.1:9222'
const A = process.argv[2]
const B = process.argv[3]
const ROUNDS = Number(process.argv[4] || 2)
@@ -1,71 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { $activeSessionId, $currentModel, setCurrentModel, setCurrentModelSource } from '@/store/session'
import { ModelPill } from './model-pill'
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
canSwitch: true,
model: 'gpt-6',
provider: 'openai',
...over
})
afterEach(() => {
cleanup()
$activeSessionId.set(null)
setCurrentModel('')
setCurrentModelSource('')
})
// #62055: a manual composer pick is sticky and silently overrides the
// Settings → Model default for every NEW chat. The pill must say so.
describe('ModelPill pinned-override badge', () => {
it('shows the pin dot on a draft running a manual pick', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set(null)
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
})
it('stays quiet when the composer reflects the profile default', () => {
setCurrentModel('google/gemma-4-26b-a4b-it:free')
setCurrentModelSource('default')
$activeSessionId.set(null)
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
})
it('stays quiet on a live session (footer shows that session, not the pin)', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set('live-1')
render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
})
it('is exercised in both render paths', () => {
setCurrentModel('deepseek/deepseek-v4-flash')
setCurrentModelSource('manual')
$activeSessionId.set(null)
// Fallback (no live menu) path.
const { unmount } = render(<ModelPill disabled={false} model={modelState()} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
unmount()
// Live-menu (dropdown) path.
render(<ModelPill disabled={false} model={modelState({ modelMenuContent: <div /> })} />)
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash')
})
})
@@ -11,10 +11,8 @@ import { ChevronDown } from '@/lib/icons'
import { formatModelStatusLabel } from '@/lib/model-status-label'
import { cn } from '@/lib/utils'
import {
$activeSessionId,
$currentFastMode,
$currentModel,
$currentModelSource,
$currentProvider,
$currentReasoningEffort,
setModelPickerOpen
@@ -46,17 +44,8 @@ export function ModelPill({
const currentProvider = useStore($currentProvider)
const fastMode = useStore($currentFastMode)
const reasoningEffort = useStore($currentReasoningEffort)
const modelSource = useStore($currentModelSource)
const activeSessionId = useStore($activeSessionId)
const [open, setOpen] = useState(false)
// The composer pick is sticky: a manual selection is pinned and every NEW
// chat uses it instead of the Settings → Model default — silently, which has
// cost users real money on a forgotten paid-model pick (#62055). Surface the
// pin whenever a draft (no live session) is running on a manual override. A
// live session's footer reflects that session's model, so no badge there.
const pinnedOverride = !activeSessionId && modelSource === 'manual' && Boolean(currentModel.trim())
// The model resolves a beat after the gateway/session comes up. Rather than
// flash a literal "No model", show a quiet loader (inherits the pill text
// color at half opacity) until a model lands.
@@ -69,14 +58,6 @@ export function ModelPill({
) : (
<GlyphSpinner className="opacity-50" spinner="braille" />
)}
{pinnedOverride && (
<span
aria-label={copy.modelPinned}
className="size-1 shrink-0 rounded-full bg-(--ui-accent)"
data-testid="model-pinned-dot"
role="img"
/>
)}
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
</>
)
@@ -90,15 +71,11 @@ export function ModelPill({
)
: PILL
const baseTitle = currentProvider
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
: copy.switchModel
const title = pinnedOverride ? `${baseTitle}${copy.modelPinned}` : baseTitle
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
if (!model.modelMenuContent) {
return (
<Tip label={pinnedOverride ? `${copy.openModelPicker}${copy.modelPinned}` : copy.openModelPicker} side="top">
<Tip label={copy.openModelPicker} side="top">
<Button
aria-label={copy.openModelPicker}
className={pillClass}
+2 -10
View File
@@ -23,7 +23,7 @@ import { cn } from '@/lib/utils'
import { $pinnedSessionIds } from '@/store/layout'
import { $petActive } from '@/store/pet'
import { $petOverlayActive } from '@/store/pet-overlay'
import { $gatewaySwapTarget, $profiles } from '@/store/profile'
import { $gatewaySwapTarget } from '@/store/profile'
import {
$contextSuggestions,
$freshDraftReady,
@@ -50,7 +50,6 @@ import { useComposerScope } from './composer/scope'
import type { ChatBarState } from './composer/types'
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
import { ProfileTag } from './profile-tag'
import { useRuntimeMessageRepository } from './runtime-repository'
import { ScrollToBottomButton } from './scroll-to-bottom-button'
import { useSessionView } from './session-view'
@@ -102,18 +101,12 @@ function ChatHeader({
}: ChatHeaderProps) {
const sessions = useStore($sessions)
const pinnedSessionIds = useStore($pinnedSessionIds)
const profiles = useStore($profiles)
const activeStoredSession =
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
// Which agent/persona owns this chat — glanceable in the header once a
// second profile exists, so the open session's ownership is never ambiguous
// (#66003). Single-profile users see the unchanged header.
const showProfileTag = profiles.length > 1 && Boolean(activeStoredSession)
// Pins live on the durable lineage-root id, but selectedSessionId is the live
// (tip) id — resolve through the loaded row so the menu reflects the pin
// state after auto-compression rotates the id.
@@ -133,13 +126,12 @@ function ChatHeader({
return (
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
<div
className={cn(titlebarHeaderTitleClass, showProfileTag && 'flex items-center')}
className={titlebarHeaderTitleClass}
style={{
maxWidth:
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
}}
>
{showProfileTag && <ProfileTag className="pointer-events-auto mr-1.5" profile={activeStoredSession?.profile} />}
<SessionActionsMenu
align="start"
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
@@ -1,49 +0,0 @@
import { cleanup, render, screen } from '@testing-library/react'
import { atom } from 'nanostores'
import { afterEach, describe, expect, it, vi } from 'vitest'
// Keep store/profile's side-effecting imports inert (gateway socket layer +
// REST client) — same seam as store/profile.test.ts.
vi.mock('@/store/gateway', () => ({
$gateway: atom<unknown>(null),
ensureGatewayForProfile: vi.fn(async () => undefined)
}))
vi.mock('@/hermes', () => ({
getProfiles: vi.fn(async () => ({ profiles: [] })),
setApiRequestProfile: vi.fn()
}))
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
const { ProfileTag } = await import('./profile-tag')
const { setProfileColor } = await import('@/store/profile')
afterEach(cleanup)
describe('ProfileTag', () => {
it('shows the profile initial with an accessible owner label', () => {
render(<ProfileTag profile="xavier" />)
const tag = screen.getByRole('img', { name: 'Profile: xavier' })
expect(tag.textContent).toBe('x')
})
it('normalizes an empty profile to default and stays neutral', () => {
render(<ProfileTag profile="" />)
const tag = screen.getByRole('img', { name: 'Profile: default' })
expect(tag.textContent).toBe('d')
// Default/root profile carries no identity color.
expect(tag.style.color).toBe('')
})
it('uses the profile identity color (user override wins)', () => {
setProfileColor('xavier', 'hsl(120 68% 58%)')
render(<ProfileTag profile="xavier" />)
const tag = screen.getByRole('img', { name: 'Profile: xavier' })
// jsdom normalizes hsl() to rgb(); assert the override landed, not the format.
expect(tag.style.color).toBe('rgb(75, 221, 75)')
})
})
-36
View File
@@ -1,36 +0,0 @@
import { useStore } from '@nanostores/react'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
import { cn } from '@/lib/utils'
import { $profileColors, normalizeProfileKey } from '@/store/profile'
/** Owning-profile chip: soft profile-tint square with the initial, tooltip +
* accessible label carrying the full name. Same visual language as the
* profile rail; the default profile stays neutral. Identity, not status
* session state dots keep their own semantics (#66003). */
export function ProfileTag({ className, profile }: { className?: string; profile: null | string | undefined }) {
const { t } = useI18n()
const colors = useStore($profileColors)
const key = normalizeProfileKey(profile)
const color = resolveProfileColor(key, colors)
const hue = color ?? 'var(--ui-text-quaternary)'
const label = t.sidebar.row.ownedByProfile(key)
return (
<Tip label={label}>
<span
aria-label={label}
className={cn(
'grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none',
className
)}
role="img"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{key.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
</Tip>
)
}
+2 -3
View File
@@ -93,10 +93,11 @@ import {
$sessions,
$sessionsLoading,
$sessionsTotal,
$workingSessionIds,
sessionPinId,
setCurrentCwd
} from '@/store/session'
import { $focusedStoredSessionId, $workingSessionIds, type SplitDir } from '@/store/session-states'
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
import {
type AppView,
@@ -1231,7 +1232,6 @@ export function ChatSidebar({
pinned={false}
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
sessions={searchResults}
showProfileTags={showAllProfiles}
workingSessionIdSet={workingSessionIdSet}
/>
)}
@@ -1254,7 +1254,6 @@ export function ChatSidebar({
pinned
rootClassName="shrink-0 p-0 pb-1"
sessions={pinnedSessions}
showProfileTags={showAllProfiles}
sortable={pinnedSessions.length > 1}
workingSessionIdSet={workingSessionIdSet}
/>
@@ -66,8 +66,6 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
import { PROFILES_ROUTE } from '../../routes'
import { useProfilePrewarm } from './use-profile-prewarm'
const RAIL_GAP = 4 // px — matches gap-1 between squares.
// Past this many profiles the strip of colored squares stops scaling (tiny
@@ -459,40 +457,30 @@ function ProfileDropdown({
<SelectValue placeholder={p.title} />
</SelectTrigger>
<SelectContent collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }} side="top">
{profiles.map(profile => (
<ProfileDropdownItem
color={resolveProfileColor(profile.name, colors)}
key={profile.name}
name={profile.name}
/>
))}
{profiles.map(profile => {
const color = resolveProfileColor(profile.name, colors)
const hue = color ?? 'var(--ui-text-quaternary)'
return (
<SelectItem key={profile.name} value={profile.name}>
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{profile.name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
<span className="truncate">{profile.name}</span>
</span>
</SelectItem>
)
})}
</SelectContent>
</Select>
)
}
// One dropdown row per profile — its own component so each row can own a
// hover-intent prewarm timer (see useProfilePrewarm).
function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) {
const hue = color ?? 'var(--ui-text-quaternary)'
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name)
return (
<SelectItem onPointerEnter={startPrewarm} onPointerLeave={cancelPrewarm} value={name}>
<span className="flex min-w-0 items-center gap-1.5">
<span
aria-hidden="true"
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
>
{name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
</span>
<span className="truncate">{name}</span>
</span>
</SelectItem>
)
}
interface ProfilePillProps {
active: boolean
// home / All / Manage are glyph action buttons (navigation, not identity).
@@ -560,9 +548,6 @@ function ProfileSquare({
const [pickerOpen, setPickerOpen] = useState(false)
const pressTimer = useRef<null | number>(null)
const suppressClick = useRef(false)
// Hovering a square telegraphs the switch — start that profile's backend
// spawn now so a cold click doesn't pay the full boot.
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(label)
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
id: label,
@@ -652,11 +637,7 @@ function ProfileSquare({
setPickerOpen(true)
}, LONG_PRESS_MS)
}}
onPointerEnter={startPrewarm}
onPointerLeave={() => {
clearPress()
cancelPrewarm()
}}
onPointerLeave={clearPress}
onPointerUp={clearPress}
>
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
@@ -1,7 +1,6 @@
import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { ProfileTag } from '@/app/chat/profile-tag'
import { startSessionDrag } from '@/app/chat/session-drag'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
@@ -15,13 +14,12 @@ import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
import { openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { useProfilePrewarm } from './use-profile-prewarm'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
session: SessionInfo
@@ -38,10 +36,6 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
/** Tag the row with its owning profile (initial chip + tooltip). Used by
* flat cross-profile lists Pinned and search results in the All-profiles
* view where no group header communicates ownership (#66003). */
showProfile?: boolean
}
const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const
@@ -67,7 +61,6 @@ export function SidebarSessionRow({
reorderable = false,
dragging = false,
dragHandleProps,
showProfile = false,
className,
style,
ref,
@@ -75,7 +68,6 @@ export function SidebarSessionRow({
}: SidebarSessionRowProps) {
const { t } = useI18n()
const r = t.sidebar.row
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(session.profile)
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
@@ -169,12 +161,6 @@ export function SidebarSessionRow({
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
}}
// Hovering a row from another profile (the all-profiles view) telegraphs
// a cross-profile resume — start that backend's spawn now so the click
// doesn't pay the full cold boot. Same-profile rows no-op inside
// prewarmProfileBackend.
onPointerEnter={startPrewarm}
onPointerLeave={cancelPrewarm}
ref={ref}
style={style}
{...rest}
@@ -259,7 +245,6 @@ export function SidebarSessionRow({
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</SidebarRowLabel>
{showProfile && <ProfileTag profile={session.profile} />}
</SidebarRowBody>
</SidebarRowShell>
</SessionContextMenu>
@@ -135,10 +135,6 @@ interface SidebarSessionsSectionProps {
// Rendered atop the entered-project body (a "back to overview" row).
projectBackRow?: React.ReactNode
dndSensors?: ReturnType<typeof useSensors>
// Tag every row with its owning profile. Set on the flat cross-profile
// lists (Pinned / search results) in the All-profiles view, where no group
// header communicates ownership (#66003).
showProfileTags?: boolean
}
export function SidebarSessionsSection({
@@ -178,8 +174,7 @@ export function SidebarSessionsSection({
onReorderSessions,
onReorderProjects,
projectBackRow,
dndSensors,
showProfileTags = false
dndSensors
}: SidebarSessionsSectionProps) {
const sectionOpen = collapsible ? open : true
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
@@ -208,8 +203,7 @@ export function SidebarSessionsSection({
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id),
reorderable: draggable && !branchStem,
session,
showProfile: showProfileTags
session
}
return draggable && !branchStem ? (
@@ -317,7 +311,6 @@ export function SidebarSessionsSection({
onResumeSession={onResumeSession}
onTogglePin={onTogglePin}
pinned={pinned}
showProfileTags={showProfileTags}
sortable={sessionsDraggable}
workingSessionIdSet={workingSessionIdSet}
/>
@@ -1,38 +0,0 @@
import { useCallback, useEffect, useRef } from 'react'
import { prewarmProfileBackend } from '@/store/profile'
// Dwell before firing: long enough that sweeping the pointer across the rail
// or a mixed-profile session list doesn't spawn a backend for every element
// passed through, short enough to beat the click by hundreds of ms.
const PREWARM_DWELL_MS = 120
/**
* pointerenter/pointerleave handlers that pre-warm `profile`'s pool backend
* after a short hover dwell (see prewarmProfileBackend in store/profile).
* Consumers merge these with their own pointer handlers.
*/
export function useProfilePrewarm(profile: string | null | undefined) {
const timer = useRef<null | number>(null)
const profileRef = useRef(profile)
profileRef.current = profile
const cancelPrewarm = useCallback(() => {
if (timer.current != null) {
clearTimeout(timer.current)
timer.current = null
}
}, [])
useEffect(() => cancelPrewarm, [cancelPrewarm])
const startPrewarm = useCallback(() => {
cancelPrewarm()
timer.current = window.setTimeout(() => {
timer.current = null
prewarmProfileBackend(profileRef.current || 'default')
}, PREWARM_DWELL_MS)
}, [cancelPrewarm])
return { cancelPrewarm, startPrewarm }
}
@@ -21,7 +21,6 @@ interface SessionRowCommonProps {
onPin: () => void
onResume: () => void
reorderable?: boolean
showProfile?: boolean
}
interface VirtualSessionListProps {
@@ -34,7 +33,6 @@ interface VirtualSessionListProps {
onResumeSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
pinned: boolean
showProfileTags?: boolean
sortable: boolean
workingSessionIdSet: Set<string>
}
@@ -52,7 +50,6 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
onResumeSession,
onTogglePin,
pinned,
showProfileTags = false,
sortable,
workingSessionIdSet
}) => {
@@ -93,8 +90,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
onDelete: () => onDeleteSession(session.id),
onPin: () => onTogglePin(sessionPinId(session)),
onResume: () => onResumeSession(session.id),
reorderable,
showProfile: showProfileTags
reorderable
}
return reorderable ? (
@@ -3,8 +3,7 @@ import { useEffect, useRef } from 'react'
import { setPetActivity } from '@/store/pet'
import { setPetScale } from '@/store/pet-gallery'
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
import { $sessions } from '@/store/session'
import { $attentionSessionIds } from '@/store/session-states'
import { $attentionSessionIds, $sessions } from '@/store/session'
import { isSecondaryWindow } from '@/store/windows'
import type { GatewayRequester } from '../types'
@@ -28,16 +28,18 @@ import { notify, notifyError } from '@/store/notifications'
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
import {
$activeSessionId,
$attentionSessionIds,
$connection,
$currentCwd,
$sessions,
$workingSessionIds,
ensureDefaultWorkspaceCwd,
setConnection,
setCurrentBranch,
setCurrentCwd,
setSessionsLoading
} from '@/store/session'
import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states'
import { resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
@@ -284,14 +286,10 @@ export function useGatewayBoot({
return
}
// Same shape as boot(): profile first (session scope depends on it),
// then the independent fetches concurrently.
await adoptPrimaryProfile()
await Promise.all([
seedDefaultCwd(),
callbacksRef.current.refreshHermesConfig().catch(() => undefined),
callbacksRef.current.refreshSessions().catch(() => undefined)
])
await seedDefaultCwd()
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
await callbacksRef.current.refreshSessions().catch(() => undefined)
completeDesktopBoot()
bootCompleted = true
} catch (err) {
@@ -464,11 +462,6 @@ export function useGatewayBoot({
return
}
// Profile adoption must land first: refreshSessions scopes its fetch by
// $profileScope ← $activeGatewayProfile. The remaining three fetches
// (cwd seed, config, sessions) are independent REST calls — running
// them serially added their sum to time-to-populated-sidebar when only
// the max is needed.
await adoptPrimaryProfile()
setDesktopBootStep({
@@ -476,17 +469,20 @@ export function useGatewayBoot({
message: translateNow('boot.steps.loadingSettings'),
progress: 97
})
await seedDefaultCwd()
await Promise.all([
seedDefaultCwd(),
callbacksRef.current.refreshHermesConfig(),
callbacksRef.current.refreshSessions()
])
await callbacksRef.current.refreshHermesConfig()
if (cancelled) {
return
}
setDesktopBootStep({
phase: 'renderer.sessions',
message: translateNow('boot.steps.loadingSessions'),
progress: 99
})
await callbacksRef.current.refreshSessions()
completeDesktopBoot()
bootCompleted = true
} catch (err) {
+1 -2
View File
@@ -5,8 +5,7 @@ import { useNavigate } from 'react-router-dom'
import { sessionTitle } from '@/lib/chat-runtime'
import { cn } from '@/lib/utils'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $attentionSessionIds, $workingSessionIds } from '@/store/session-states'
import { $attentionSessionIds, $unreadFinishedSessionIds, $workingSessionIds } from '@/store/session'
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
@@ -2,9 +2,8 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'
import type { MutableRefObject } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createClientSessionState } from '@/lib/chat-runtime'
import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue'
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
import { $workingSessionIds } from '@/store/session'
import { useBackgroundQueueDrain } from './use-background-queue-drain'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
@@ -33,7 +32,6 @@ function Harness({
describe('useBackgroundQueueDrain', () => {
beforeEach(() => {
vi.useRealTimers()
clearAllSessionStates()
})
afterEach(() => {
@@ -41,7 +39,7 @@ describe('useBackgroundQueueDrain', () => {
vi.restoreAllMocks()
vi.useRealTimers()
$queuedPromptsBySession.set({})
clearAllSessionStates()
$workingSessionIds.set([])
})
it('drains an idle queued prompt for a non-selected background session', async () => {
@@ -49,7 +47,7 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'continue in the background', attachments: [] })
clearAllSessionStates()
$workingSessionIds.set([])
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
@@ -70,7 +68,7 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'visible queue entry', attachments: [] })
clearAllSessionStates()
$workingSessionIds.set([])
render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId="stored-session-a" submitText={submitText} />)
@@ -85,8 +83,7 @@ describe('useBackgroundQueueDrain', () => {
const submitText = vi.fn(async () => true)
enqueueQueuedPrompt('stored-session-a', { text: 'wait for current turn', attachments: [] })
// Mark the session as working (busy) so the drain should skip it
publishSessionState('rt-session-a', { ...createClientSessionState('stored-session-a'), busy: true })
$workingSessionIds.set(['stored-session-a'])
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
@@ -12,7 +12,7 @@ import {
shouldAutoDrain
} from '@/store/composer-queue'
import { notify } from '@/store/notifications'
import { $workingSessionIds } from '@/store/session-states'
import { $workingSessionIds } from '@/store/session'
import type { SubmitTextOptions } from './use-prompt-actions/utils'
@@ -1,5 +1,5 @@
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
import { type MutableRefObject, useCallback, useRef } from 'react'
import { writeAgentTerminalChunk } from '@/app/right-sidebar/terminal/agent-terminal-stream'
import { readActiveTerminal } from '@/app/right-sidebar/terminal/buffer'
@@ -26,8 +26,6 @@ import { followActiveSessionCwd } from '@/store/projects'
import { clearAllPrompts, setApprovalRequest, setSecretRequest, setSudoRequest } from '@/store/prompts'
import {
$currentCwd,
$currentModel,
$currentProvider,
sessionMatchesStoredId,
setCurrentBranch,
setCurrentCwd,
@@ -79,7 +77,6 @@ interface GatewayEventDeps {
queryClient: QueryClient
refreshHermesConfig: () => Promise<void>
sessionInterrupted: (sessionId: string) => boolean
sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
@@ -108,48 +105,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
queryClient,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
} = deps
const unscopedStreamSessionIdRef = useRef<string | null>(null)
// session.info arrives in bursts (agent build ready + turn end + title /
// MCP / compress edges within the same second). Each used to fire its own
// refreshHermesConfig — two REST calls (config + defaults) per event, per
// turn, including for BACKGROUND sessions whose values the fetch can't even
// apply. Coalesce to one trailing fetch per burst; the caller gates on
// `apply` so background traffic doesn't schedule anything.
const configRefreshTimerRef = useRef<null | number>(null)
const scheduleConfigRefresh = useCallback(() => {
if (configRefreshTimerRef.current !== null) {
return
}
if (typeof window === 'undefined') {
void refreshHermesConfig()
return
}
configRefreshTimerRef.current = window.setTimeout(() => {
configRefreshTimerRef.current = null
void refreshHermesConfig()
}, 300)
}, [refreshHermesConfig])
useEffect(
() => () => {
if (configRefreshTimerRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(configRefreshTimerRef.current)
configRefreshTimerRef.current = null
}
},
[]
)
return useCallback(
(event: RpcEvent) => {
const payload = event.payload as GatewayEventPayload | undefined
@@ -190,19 +151,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const modelChanged = typeof payload?.model === 'string'
const providerChanged = typeof payload?.provider === 'string'
const runningChanged = typeof payload?.running === 'boolean'
// The backend stamps model/provider (as strings) on EVERY session.info,
// so the presence flags above are true on every heartbeat/turn edge —
// fine for the cheap atom writes below (nanostores skips identical
// values), but they also drove queryClient.invalidateQueries, refetching
// the model-options provider catalog once or twice per turn for a model
// that never changed. Only a genuine VALUE change (vs the session's own
// cached runtime state, captured before the state patch below applies;
// composer atoms as the fallback for an uncached session) invalidates.
const knownState = sessionId ? sessionStateByRuntimeIdRef.current.get(sessionId) : undefined
const modelValueChanged = modelChanged && payload!.model !== (knownState?.model ?? $currentModel.get())
const providerValueChanged =
providerChanged && payload!.provider !== (knownState?.provider ?? $currentProvider.get())
// Config is profile-scoped, but session.info also arrives for background
// sessions. Only an active-session event from the currently active
@@ -301,15 +249,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
}
if (busy) {
// Don't re-arm busy from a stale session.info if the user
// just clicked Stop (interrupted=true). The backend's
// cooperative interrupt may not have propagated yet, so
// running is still true in the heartbeat. The turn's
// finally block will emit running=false to clear busy.
if (state.interrupted) {
return state
}
return {
...state,
busy,
@@ -344,14 +283,11 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
if (apply) {
reportInstallMethodWarning(payload?.install_warning)
// Config refetch is only meaningful for the foreground context —
// everything refreshHermesConfig applies is either active-session
// guarded or a composer/global pref. Background sessions' heartbeats
// used to trigger it too (two REST calls each, every turn).
scheduleConfigRefresh()
}
if (modelValueChanged || providerValueChanged) {
void refreshHermesConfig()
if (modelChanged || providerChanged) {
void queryClient.invalidateQueries({
queryKey: explicitSid && sessionId ? ['model-options', sessionId] : ['model-options']
})
@@ -371,27 +307,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
triggerHaptic('streamStart')
}
updateSessionState(sessionId, state => {
// If the user clicked Stop (cancelRun set interrupted=true), don't
// let a stale message.start from a chained turn (goal follow-up,
// completion drain) or an in-flight LLM response re-arm busy.
// The interrupt is user intent — the backend's cooperative cancel
// may not have propagated yet, so its events are stale. The turn's
// finally block will emit session.info with running=false to clear
// busy for real once the agent loop actually exits.
if (state.interrupted) {
return state
}
return {
...state,
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false,
turnStartedAt: Date.now()
}
})
updateSessionState(sessionId, state => ({
...state,
busy: true,
awaitingResponse: true,
sawAssistantPayload: false,
interrupted: false,
turnStartedAt: Date.now()
}))
if (isActiveEvent) {
setTurnStartedAt(Date.now())
@@ -810,9 +733,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
lastCwdInfoSessionRef,
nativeSubagentSessionsRef,
queryClient,
scheduleConfigRefresh,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
]
@@ -131,46 +131,6 @@ export function useMessageStream({
[updateSessionState]
)
// Turn-complete triggers a full sidebar refresh (recents + cron + messaging
// REST fan-out, each scanning profile state.dbs server-side) plus a
// cross-window broadcast that makes every other window do the same. Parallel
// tiles / multi-window finishing near-simultaneously used to multiply that.
// Coalesce completions into one trailing refresh per burst — a ~300ms title
// lag is invisible; the redundant aggregator scans are not.
const sessionsRefreshTimerRef = useRef<null | number>(null)
const scheduleSessionsRefresh = useCallback(() => {
if (sessionsRefreshTimerRef.current !== null) {
return
}
const run = () => {
sessionsRefreshTimerRef.current = null
void refreshSessions().catch(() => undefined)
// Sync freshly-titled rows to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
}
if (typeof window === 'undefined') {
run()
return
}
sessionsRefreshTimerRef.current = window.setTimeout(run, 300)
}, [refreshSessions])
useEffect(
() => () => {
if (sessionsRefreshTimerRef.current !== null && typeof window !== 'undefined') {
window.clearTimeout(sessionsRefreshTimerRef.current)
sessionsRefreshTimerRef.current = null
}
},
[]
)
const queuedDeltasRef = useRef<Map<string, QueuedStreamDeltas>>(new Map())
const flushHandleRef = useRef<number | null>(null)
const lastFlushAtRef = useRef<number>(0)
@@ -484,7 +444,10 @@ export function useMessageStream({
}
})
scheduleSessionsRefresh()
void refreshSessions().catch(() => undefined)
// Sync the freshly-titled row to other windows (e.g. main, when the turn
// ran in the pop-out).
broadcastSessionsChanged()
if (compactedTurnRef.current.delete(sessionId)) {
shouldHydrate = false
@@ -501,7 +464,7 @@ export function useMessageStream({
title: translateNow('notifications.native.turnDoneTitle')
})
},
[hydrateFromStoredSession, scheduleSessionsRefresh, updateSessionState]
[hydrateFromStoredSession, refreshSessions, updateSessionState]
)
const failAssistantMessage = useCallback(
@@ -563,7 +526,6 @@ export function useMessageStream({
queryClient,
refreshHermesConfig,
sessionInterrupted,
sessionStateByRuntimeIdRef,
updateSessionState,
upsertToolCall
})
@@ -1,155 +0,0 @@
import { QueryClient } from '@tanstack/react-query'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useEffect, useRef } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ClientSessionState } from '@/app/types'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setCurrentModel, setCurrentProvider } from '@/store/session'
import type { RpcEvent } from '@/types/hermes'
import { useMessageStream } from './index'
// Per-turn REST amplification guards: session.info must not refetch config for
// background sessions nor invalidate the model-options catalog when the model
// string is merely PRESENT (the backend stamps it on every event) rather than
// actually changed. message.complete must coalesce sidebar refreshes.
const ACTIVE_SID = 'session-active'
let handleEvent: ((event: RpcEvent) => void) | null = null
let refreshHermesConfig: ReturnType<typeof vi.fn<() => Promise<void>>>
let refreshSessions: ReturnType<typeof vi.fn<() => Promise<void>>>
let queryClient: QueryClient
function Harness() {
const activeSessionIdRef = useRef<string | null>(ACTIVE_SID)
const sessionStateByRuntimeIdRef = useRef(new Map<string, ClientSessionState>())
const stream = useMessageStream({
activeSessionIdRef,
hydrateFromStoredSession: vi.fn(async () => undefined),
queryClient,
refreshHermesConfig,
refreshSessions,
sessionStateByRuntimeIdRef,
updateSessionState: (sessionId, updater) => {
const current = sessionStateByRuntimeIdRef.current.get(sessionId) ?? createClientSessionState()
const next = updater(current)
sessionStateByRuntimeIdRef.current.set(sessionId, next)
return next
}
})
useEffect(() => {
handleEvent = stream.handleGatewayEvent
}, [stream.handleGatewayEvent])
return null
}
async function mountStream() {
render(<Harness />)
await waitFor(() => expect(handleEvent).not.toBeNull())
}
const sessionInfo = (sessionId: string, payload: Record<string, unknown>) =>
act(() => handleEvent!({ payload, session_id: sessionId, type: 'session.info' }))
beforeEach(() => {
handleEvent = null
refreshHermesConfig = vi.fn<() => Promise<void>>(async () => undefined)
refreshSessions = vi.fn<() => Promise<void>>(async () => undefined)
queryClient = new QueryClient()
setCurrentModel('')
setCurrentProvider('')
})
afterEach(() => {
cleanup()
setCurrentModel('')
setCurrentProvider('')
vi.useRealTimers()
vi.restoreAllMocks()
})
describe('session.info config refetch gating', () => {
it('coalesces active-session bursts into one trailing config fetch', async () => {
// Mount under real timers (waitFor), then freeze time for the debounce.
await mountStream()
vi.useFakeTimers()
sessionInfo(ACTIVE_SID, { model: 'm1', running: true })
sessionInfo(ACTIVE_SID, { model: 'm1', running: false })
sessionInfo(ACTIVE_SID, { model: 'm1', title: 't' })
expect(refreshHermesConfig).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshHermesConfig).toHaveBeenCalledTimes(1)
})
it('never fetches config for a background session heartbeat', async () => {
await mountStream()
vi.useFakeTimers()
sessionInfo('session-background', { model: 'm1', running: true })
sessionInfo('session-background', { model: 'm1', running: false })
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshHermesConfig).not.toHaveBeenCalled()
})
})
describe('session.info model-options invalidation gating', () => {
it('skips invalidation when model/provider merely restate the known values', async () => {
await mountStream()
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
// Seed the session's cached runtime state.
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
invalidate.mockClear()
// Turn-end heartbeat restating the same model/provider — the pre-fix path
// invalidated (and refetched the provider catalog) on every one of these.
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: false })
expect(invalidate).not.toHaveBeenCalled()
})
it('invalidates when the session model actually changes', async () => {
await mountStream()
const invalidate = vi.spyOn(queryClient, 'invalidateQueries')
sessionInfo(ACTIVE_SID, { model: 'm1', provider: 'p1', running: true })
invalidate.mockClear()
sessionInfo(ACTIVE_SID, { model: 'm2', provider: 'p1', running: true })
expect(invalidate).toHaveBeenCalledWith({ queryKey: ['model-options', ACTIVE_SID] })
})
})
describe('message.complete sidebar refresh coalescing', () => {
it('collapses near-simultaneous completions into one refresh', async () => {
await mountStream()
vi.useFakeTimers()
act(() => handleEvent!({ payload: { text: 'a' }, session_id: 's1', type: 'message.complete' }))
act(() => handleEvent!({ payload: { text: 'b' }, session_id: 's2', type: 'message.complete' }))
expect(refreshSessions).not.toHaveBeenCalled()
await act(async () => {
await vi.advanceTimersByTimeAsync(400)
})
expect(refreshSessions).toHaveBeenCalledTimes(1)
})
})
@@ -207,47 +207,6 @@ describe('useModelControls', () => {
expect($currentModel.get()).toBe('openai/gpt-5.5')
})
it('reseeds a sticky manual pick that was removed from the catalog', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const queryClient = new QueryClient()
queryClient.setQueryData(['model-options', 'global'], {
providers: [{ models: ['openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }]
})
// A manual pick whose model no longer exists on its provider.
setCurrentModel('openrouter/owl-alpha')
setCurrentProvider('openrouter')
setCurrentModelSource('manual')
const { result } = renderHook(() => useModelControls({ queryClient, requestGateway: vi.fn() }))
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openai/gpt-5.5')
expect(getCurrentModelSource()).toBe('default')
})
it('keeps a sticky manual pick that is still in the catalog', async () => {
vi.mocked(getGlobalModelInfo).mockResolvedValue({ model: 'openai/gpt-5.5', provider: 'openai-codex' })
const queryClient = new QueryClient()
queryClient.setQueryData(['model-options', 'global'], {
providers: [{ models: ['openrouter/glm-4.7', 'openai/gpt-5.5'], name: 'OpenRouter', slug: 'openrouter' }]
})
setCurrentModel('openrouter/glm-4.7')
setCurrentProvider('openrouter')
setCurrentModelSource('manual')
const { result } = renderHook(() => useModelControls({ queryClient, requestGateway: vi.fn() }))
await result.current.refreshCurrentModel()
expect($currentModel.get()).toBe('openrouter/glm-4.7')
expect(getCurrentModelSource()).toBe('manual')
})
it('refreshes legacy/default-derived composer state from the profile default', async () => {
setCurrentModel('openai/gpt-5.5')
setCurrentProvider('nous')
@@ -3,7 +3,6 @@ import { useCallback } from 'react'
import { getGlobalModelInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { manualPickRemoved } from '@/lib/model-options'
import { notifyError } from '@/store/notifications'
import {
$activeSessionId,
@@ -59,28 +58,13 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
return
}
// A manual pick stays sticky UNLESS it was removed from the catalog (its
// model no longer exists on the provider), in which case keeping it would
// 404 every new chat — fall through to reseed from the profile default.
// Reads the model-options cache the composer already populated; an
// unknown/not-yet-loaded catalog conservatively preserves the pick.
const keepManualPick = () => {
if (force || !$currentModel.get() || getCurrentModelSource() !== 'manual') {
return false
}
const options = queryClient.getQueryData<ModelOptionsResponse>(['model-options', 'global'])
return !manualPickRemoved(options?.providers, $currentProvider.get(), $currentModel.get())
}
if (keepManualPick()) {
if (!force && $currentModel.get() && getCurrentModelSource() === 'manual') {
return
}
const result = await getGlobalModelInfo()
if ($activeSessionId.get() || keepManualPick()) {
if ($activeSessionId.get() || (!force && $currentModel.get() && getCurrentModelSource() === 'manual')) {
return
}
@@ -98,7 +82,7 @@ export function useModelControls({ queryClient, requestGateway }: ModelControlsO
} catch {
// The delayed session.info event still updates this once the agent is ready.
}
}, [queryClient])
}, [])
// Returns whether the switch succeeded so callers can await it before applying
// follow-up changes. The composer model is plain UI state: with no live
@@ -514,16 +514,7 @@ export function usePromptActions({
)
const cancelRun = useCallback(async () => {
// Read from the ref, not the closure-captured `activeSessionId`. The
// actions bag is a stable ref mutated in place (Object.assign on each
// ContribWiring render), and ChatRoutesSurface is memoized on that stable
// ref — so it does NOT re-render when activeSessionId changes, which means
// the ChatView element's onCancel prop holds a stale cancelRun closure.
// The closure's `activeSessionId` can be a previous session's id (or null
// from a new-chat draft), sending session.interrupt to the wrong session.
// The ref is updated via useEffect on every activeSessionId change, so it
// always reflects the current session — same pattern submitText uses.
const sessionId = activeSessionIdRef.current
const sessionId = activeSessionId || activeSessionIdRef.current
const releaseBusy = () => {
setMutableRef(busyRef, false)
@@ -597,7 +588,15 @@ export function usePromptActions({
releaseBusy()
notifyError(stopError, copy.stopFailed)
}
}, [activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState])
}, [
activeSessionId,
activeSessionIdRef,
busyRef,
copy.stopFailed,
requestGateway,
selectedStoredSessionIdRef,
updateSessionState
])
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration
@@ -212,18 +212,14 @@ describe('createBackendSessionForSend profile routing', () => {
// (b) arm $resumeFailedSessionId so use-route-resume can retry. A resume that
// succeeds must NOT leave the flag armed.
function ResumeHarness({
onStateUpdate,
onReady,
requestGateway,
runtimeIdByStoredSessionIdRef,
selectedStoredSessionId = null,
sessionStateByRuntimeIdRef
}: {
onStateUpdate?: (sessionId: string, state: ClientSessionState) => void
onReady: (resume: (storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) => void
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
runtimeIdByStoredSessionIdRef?: MutableRefObject<Map<string, string>>
selectedStoredSessionId?: string | null
sessionStateByRuntimeIdRef?: MutableRefObject<Map<string, ClientSessionState>>
}) {
const ref = <T,>(value: T): MutableRefObject<T> => ({ current: value })
@@ -239,16 +235,11 @@ function ResumeHarness({
requestGateway,
resetViewSync: vi.fn(),
runtimeIdByStoredSessionIdRef: runtimeIdByStoredSessionIdRef ?? ref(new Map<string, string>()),
selectedStoredSessionId,
selectedStoredSessionIdRef: ref<string | null>(selectedStoredSessionId),
selectedStoredSessionId: null,
selectedStoredSessionIdRef: ref<string | null>(null),
sessionStateByRuntimeIdRef: sessionStateByRuntimeIdRef ?? ref(new Map<string, ClientSessionState>()),
syncSessionStateToView: vi.fn(),
updateSessionState: (sessionId, updater) => {
const next = updater({} as ClientSessionState)
onStateUpdate?.(sessionId, next)
return next
}
updateSessionState: (_sessionId, updater) => updater({} as ClientSessionState)
})
useEffect(() => {
@@ -330,155 +321,6 @@ describe('resumeSession failure recovery', () => {
expect($messages.get().length).toBeGreaterThan(0)
})
it('preserves an optimistic user message during a same-session reconnect', async () => {
setMessages([
{
id: 'stored-user',
role: 'user',
parts: [{ type: 'text', text: 'earlier question' }]
},
{
id: 'stored-assistant',
role: 'assistant',
parts: [{ type: 'text', text: 'earlier answer' }]
},
{
id: 'user-optimistic',
role: 'user',
parts: [{ type: 'text', text: 'message sent during reconnect' }]
}
])
const storedMessages = [
{ content: 'earlier question', role: 'user', timestamp: 1 },
{ content: 'earlier answer', role: 'assistant', timestamp: 2 }
]
vi.mocked(getSessionMessages).mockResolvedValue({ messages: storedMessages, session_id: 'stored-1' } as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
return {
session_id: 'runtime-1',
session_key: 'stored-1',
resumed: 'stored-1',
message_count: 2,
messages: storedMessages,
info: {}
} as never
}
return {} as never
})
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness onReady={r => (resume = r)} requestGateway={requestGateway} selectedStoredSessionId="stored-1" />
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
expect($messages.get().map(message => message.id)).toContain('user-optimistic')
})
it('restores the in-flight turn and queued user prompt after a full renderer restart', async () => {
const storedMessages = [
{ content: 'earlier question', role: 'user', timestamp: 1 },
{ content: 'earlier answer', role: 'assistant', timestamp: 2 }
]
vi.mocked(getSessionMessages).mockResolvedValue({ messages: storedMessages, session_id: 'stored-1' } as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
return {
session_id: 'runtime-1',
session_key: 'stored-1',
resumed: 'stored-1',
message_count: storedMessages.length,
messages: storedMessages,
running: true,
inflight: {
user: 'current prompt',
assistant: 'partial answer',
streaming: true
},
queued: { user: 'newest prompt' },
info: {}
} as never
}
return {} as never
})
let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, state) => (resumedState = state)}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
const renderedMessages = JSON.stringify(resumedState?.messages)
expect(renderedMessages).toContain('current prompt')
expect(renderedMessages).toContain('partial answer')
expect(renderedMessages).toContain('newest prompt')
})
it('uses the continuation projection when resume rotates an equal-length stored transcript', async () => {
const parentMessages = [
{ content: 'question before compression', role: 'user', timestamp: 1 },
{ content: 'answer before compression', role: 'assistant', timestamp: 2 }
]
const continuationMessages = [
{ content: 'prompt after compression', role: 'user', timestamp: 3 },
{ content: 'answer after compression', role: 'assistant', timestamp: 4 }
]
vi.mocked(getSessionMessages).mockResolvedValue({
messages: parentMessages,
session_id: 'stored-1'
} as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
return {
session_id: 'runtime-continuation',
session_key: 'stored-continuation',
resumed: 'stored-continuation',
message_count: continuationMessages.length,
messages: continuationMessages,
info: {}
} as never
}
return {} as never
})
let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, state) => (resumedState = state)}
requestGateway={requestGateway}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-1', true)
const renderedMessages = JSON.stringify(resumedState?.messages)
expect(renderedMessages).toContain('prompt after compression')
expect(renderedMessages).toContain('answer after compression')
expect(renderedMessages).not.toContain('answer before compression')
})
it('does NOT throw out of the fallback when REST also fails (no unhandled rejection)', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.resume') {
@@ -759,10 +601,9 @@ describe('resumeSession warm-cache mapping integrity', () => {
expect(sessionStateByRuntimeIdRef.current.has('rt-recycled')).toBe(false)
})
it('honours a warm cache entry whose stored id matches and refreshes its persisted transcript', async () => {
it('honours a warm cache entry whose stored id matches (no needless refetch)', async () => {
// Correctly-wired mapping: 'rt-A' <-> 'stored-A'. The fast-path should trust
// it and never reach session.resume. session.activate refreshes the live
// projection and, critically, rebinds its event transport after reconnect.
// it and never reach session.resume (only the lightweight usage probe).
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
@@ -772,23 +613,13 @@ describe('resumeSession warm-cache mapping integrity', () => {
}
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
return {
session_id: 'rt-A',
session_key: 'stored-A',
resumed: 'stored-A',
message_count: 0,
messages: [],
running: false,
info: {}
} as never
if (method === 'session.usage') {
return { input: 0, output: 0, total: 0 } as never
}
return {} as never
})
vi.mocked(getSessionMessages).mockResolvedValue({ messages: [], session_id: 'stored-A' } as never)
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
@@ -802,132 +633,10 @@ describe('resumeSession warm-cache mapping integrity', () => {
await resume!('stored-A', true)
// Fast-path served the session from cache: no full resume RPC, mapping intact.
// The persisted transcript still refreshes in parallel because the runtime
// projection can differ even when its row count matches.
const methods = requestGateway.mock.calls.map(([method]) => method)
expect(methods).toContain('session.activate')
expect(methods).not.toContain('session.resume')
expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined)
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
})
it('repairs an idle warm cache from a divergent equal-length persisted transcript', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const state = clientState('stored-A')
state.messages = [
{
id: 'cached-user',
role: 'user',
parts: [{ type: 'text', text: 'stale runtime prompt' }]
},
{
id: 'cached-assistant',
role: 'assistant',
parts: [{ type: 'text', text: 'stale runtime answer' }]
}
]
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', state]])
}
const staleRuntimeMessages = [
{ content: 'stale runtime prompt', role: 'user', timestamp: 1 },
{ content: 'stale runtime answer', role: 'assistant', timestamp: 2 }
]
const persistedMessages = [
{ content: 'prompt saved after compression', role: 'user', timestamp: 3 },
{ content: 'answer saved after compression', role: 'assistant', timestamp: 4 }
]
vi.mocked(getSessionMessages).mockResolvedValue({
messages: persistedMessages,
session_id: 'stored-A'
} as never)
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
return {
session_id: 'rt-A',
session_key: 'stored-A',
resumed: 'stored-A',
message_count: staleRuntimeMessages.length,
messages: staleRuntimeMessages,
running: false,
info: {}
} as never
}
return {} as never
})
let resumedState: ClientSessionState | undefined
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (resume = ready)}
onStateUpdate={(_sessionId, next) => (resumedState = next)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
const renderedMessages = JSON.stringify(resumedState?.messages)
expect(renderedMessages).toContain('prompt saved after compression')
expect(renderedMessages).toContain('answer saved after compression')
expect(renderedMessages).not.toContain('stale runtime answer')
})
it('keeps a warm runtime and optimistic turn on a transient activation timeout', async () => {
const runtimeIdByStoredSessionIdRef: MutableRefObject<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])
}
const state = clientState('stored-A')
state.messages = [
{
id: 'user-optimistic',
role: 'user',
parts: [{ type: 'text', text: 'do not lose me' }]
}
]
const sessionStateByRuntimeIdRef: MutableRefObject<Map<string, ClientSessionState>> = {
current: new Map([['rt-A', state]])
}
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.activate') {
throw new Error('request timed out: session.activate')
}
return {} as never
})
let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise<unknown>) | null = null
render(
<ResumeHarness
onReady={r => (resume = r)}
requestGateway={requestGateway}
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef}
/>
)
await waitFor(() => expect(resume).not.toBeNull())
await resume!('stored-A', true)
expect(requestGateway.mock.calls.map(([method]) => method)).not.toContain('session.resume')
expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A')
expect(sessionStateByRuntimeIdRef.current.get('rt-A')?.messages[0]?.id).toBe('user-optimistic')
})
})
describe('createBackendSessionForSend workspace target', () => {
@@ -5,8 +5,7 @@ import type { NavigateFunction } from 'react-router-dom'
import { revealTreePane } from '@/components/pane-shell/tree/store'
import { deleteSession, getSessionMessages, setSessionArchived } from '@/hermes'
import { useI18n } from '@/i18n'
import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
import { preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages'
import { setSessionYolo } from '@/lib/yolo-session'
import { clearQueuedPrompts } from '@/store/composer-queue'
import { $pinnedSessionIds } from '@/store/layout'
@@ -63,14 +62,12 @@ import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../../types'
import {
appendLiveSessionProjection,
applyRuntimeInfo,
applyStoredSessionPreviewRuntimeInfo,
type BranchMessage,
chatMessageArraysEquivalent,
isSessionGoneError,
patchSessionWorkspace,
preserveLocalPendingTurnMessages,
reconcileResumeMessages,
resolveStoredSession,
sessionMatchesStoredId,
@@ -120,19 +117,6 @@ function applyStoredUsage(stored: { input_tokens?: number | null; output_tokens?
setCurrentUsage(current => ({ ...current, input, output, total: input + output }))
}
function reconcileAuthoritativeMessages(
authoritativeMessages: SessionResumeResponse['messages'],
previousMessages: ChatMessage[],
liveProjection?: Pick<SessionResumeResponse, 'inflight' | 'queued' | 'session_id'>
): ChatMessage[] {
const authoritative = toChatMessages(authoritativeMessages)
const withLiveProjection = liveProjection ? appendLiveSessionProjection(authoritative, liveProjection) : authoritative
const reconciled = reconcileResumeMessages(withLiveProjection, previousMessages)
const withPendingTurn = preserveLocalPendingTurnMessages(reconciled, previousMessages)
return preserveLocalAssistantErrors(withPendingTurn, previousMessages)
}
// `session.create` params from the current profile + sticky-UI model/effort/fast,
// ensuring the gateway is on that profile first. Shared by the primary send path
// and the "open in split" tile path; `cwd` is the one thing that differs (the
@@ -447,8 +431,6 @@ export function useSessionActions({
async (storedSessionId: string, replaceRoute = false) => {
const requestId = resumeRequestRef.current + 1
resumeRequestRef.current = requestId
const resumedSameSelectedSession = selectedStoredSessionIdRef.current === storedSessionId
const resumeStartMessages = resumedSameSelectedSession ? $messages.get() : []
const isCurrentResume = () =>
resumeRequestRef.current === requestId && selectedStoredSessionIdRef.current === storedSessionId
@@ -480,7 +462,7 @@ export function useSessionActions({
// session being resumed. A pooled profile backend that gets idle-reaped
// and respawned (pruneSecondaryGateways) re-mints runtime ids, so a
// recycled id can resolve to a live-but-DIFFERENT session's cache entry.
// The session.activate 404 guard below only catches a fully-DEAD id — a
// The session.usage 404 guard below only catches a fully-DEAD id — a
// recycled-live id 200s, so an unchecked hit paints the wrong transcript
// under the current route (the "open chat A, chat B loads" bug). On a
// mismatch the mapping is cross-wired: purge both sides and report a miss
@@ -507,10 +489,7 @@ export function useSessionActions({
if (!takeWarmCache()) {
setActiveSessionId(null)
activeSessionIdRef.current = null
if (!resumedSameSelectedSession) {
setMessages([])
}
setMessages([])
}
// Swap the single live gateway to this session's profile before any
@@ -538,7 +517,7 @@ export function useSessionActions({
const stored =
$sessions.get().find(session => sessionMatchesStoredId(session, storedSessionId)) ?? storedForProfile
let cachedViewState =
const cachedViewState =
!cachedState.model && stored?.model != null
? {
...cachedState,
@@ -546,14 +525,6 @@ export function useSessionActions({
}
: cachedState
if (resumedSameSelectedSession) {
const messages = preserveLocalPendingTurnMessages(cachedViewState.messages, resumeStartMessages)
if (messages !== cachedViewState.messages) {
cachedViewState = { ...cachedViewState, messages }
}
}
if (cachedViewState !== cachedState) {
sessionStateByRuntimeIdRef.current.set(cachedRuntimeId, cachedViewState)
publishSessionState(cachedRuntimeId, cachedViewState)
@@ -564,17 +535,6 @@ export function useSessionActions({
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
// Paint the warm cache immediately, but also refresh the persisted
// transcript in parallel. A resumed runtime carries the agent's
// compression projection, which can have the same row count as the
// stored conversation while containing different rows. Trusting that
// projection alone made completed prompts disappear after an app
// restart whenever this warm path short-circuited the cold REST
// prefetch. Watch mirrors stay live-only by design.
const persistedTranscriptPromise = isWatchWindow()
? null
: getSessionMessages(storedSessionId, sessionProfile).catch(() => null)
setFreshDraftReady(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
@@ -587,111 +547,27 @@ export function useSessionActions({
setSessionStartedAt(Date.now())
try {
let activated: SessionResumeResponse | null = null
try {
activated = await requestGateway<SessionResumeResponse>('session.activate', {
session_id: cachedRuntimeId,
cols: 96
})
} catch (error) {
// Compatibility for older backends. Modern backends require
// session.activate here because it rebinds the live session's
// event transport to this newly-opened WebSocket.
if (!isMissingRpcMethod(error)) {
throw error
}
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
if (!isCurrentResume()) {
return
}
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
return
}
const usage = await requestGateway<UsageStats>('session.usage', { session_id: cachedRuntimeId })
if (!isCurrentResume()) {
return
}
if (activated.session_key && activated.session_key !== storedSessionId) {
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
} else {
const runtimeInfo = applyRuntimeInfo(activated.info)
let activatedMessages =
activated.messages.length || activated.inflight || activated.queued
? reconcileAuthoritativeMessages(activated.messages, cachedViewState.messages, activated)
: cachedViewState.messages
const running = Boolean(activated.running ?? cachedViewState.busy)
// While idle, the persisted REST transcript is the display
// authority: session.activate returns the runtime's compressed
// context projection, not necessarily the complete conversation.
// During a live turn, keep the runtime/cache projection so an
// accepted but not-yet-persisted prompt or stream is never lost.
if (!running && persistedTranscriptPromise) {
const persisted = await persistedTranscriptPromise
if (!isCurrentResume()) {
return
}
const activatedStoredSessionId = activated.session_key || activated.resumed
const persistedMatchesActivatedSession =
!persisted?.session_id ||
!activatedStoredSessionId ||
persisted.session_id === activatedStoredSessionId
if (persisted && persistedMatchesActivatedSession) {
activatedMessages = reconcileAuthoritativeMessages(persisted.messages, activatedMessages)
}
}
const activatedState = updateSessionState(
cachedRuntimeId,
state => ({
...state,
...(runtimeInfo ?? {}),
messages: activatedMessages,
busy: running,
awaitingResponse: running
}),
storedSessionId
)
busyRef.current = running
setBusy(running)
setAwaitingResponse(running)
syncSessionStateToView(cachedRuntimeId, activatedState)
return
if (usage) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
} catch (error) {
return
} catch {
// The cached runtime id was minted by a prior backend instance. A
// pooled profile backend that gets idle-reaped (pruneSecondaryGateways)
// and respawned across a profile swap mints fresh ids, so this mapping
// now 404s ("session not found"). Drop it and fall through to a full
// resume that rebinds a live runtime id. A transient timeout or
// transport error is NOT proof that the session is dead: keep the
// cache and optimistic turn intact for the next reconnect attempt.
// resume that rebinds a live runtime id.
if (!isCurrentResume()) {
return
}
if (!isSessionGoneError(error)) {
return
}
runtimeIdByStoredSessionIdRef.current.delete(storedSessionId)
sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId)
dropSessionState(cachedRuntimeId)
@@ -709,7 +585,7 @@ export function useSessionActions({
// session's transcript would leak into this cold resume ("switching
// sessions shows the same messages"). Clear it so the loader/prefetch
// paints fresh; guarded so the normal cold path (already cleared) no-ops.
if (!resumedSameSelectedSession && $messages.get().length > 0) {
if ($messages.get().length > 0) {
setMessages([])
}
@@ -734,14 +610,7 @@ export function useSessionActions({
try {
const watchWindow = isWatchWindow()
let localSnapshot = resumedSameSelectedSession
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
: $messages.get()
let prefetchApplied = false
let prefetchedMessageCount = 0
let prefetchedStoredSessionId: string | null = null
let localSnapshot = $messages.get()
// REST transcript prefetch and the gateway resume RPC are independent
// — run them concurrently so a big session's wall time is
@@ -772,14 +641,7 @@ export function useSessionActions({
const storedMessages = await prefetchPromise
if (isCurrentResume()) {
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
: $messages.get()
localSnapshot = reconcileAuthoritativeMessages(storedMessages.messages, previousMessages)
prefetchApplied = true
prefetchedMessageCount = storedMessages.messages.length
prefetchedStoredSessionId = storedMessages.session_id || storedSessionId
localSnapshot = preserveLocalAssistantErrors(toChatMessages(storedMessages.messages), $messages.get())
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
@@ -803,25 +665,14 @@ export function useSessionActions({
// skip converting/reconciling the resume payload entirely — on a
// 1000+-message session that second conversion plus the deep
// equivalence compare costs over a second of main-thread time.
const resumedStoredSessionId = resumed.session_key || resumed.resumed
const prefetchMatchesResumedSession =
!prefetchedStoredSessionId || !resumedStoredSessionId || prefetchedStoredSessionId === resumedStoredSessionId
const hasLiveProjection = Boolean(resumed.inflight || resumed.queued)
const preferredMessages =
prefetchApplied &&
prefetchMatchesResumedSession &&
!hasLiveProjection &&
resumed.messages.length <= prefetchedMessageCount
localSnapshot.length > 0
? localSnapshot
: (() => {
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages)
: currentMessages
const resumedMessages = reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed)
const resumedMessages = preserveLocalAssistantErrors(
reconcileResumeMessages(toChatMessages(resumed.messages), currentMessages),
currentMessages
)
return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages
})()
@@ -884,11 +735,7 @@ export function useSessionActions({
return
}
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
: $messages.get()
setMessages(reconcileAuthoritativeMessages(fallback.messages, previousMessages))
setMessages(preserveLocalAssistantErrors(toChatMessages(fallback.messages), $messages.get()))
} catch (e) {
// Fallback also failed: nothing to paint. Leave whatever messages are
// already shown and fall through to arm the resume-failure latch so
@@ -6,13 +6,11 @@ import { $activeGatewayProfile } from '@/store/profile'
import type { SessionInfo } from '@/types/hermes'
import {
appendLiveSessionProjection,
applyRuntimeInfo,
chatMessageArraysEquivalent,
chatMessagesEquivalent,
chatPartsEquivalent,
isSessionGoneError,
preserveLocalPendingTurnMessages,
reconcileResumeMessages,
sessionMatchesStoredId,
sessionShouldHaveTranscript,
@@ -291,72 +289,3 @@ describe('reconcileResumeMessages', () => {
expect(out.parts.some(p => p.type === 'reasoning')).toBe(true)
})
})
describe('preserveLocalPendingTurnMessages', () => {
it('keeps an optimistic user turn and pending assistant when the server projection is behind', () => {
const next = [msg('1-user', 'user', 'first'), msg('2-assistant', 'assistant', 'first answer')]
const previous = [
...next,
msg('user-optimistic', 'user', 'new question'),
msg('assistant-stream-1', 'assistant', 'partial answer', { pending: true })
]
expect(preserveLocalPendingTurnMessages(next, previous).map(message => message.id)).toEqual([
'1-user',
'2-assistant',
'user-optimistic',
'assistant-stream-1'
])
})
it('drops the local copies once the same role ordinals are authoritative', () => {
const previous = [
msg('1-user', 'user', 'first'),
msg('2-assistant', 'assistant', 'first answer'),
msg('user-optimistic', 'user', 'new question'),
msg('assistant-stream-1', 'assistant', 'partial answer', { pending: true })
]
const next = [
msg('1-user-stored', 'user', 'first'),
msg('2-assistant-stored', 'assistant', 'first answer'),
msg('3-user-stored', 'user', 'new question'),
msg('4-assistant-stored', 'assistant', 'complete answer')
]
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
})
describe('appendLiveSessionProjection', () => {
it('restores the running turn and accepted queued prompt after a renderer restart', () => {
const stored = [msg('stored-user', 'user', 'earlier'), msg('stored-assistant', 'assistant', 'earlier answer')]
const restored = appendLiveSessionProjection(stored, {
session_id: 'runtime-1',
inflight: {
user: 'current prompt',
assistant: 'partial answer',
streaming: true
},
queued: { user: 'newest prompt' }
})
expect(restored.map(message => message.role)).toEqual(['user', 'assistant', 'user', 'assistant', 'user'])
expect(restored.map(message => message.parts.map(part => ('text' in part ? part.text : '')).join(''))).toEqual([
'earlier',
'earlier answer',
'current prompt',
'partial answer',
'newest prompt'
])
expect(restored[3]).toMatchObject({ id: 'assistant-stream-runtime-1', pending: true })
})
it('preserves the original array when no live projection exists', () => {
const stored = [msg('stored-user', 'user', 'earlier')]
expect(appendLiveSessionProjection(stored, { session_id: 'runtime-1' })).toBe(stored)
})
})
@@ -1,5 +1,5 @@
import { getSession } from '@/hermes'
import { assistantTextPart, type ChatMessage, chatMessageText, textPart } from '@/lib/chat-messages'
import { type ChatMessage, chatMessageText } from '@/lib/chat-messages'
import { normalizePersonalityValue } from '@/lib/chat-runtime'
import { embeddedImageUrls, textWithoutEmbeddedImages } from '@/lib/embedded-images'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
@@ -26,7 +26,7 @@ import {
// it from here; the canonical definition lives in @/store/session.
export { sessionMatchesStoredId }
import { reportBackendContract, reportInstallMethodWarning } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, SessionRuntimeInfo } from '@/types/hermes'
import type { SessionCreateResponse, SessionInfo, SessionRuntimeInfo } from '@/types/hermes'
import type { ClientSessionState } from '../../../types'
@@ -224,124 +224,6 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
})
}
/**
* Keep the local tail of a turn while a reconnect hydrates an older server
* projection. The user's optimistic row exists before prompt.submit persists
* it, and the pending assistant row exists before message.complete commits it;
* dropping either makes an accepted turn appear to vanish during transport
* churn.
*
* Authoritative rows use different ids, so match by role ordinal. A matching
* user row is considered committed only when its visible text also matches;
* any authoritative assistant at the same ordinal supersedes the local stream.
*/
export function preserveLocalPendingTurnMessages(
nextMessages: ChatMessage[],
previousMessages: ChatMessage[]
): ChatMessage[] {
if (!previousMessages.length) {
return nextMessages
}
const nextByRoleOrdinal = new Map<string, ChatMessage>()
const nextRoleCounts = new Map<ChatMessage['role'], number>()
for (const message of nextMessages) {
const ordinal = nextRoleCounts.get(message.role) ?? 0
nextRoleCounts.set(message.role, ordinal + 1)
nextByRoleOrdinal.set(`${message.role}:${ordinal}`, message)
}
const nextIds = new Set(nextMessages.map(message => message.id))
const previousRoleCounts = new Map<ChatMessage['role'], number>()
const preserved: ChatMessage[] = []
for (const message of previousMessages) {
const ordinal = previousRoleCounts.get(message.role) ?? 0
previousRoleCounts.set(message.role, ordinal + 1)
const isOptimisticUser = message.role === 'user' && message.id.startsWith('user-')
const isPendingAssistant =
message.role === 'assistant' && (message.pending === true || message.id.startsWith('assistant-stream-'))
if ((!isOptimisticUser && !isPendingAssistant) || nextIds.has(message.id)) {
continue
}
const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`)
if (authoritative) {
if (isPendingAssistant) {
continue
}
if (chatMessageText(authoritative).trim() === chatMessageText(message).trim()) {
continue
}
}
preserved.push(message)
}
return preserved.length ? [...nextMessages, ...preserved] : nextMessages
}
/**
* Append the backend-only tail of a live turn to a stored transcript.
*
* Session history is committed only when a turn finishes. During a reconnect,
* `inflight` is therefore the authority for the currently running user/assistant
* pair, while `queued` is an accepted next-turn prompt waiting in gateway
* memory. Stable ids let repeated activate/resume hydration reconcile instead
* of growing duplicate rows.
*/
export function appendLiveSessionProjection(
messages: ChatMessage[],
projection: Pick<SessionResumeResponse, 'inflight' | 'queued' | 'session_id'>
): ChatMessage[] {
const inflightUser = projection.inflight?.user?.trim() ?? ''
const inflightAssistant = projection.inflight?.assistant ?? ''
const inflightStreaming = Boolean(projection.inflight?.streaming)
const queuedUser = projection.queued?.user?.trim() ?? ''
if (!inflightUser && !inflightAssistant && !inflightStreaming && !queuedUser) {
return messages
}
const sessionId = projection.session_id || 'session'
const projected: ChatMessage[] = []
if (inflightUser) {
projected.push({
id: `user-inflight-${sessionId}`,
role: 'user',
parts: [textPart(inflightUser)]
})
}
// Keep a pending assistant boundary even before the first delta when a
// queued user turn follows it. This preserves the two distinct turns.
if (inflightAssistant || inflightStreaming || (inflightUser && queuedUser)) {
projected.push({
id: `assistant-stream-${sessionId}`,
role: 'assistant',
parts: inflightAssistant ? [assistantTextPart(inflightAssistant)] : [],
pending: inflightStreaming
})
}
if (queuedUser) {
projected.push({
id: `user-queued-${sessionId}`,
role: 'user',
parts: [textPart(queuedUser)]
})
}
return projected.length ? [...messages, ...projected] : messages
}
export interface BranchMessage {
content: string
role: ChatMessage['role']
@@ -1,207 +0,0 @@
import { act, renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { SessionInfo, SidebarSessionsResponse } from '@/hermes'
import {
$cronSessions,
$messagingSessions,
$sessions,
$sessionsLoading,
setCronSessions,
setMessagingSessions,
setSessions,
setSessionsLoading
} from '@/store/session'
import { useSessionListActions } from './use-session-list-actions'
// Sidebar refresh hygiene: a content-identical refresh (turn complete,
// cross-window broadcast, reconnect) must not replace $sessions' array
// identity — that identity is the dependency for every sidebar memo — and
// must not flicker the loading flag over an already-populated list.
const row = (id: string, over: Partial<SessionInfo> = {}): SessionInfo =>
({
ended_at: null,
id,
input_tokens: 0,
is_active: false,
last_active: 1000,
message_count: 3,
model: 'm',
output_tokens: 0,
preview: 'hey',
profile: 'default',
source: 'desktop',
started_at: 900,
title: `Chat ${id}`,
...over
}) as SessionInfo
// Batched sidebar response builder. `refreshSessions` now makes ONE
// listSidebarSessions call that returns all three slices, replacing the three
// separate listAllProfileSessions calls (each of which reopened every profile
// DB) — #66377-adjacent perf work from the desktop audit canvas.
const sidebar = (
recents: { sessions: SessionInfo[]; total?: number; profile_totals?: Record<string, number> },
cron: SessionInfo[] = [],
messaging: SessionInfo[] = []
): SidebarSessionsResponse => ({
recents: { sessions: recents.sessions, total: recents.total, profile_totals: recents.profile_totals },
cron: { sessions: cron },
messaging: { sessions: messaging, total: messaging.length }
})
const listSidebarSessions = vi.fn()
const listAllProfileSessions = vi.fn()
vi.mock('@/hermes', async importOriginal => ({
...(await importOriginal<Record<string, unknown>>()),
getCronJobs: vi.fn(async () => []),
listAllProfileSessions: (...args: unknown[]) => listAllProfileSessions(...args),
listSidebarSessions: (...args: unknown[]) => listSidebarSessions(...args)
}))
beforeEach(() => {
listSidebarSessions.mockReset()
listAllProfileSessions.mockReset()
setSessions([])
setCronSessions([])
setMessagingSessions([])
setSessionsLoading(false)
})
afterEach(() => {
setSessions([])
setCronSessions([])
setMessagingSessions([])
setSessionsLoading(false)
})
describe('refreshSessions identity + loading hygiene', () => {
it('keeps the previous $sessions array when the refresh is content-identical', async () => {
const rows = [row('a'), row('b')]
listSidebarSessions.mockResolvedValue(sidebar({ sessions: rows, total: 2, profile_totals: { default: 2 } }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const first = $sessions.get()
expect(first.map(s => s.id)).toEqual(['a', 'b'])
// Second refresh returns fresh (but equal) row objects, as the API does.
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: [row('a'), row('b')], total: 2, profile_totals: { default: 2 } })
)
await act(async () => {
await result.current.refreshSessions()
})
expect($sessions.get()).toBe(first)
})
it('swaps the array when rows actually changed', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const first = $sessions.get()
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: [row('a', { last_active: 2000, title: 'Renamed' })], total: 1, profile_totals: {} })
)
await act(async () => {
await result.current.refreshSessions()
})
expect($sessions.get()).not.toBe(first)
expect($sessions.get()[0].title).toBe('Renamed')
})
it('does not flicker the loading flag over a populated list', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
const loadingStates: boolean[] = []
const off = $sessionsLoading.subscribe(value => loadingStates.push(value))
await act(async () => {
await result.current.refreshSessions()
})
off()
// Only the initial subscribe emission — no true/false churn per refresh.
expect(loadingStates).toEqual([false])
})
it('still shows loading for the initial (empty-list) fetch', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [row('a')], total: 1, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
const loadingStates: boolean[] = []
const off = $sessionsLoading.subscribe(value => loadingStates.push(value))
await act(async () => {
await result.current.refreshSessions()
})
off()
expect(loadingStates).toEqual([false, true, false])
})
})
describe('refreshSessions batches slices into one request', () => {
it('makes a single sidebar call and distributes recents / cron / messaging', async () => {
const recents = [row('a'), row('b')]
const cron = [row('c1', { source: 'cron', title: 'nightly' })]
const messaging = [row('m1', { source: 'telegram', title: 'tg chat' })]
listSidebarSessions.mockResolvedValue(
sidebar({ sessions: recents, total: 2, profile_totals: { default: 2 } }, cron, messaging)
)
const { result } = renderHook(() => useSessionListActions({ profileScope: 'default' }))
await act(async () => {
await result.current.refreshSessions()
})
// One batched call, not three separate listAllProfileSessions reads.
expect(listSidebarSessions).toHaveBeenCalledTimes(1)
expect(listAllProfileSessions).not.toHaveBeenCalled()
// Each slice landed in its own store.
expect($sessions.get().map(s => s.id)).toEqual(['a', 'b'])
expect($cronSessions.get().map(s => s.id)).toEqual(['c1'])
expect($messagingSessions.get().map(s => s.id)).toEqual(['m1'])
})
it('forwards the active profile scope + section limits to the batched call', async () => {
listSidebarSessions.mockResolvedValue(sidebar({ sessions: [], total: 0, profile_totals: {} }))
const { result } = renderHook(() => useSessionListActions({ profileScope: 'work' }))
await act(async () => {
await result.current.refreshSessions()
})
expect(listSidebarSessions).toHaveBeenCalledWith(
expect.objectContaining({
recentsProfile: 'work',
recentsExclude: expect.arrayContaining(['cron']),
messagingExclude: expect.arrayContaining(['cron'])
})
)
})
})
@@ -1,6 +1,6 @@
import { useCallback, useRef } from 'react'
import { getCronJobs, listAllProfileSessions, listSidebarSessions, type SessionInfo } from '@/hermes'
import { getCronJobs, listAllProfileSessions, type SessionInfo } from '@/hermes'
import { sameCronSignature } from '@/lib/session-signatures'
import {
isMessagingSource,
@@ -15,7 +15,9 @@ import {
$messagingSessions,
$selectedStoredSessionId,
$sessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
setCronSessions,
@@ -27,7 +29,6 @@ import {
setSessionsLoading,
setSessionsTotal
} from '@/store/session'
import { $workingSessionIds, getRecentlySettledSessionIds } from '@/store/session-states'
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
@@ -75,6 +76,22 @@ interface UseSessionListActionsArgs {
export function useSessionListActions({ profileScope }: UseSessionListActionsArgs) {
const refreshSessionsRequestRef = useRef(0)
// Cron-job sessions as their own list (latest N). Independent of the recents
// page so the two never compete for slots. Cheap + bounded. Kept (even though
// the sidebar now lists cron *jobs*, not run sessions) so a pinned cron run
// still resolves into the Pinned section via sessionByAnyId.
const refreshCronSessions = useCallback(async () => {
try {
const { sessions } = await listAllProfileSessions(CRON_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
source: 'cron'
})
setCronSessions(prev => (sameCronSignature(prev, sessions) ? prev : sessions))
} catch {
// Non-fatal: the cron section just stays empty/stale.
}
}, [])
// Messaging-platform sessions as their own slice, fetched separately from
// local recents so each platform renders a self-managed section and never
// competes with local chats for the recents page budget. One combined fetch
@@ -137,15 +154,7 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
const refreshSessions = useCallback(async () => {
const requestId = refreshSessionsRequestRef.current + 1
refreshSessionsRequestRef.current = requestId
// The loading flag exists to drive the initial skeletons (they only render
// while the list is empty). Turn-complete / reconnect refreshes over a
// populated list used to flip it true→false anyway, churning every
// $sessionsLoading subscriber twice per turn for no visible change.
const showLoading = $sessions.get().length === 0
if (showLoading) {
setSessionsLoading(true)
}
setSessionsLoading(true)
try {
const limit = $sessionsLimit.get()
@@ -155,69 +164,33 @@ export function useSessionListActions({ profileScope }: UseSessionListActionsArg
// clutter the sidebar.
// Unified cross-profile list (served read-only off each profile's
// state.db; no per-profile backend is spawned). Single-profile users get
// the same rows tagged profile="default".
// Scope recents to the active profile (not always 'all') so a profile
// the same rows tagged profile="default". Cron sessions are excluded here
// and fetched separately (refreshCronSessions) so the scheduler's
// always-newest rows can't consume the recents page budget.
// Scope the fetch to the active profile (not always 'all') so a profile
// with few recent sessions isn't windowed out of the cross-profile
// recency page — the empty-history-on-profile-switch bug. Cron + messaging
// stay cross-profile.
// recency page — the empty-history-on-profile-switch bug.
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
// Batched: one request opens each profile DB once and returns all three
// source-scoped slices, instead of three separate listAllProfileSessions
// calls that each reopened + re-counted every profile DB per refresh.
const result = await listSidebarSessions({
recentsProfile: sessionProfile,
recentsLimit: limit,
recentsExclude: SIDEBAR_EXCLUDED_SOURCES,
cronLimit: CRON_SECTION_LIMIT,
messagingLimit: MESSAGING_SECTION_LIMIT,
messagingExclude: MESSAGING_EXCLUDED_SOURCES
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
})
if (refreshSessionsRequestRef.current === requestId) {
const recents = result.recents
// Signature-gate the swap (same pattern as cron/messaging): a refresh
// that returns content-identical rows must keep the previous array
// identity, or every sidebar memo keyed on $sessions recomputes and the
// whole list re-renders once per turn/broadcast for nothing.
setSessions(prev => {
const next = mergeSessionPage(prev, recents.sessions, sessionsToKeep())
return sameCronSignature(prev, next) ? prev : next
})
setSessionsTotal(typeof recents.total === 'number' ? recents.total : recents.sessions.length)
setSessionProfileTotals(prev => {
const next = recents.profile_totals ?? {}
const prevKeys = Object.keys(prev)
return prevKeys.length === Object.keys(next).length && prevKeys.every(key => prev[key] === next[key])
? prev
: next
})
// Cron section: latest N cron sessions (kept so a pinned cron run still
// resolves via sessionByAnyId), signature-gated like above.
setCronSessions(prev => (sameCronSignature(prev, result.cron.sessions) ? prev : result.cron.sessions))
// Messaging sections: drop any non-messaging source the broad exclude
// didn't catch (custom sources stay in local recents), then split per
// platform in the UI.
const messagingRows = result.messaging.sessions.filter(s => isMessagingSource(s.source))
setMessagingSessions(prev => (sameCronSignature(prev, messagingRows) ? prev : messagingRows))
// Hit the cap → at least one platform may have more on disk than loaded.
setMessagingTruncated(result.messaging.sessions.length >= MESSAGING_SECTION_LIMIT)
setSessions(prev => mergeSessionPage(prev, result.sessions, sessionsToKeep()))
setSessionsTotal(typeof result.total === 'number' ? result.total : result.sessions.length)
setSessionProfileTotals(result.profile_totals ?? {})
}
} finally {
if (showLoading && refreshSessionsRequestRef.current === requestId) {
if (refreshSessionsRequestRef.current === requestId) {
setSessionsLoading(false)
}
}
// Cron *jobs* are a distinct API (getCronJobs), not a session slice.
void refreshCronSessions()
void refreshCronJobs()
}, [profileScope, refreshCronJobs])
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
const loadMoreSessions = useCallback(async () => {
bumpSessionsLimit()
@@ -6,18 +6,24 @@ import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
import { createClientSessionState } from '@/lib/chat-runtime'
import { setMutableRef } from '@/lib/mutable-ref'
import {
$activeSessionId,
$busy,
$messages,
noteSessionActivity,
onSessionWatchdogClear,
setActiveSessionStoredId,
setCurrentFastMode,
setCurrentModel,
setCurrentPersonality,
setCurrentProvider,
setCurrentReasoningEffort,
setCurrentServiceTier,
setSessionAttention,
setSessionWorking,
setTurnStartedAt,
setYoloActive
} from '@/store/session'
import { publishSessionState, setWatchdogClearFn } from '@/store/session-states'
import { publishSessionState } from '@/store/session-states'
import type { ClientSessionState } from '../../types'
@@ -97,20 +103,33 @@ export function useSessionStateCache({
const existing = sessionStateByRuntimeIdRef.current.get(sessionId)
if (existing) {
if (storedSessionId !== undefined && storedSessionId !== existing.storedSessionId) {
// Stored id changed (e.g. auto-compression rotated it). Create a NEW
// state object rather than mutating in place — updateSessionState needs
// the PREVIOUS state to detect transitions (busy→idle, id rotation).
const updated = { ...existing, storedSessionId }
sessionStateByRuntimeIdRef.current.set(sessionId, updated)
if (storedSessionId !== undefined) {
const previousStoredSessionId = existing.storedSessionId
existing.storedSessionId = storedSessionId
if (storedSessionId) {
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
if (existing.busy) {
setSessionWorking(storedSessionId, true)
}
}
if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) {
setSessionWorking(previousStoredSessionId, false)
// Auto-compression rotated the stored id on the active session. Signal
// the route-following effect in use-session-actions so the URL + selection
// re-anchor to the continuation id — otherwise the next send hits a stale
// stored→runtime mapping (getRuntimeIdForStoredSession returns null) and
// triggers a full thread reload via resumeStoredSession.
if (sessionId === $activeSessionId.get()) {
setActiveSessionStoredId(storedSessionId)
}
}
}
return sessionStateByRuntimeIdRef.current.get(sessionId)!
return existing
}
const created = createClientSessionState(storedSessionId ?? null)
@@ -256,10 +275,30 @@ export function useSessionStateCache({
const previous = ensureSessionState(sessionId, storedSessionId)
const next = updater({ ...previous, messages: previous.messages })
sessionStateByRuntimeIdRef.current.set(sessionId, next)
// Publishing to $sessionStates automatically fires transition side-effects
// (watchdog, settle grace, unread marker, compression id rotation) inside
// publishSessionState — no manual transition call needed.
// Mirror into the reactive multi-session store — session tiles (and any
// other non-primary surface) subscribe per runtime id there instead of
// through the single active $messages view.
publishSessionState(sessionId, next)
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
setSessionWorking(previous.storedSessionId, false)
}
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
setSessionAttention(previous.storedSessionId, false)
}
setSessionWorking(next.storedSessionId, next.busy)
setSessionAttention(next.storedSessionId, next.needsInput)
// Every state update is effectively a "still alive" heartbeat for
// streaming events. The session-store watchdog uses this to keep the
// working flag alive during long-running turns and to clear it once
// the stream goes silent.
if (next.busy) {
noteSessionActivity(next.storedSessionId)
}
syncSessionStateToView(sessionId, next)
return next
@@ -279,32 +318,30 @@ export function useSessionStateCache({
return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null
}, [])
// Wire the watchdog's force-clear callback to our cache. When the watchdog
// fires (8 min of stream silence — a hung or looping turn that never
// delivered its terminal event), it calls this to clear the session's busy
// state. Clearing the sidebar dot alone would leave the composer wedged on
// "Thinking"/Stop; updateSessionState propagates the clear to $sessionStates
// → $workingSessionIds (computed) follows automatically, and
// syncSessionStateToView re-syncs $busy when the healed session is the one
// on screen.
useEffect(() => {
setWatchdogClearFn(runtimeId => {
const state = sessionStateByRuntimeIdRef.current.get(runtimeId)
// When the store watchdog force-clears a stuck session (8 min of stream
// silence — a hung or looping turn that never delivered its terminal event),
// also drop that session's busy/awaiting flags here. Clearing the sidebar dot
// alone leaves the composer wedged on "Thinking"/Stop; updateSessionState
// re-syncs `$busy` when the healed session is the one on screen.
useEffect(
() =>
onSessionWatchdogClear(storedSessionId => {
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
if (!state?.busy) {
return
}
if (!runtimeId || !state?.busy) {
return
}
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
})
return () => setWatchdogClearFn(null)
}, [updateSessionState])
updateSessionState(runtimeId, current => ({
...current,
awaitingResponse: false,
busy: false,
needsInput: false
}))
}),
[updateSessionState]
)
return {
activeSessionIdRef,
@@ -1,172 +0,0 @@
import { renderHook } from '@testing-library/react'
import { beforeEach, describe, expect, it, vi } from 'vitest'
import type { BillingChargeResponse, BillingStateResponse } from './types'
const requestGatewayMock = vi.hoisted(() => vi.fn())
vi.mock('@/app/gateway/hooks/use-gateway-request', () => ({
useGatewayRequest: () => ({ requestGateway: requestGatewayMock })
}))
import { createBillingApi, useBillingApi } from './api'
describe('createBillingApi', () => {
beforeEach(() => {
requestGatewayMock.mockReset()
vi.restoreAllMocks()
})
it('passes successful RPC results through as data', async () => {
const state = {
auto_reload: null,
balance_display: '$10.00',
balance_usd: '10',
can_charge: true,
card: null,
charge_presets: ['10'],
charge_presets_display: ['$10'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '100',
min_usd: '10',
monthly_cap: null,
ok: true,
org_name: 'Nous',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER'
} satisfies BillingStateResponse
requestGatewayMock.mockResolvedValueOnce(state)
const { result } = renderHook(() => useBillingApi())
const response = await result.current.fetchBillingState()
expect(response).toEqual({ data: state, ok: true })
expect(requestGatewayMock).toHaveBeenCalledWith('billing.state', {})
})
it('normalizes object-shaped refusal envelopes', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: {
kind: 'no_payment_method',
message: 'No saved card.',
portal_url: 'https://portal.nousresearch.com/billing',
retry_after: 30
},
ok: false
})
const api = createBillingApi(requestGatewayMock)
const response = await api.chargeStatus('ch_123')
expect(response).toMatchObject({
ok: false,
refusal: {
kind: 'no_payment_method',
message: 'No saved card.',
portalUrl: 'https://portal.nousresearch.com/billing',
retryAfter: 30
}
})
expect(requestGatewayMock).toHaveBeenCalledWith('billing.charge_status', { charge_id: 'ch_123' })
})
it('normalizes current string-shaped refusal envelopes', async () => {
requestGatewayMock.mockResolvedValueOnce({
error: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
ok: false,
payload: { remainingUsd: '4.50' },
portal_url: 'https://portal.nousresearch.com/billing'
})
const api = createBillingApi(requestGatewayMock)
const response = await api.updateAutoReload({ enabled: true, reload_to_usd: '100', threshold_usd: '25' })
expect(response).toMatchObject({
ok: false,
refusal: {
kind: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '4.50' },
portalUrl: 'https://portal.nousresearch.com/billing'
}
})
expect(requestGatewayMock).toHaveBeenCalledWith('billing.auto_reload', {
enabled: true,
threshold: '25',
top_up_amount: '100'
})
})
it('maps thrown gateway failures to transport refusals', async () => {
requestGatewayMock.mockRejectedValueOnce(new Error('connection closed'))
const api = createBillingApi(requestGatewayMock)
const response = await api.fetchSubscriptionState()
expect(response).toEqual({
ok: false,
refusal: {
kind: 'transport',
message: 'connection closed',
raw: expect.any(Error)
}
})
})
it('maps thrown timeout failures to timeout refusals', async () => {
requestGatewayMock.mockRejectedValueOnce(new Error('request timed out after 5000ms'))
const api = createBillingApi(requestGatewayMock)
const response = await api.stepUp()
expect(response).toEqual({
ok: false,
refusal: {
kind: 'timeout',
message: 'request timed out after 5000ms',
raw: expect.any(Error)
}
})
})
it('sends a step-up session id when provided', async () => {
requestGatewayMock.mockResolvedValueOnce({ granted: true, ok: true })
const api = createBillingApi(requestGatewayMock)
await api.stepUp('session-123')
expect(requestGatewayMock).toHaveBeenCalledWith('billing.step_up', { session_id: 'session-123' })
})
it('sends a minted charge idempotency key and reuses it on explicit retry', async () => {
vi.spyOn(crypto, 'randomUUID').mockReturnValue('11111111-1111-4111-8111-111111111111')
const submitted = {
charge_id: 'ch_123',
idempotency_key: '11111111-1111-4111-8111-111111111111',
ok: true
} satisfies BillingChargeResponse
requestGatewayMock.mockResolvedValue(submitted)
const api = createBillingApi(requestGatewayMock)
const first = await api.charge('25')
const second = await api.charge('25', first.idempotencyKey)
expect(first).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true })
expect(second).toEqual({ data: submitted, idempotencyKey: '11111111-1111-4111-8111-111111111111', ok: true })
expect(crypto.randomUUID).toHaveBeenCalledTimes(1)
expect(requestGatewayMock).toHaveBeenNthCalledWith(1, 'billing.charge', {
amount_usd: '25',
idempotency_key: '11111111-1111-4111-8111-111111111111'
})
expect(requestGatewayMock).toHaveBeenNthCalledWith(2, 'billing.charge', {
amount_usd: '25',
idempotency_key: '11111111-1111-4111-8111-111111111111'
})
})
})
@@ -1,168 +0,0 @@
import { useMemo } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import type {
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
SubscriptionStateResponse
} from './types'
export type BillingErrorKind = BillingRefusalCode
export interface BillingRefusal {
actor?: string
code?: string
kind: BillingErrorKind | 'timeout' | 'transport'
message: string
payload?: BillingErrorPayload
portalUrl?: string
raw?: unknown
recovery?: string
retryAfter?: number
}
export type BillingResult<T> = { data: T; ok: true } | { ok: false; refusal: BillingRefusal }
export type BillingChargeResult = BillingResult<BillingChargeResponse> & { idempotencyKey: string }
export interface UpdateAutoReloadInput {
enabled: boolean
reload_to_usd?: string
threshold_usd?: string
}
export type BillingRequestGateway = <T>(
method: string,
params?: Record<string, unknown>,
timeoutMs?: number,
signal?: AbortSignal
) => Promise<T>
export interface BillingApi {
charge: (amountUsd: string, idempotencyKey?: string) => Promise<BillingChargeResult>
chargeStatus: (chargeId: string) => Promise<BillingResult<BillingChargeStatusResponse>>
fetchBillingState: () => Promise<BillingResult<BillingStateResponse>>
fetchSubscriptionState: () => Promise<BillingResult<SubscriptionStateResponse>>
stepUp: (sessionId?: string) => Promise<BillingResult<BillingMutationResponse>>
updateAutoReload: (input: UpdateAutoReloadInput) => Promise<BillingResult<BillingMutationResponse>>
}
interface RefusalRecord {
actor?: unknown
code?: unknown
error?: unknown
kind?: unknown
message?: unknown
payload?: unknown
portal_url?: unknown
recovery?: unknown
retry_after?: unknown
}
const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === 'object' && value !== null
const asOptionalString = (value: unknown): string | undefined =>
typeof value === 'string' && value.length > 0 ? value : undefined
const asOptionalNumber = (value: unknown): number | undefined => (typeof value === 'number' ? value : undefined)
const asPayload = (value: unknown): BillingErrorPayload | undefined =>
isRecord(value) ? (value as BillingErrorPayload) : undefined
const getMessage = (value: unknown): string => {
if (value instanceof Error && value.message) {
return value.message
}
if (typeof value === 'string' && value.length > 0) {
return value
}
return String(value || 'Billing request failed.')
}
const normalizeRefusal = (raw: Record<string, unknown>): BillingRefusal => {
const rawError = raw.error
const error = isRecord(rawError) ? (rawError as RefusalRecord) : undefined
const kind = asOptionalString(error?.kind) ?? asOptionalString(error?.error) ?? asOptionalString(rawError) ?? 'error'
const message = asOptionalString(error?.message) ?? asOptionalString(raw.message) ?? kind
return {
actor: asOptionalString(error?.actor) ?? asOptionalString(raw.actor),
code: asOptionalString(error?.code) ?? asOptionalString(raw.code),
kind,
message,
payload: asPayload(error?.payload) ?? asPayload(raw.payload),
portalUrl: asOptionalString(error?.portal_url) ?? asOptionalString(raw.portal_url),
raw,
recovery: asOptionalString(error?.recovery) ?? asOptionalString(raw.recovery),
retryAfter: asOptionalNumber(error?.retry_after) ?? asOptionalNumber(raw.retry_after)
}
}
const normalizeThrown = (error: unknown): BillingRefusal => {
const message = getMessage(error)
const name = error instanceof Error ? error.name : ''
return {
kind: name === 'TimeoutError' || /timed?\s*out|timeout/i.test(message) ? 'timeout' : 'transport',
message,
raw: error
}
}
const normalizeRpcResult = <T>(response: T): BillingResult<T> => {
if (isRecord(response) && response.ok === false) {
return { ok: false, refusal: normalizeRefusal(response) }
}
return { data: response, ok: true }
}
const callBilling = async <T>(
requestGateway: BillingRequestGateway,
method: string,
params: Record<string, unknown> = {}
): Promise<BillingResult<T>> => {
try {
return normalizeRpcResult(await requestGateway<T>(method, params))
} catch (error) {
return { ok: false, refusal: normalizeThrown(error) }
}
}
export const createBillingApi = (requestGateway: BillingRequestGateway): BillingApi => ({
charge: async (amountUsd, idempotencyKey = crypto.randomUUID()) => {
const result = await callBilling<BillingChargeResponse>(requestGateway, 'billing.charge', {
amount_usd: amountUsd,
idempotency_key: idempotencyKey
})
return { ...result, idempotencyKey }
},
chargeStatus: chargeId =>
callBilling<BillingChargeStatusResponse>(requestGateway, 'billing.charge_status', { charge_id: chargeId }),
fetchBillingState: () => callBilling<BillingStateResponse>(requestGateway, 'billing.state'),
fetchSubscriptionState: () => callBilling<SubscriptionStateResponse>(requestGateway, 'subscription.state'),
stepUp: sessionId =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.step_up', {
...(sessionId !== undefined ? { session_id: sessionId } : {})
}),
updateAutoReload: input =>
callBilling<BillingMutationResponse>(requestGateway, 'billing.auto_reload', {
enabled: input.enabled,
...(input.threshold_usd !== undefined ? { threshold: input.threshold_usd } : {}),
...(input.reload_to_usd !== undefined ? { top_up_amount: input.reload_to_usd } : {})
})
})
export function useBillingApi(): BillingApi {
const { requestGateway } = useGatewayRequest()
return useMemo(() => createBillingApi(requestGateway), [requestGateway])
}
@@ -1,315 +0,0 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
const current = (
overrides: Partial<NonNullable<SubscriptionStateResponse['current']>> = {}
): NonNullable<SubscriptionStateResponse['current']> => ({
cancel_at_period_end: false,
cancellation_effective_at: null,
cancellation_effective_display: null,
credits_remaining: '120',
cycle_ends_at: '2026-07-11T08:14:55.000Z',
monthly_credits: '220',
pending_downgrade_at: null,
pending_downgrade_display: null,
pending_downgrade_tier_name: null,
tier_id: 'ultra',
tier_name: 'Ultra',
...overrides
})
export const todayBillingState = {
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$10',
reload_to_usd: '10',
threshold_display: '$5',
threshold_usd: '5'
},
balance_display: '$996.47',
balance_usd: '996.47',
can_charge: false,
card: {
brand: 'visa',
last4: '3206',
masked: 'visa ....3206'
},
charge_presets: ['100', '250', '500'],
charge_presets_display: ['$100', '$250', '$500'],
cli_billing_enabled: false,
is_admin: true,
logged_in: true,
max_usd: '1000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: true,
limit_display: '$100',
limit_usd: '100',
spent_display: '$10',
spent_this_month_usd: '10'
},
ok: true,
org_name: 'sid-5',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
usage: {
available: true,
has_topup: true,
plan_name: 'Ultra',
renews_at: '2026-07-11T08:14:55.000Z',
renews_display: 'Jul 11',
status: 'active',
subscription_remaining_display: '$120',
topup_remaining_display: '$876.47',
total_spendable_display: '$996.47'
}
} satisfies BillingStateResponse
export const todaySubscriptionState = {
can_change_plan: true,
context: 'team',
current: current(),
is_admin: true,
logged_in: true,
ok: true,
org_id: 'sid-5',
org_name: 'sid-5',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
tiers: [
{
dollars_per_month_display: '$200',
is_current: true,
is_enabled: true,
monthly_credits: '220',
name: 'Ultra',
tier_id: 'ultra',
tier_order: 3
}
],
usage: todayBillingState.usage
} satisfies SubscriptionStateResponse
export const postTrainBillingState = {
...todayBillingState,
auto_reload: {
card: { kind: 'canonical' },
enabled: false,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$25',
threshold_usd: '25'
},
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: true,
card: {
brand: 'visa',
display: 'Visa ....4242 - the card on your subscription',
last4: '4242',
masked: 'visa ....4242',
resolved_via: 'subPin'
},
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$1,000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
org_name: 'Acme Research',
usage: {
available: true,
has_topup: true,
plan_bar: {
fill_fraction: 0.4,
kind: 'plan',
pct_used: 60,
remaining_display: '$40',
spent_display: '$60',
total_display: '$100'
},
plan_name: 'Pro',
renews_at: '2026-07-31T00:00:00Z',
renews_display: 'Jul 31',
status: 'active',
subscription_remaining_display: '$40',
topup_bar: {
fill_fraction: 0.75,
kind: 'topup',
pct_used: 25,
remaining_display: '$75',
spent_display: '$25',
total_display: '$100'
},
topup_remaining_display: '$75',
total_spendable_display: '$115'
}
} satisfies BillingStateResponse
export const postTrainSubscriptionState = {
...todaySubscriptionState,
current: current({
credits_remaining: '40',
cycle_ends_at: '2026-07-31T00:00:00Z',
monthly_credits: '100',
tier_id: 'pro',
tier_name: 'Pro'
}),
org_id: 'org_123',
org_name: 'Acme Research',
tiers: [
{
dollars_per_month_display: '$20',
is_current: true,
is_enabled: true,
monthly_credits: '100',
name: 'Pro',
tier_id: 'pro',
tier_order: 2
}
],
usage: postTrainBillingState.usage
} satisfies SubscriptionStateResponse
export const loggedOutBillingState = {
...todayBillingState,
auto_reload: null,
balance_display: '$0.00',
balance_usd: null,
can_charge: false,
card: null,
charge_presets: [],
charge_presets_display: [],
logged_in: false,
monthly_cap: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
usage: undefined
} satisfies BillingStateResponse
export const loggedOutSubscriptionState = {
...todaySubscriptionState,
can_change_plan: false,
current: null,
is_admin: false,
logged_in: false,
org_id: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
tiers: [],
usage: undefined
} satisfies SubscriptionStateResponse
const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
data,
ok: true
})
function withUsage(
name: string,
{
autoReload = postTrainBillingState.auto_reload,
canCharge = true,
card = postTrainBillingState.card,
cliBillingEnabled = true,
monthlyCapSpent = '89',
remaining,
subscriptionCurrent = current({ credits_remaining: remaining, monthly_credits: '220' })
}: {
autoReload?: BillingStateResponse['auto_reload']
canCharge?: boolean
card?: BillingStateResponse['card']
cliBillingEnabled?: boolean
monthlyCapSpent?: string
remaining: string
subscriptionCurrent?: SubscriptionStateResponse['current']
}
) {
const billing = {
...postTrainBillingState,
auto_reload: autoReload,
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: canCharge,
card,
cli_billing_enabled: cliBillingEnabled,
monthly_cap: {
is_default_ceiling: false,
limit_display: '$100',
limit_usd: '100',
spent_display: `$${monthlyCapSpent}`,
spent_this_month_usd: monthlyCapSpent
},
org_name: `${name} Fixture`,
usage: {
...postTrainBillingState.usage,
plan_name: 'Ultra',
subscription_remaining_display: `$${remaining}`,
total_spendable_display: '$142.50'
}
} satisfies BillingStateResponse
const subscription = {
...todaySubscriptionState,
current: subscriptionCurrent,
org_name: `${name} Fixture`,
usage: billing.usage
} satisfies SubscriptionStateResponse
return { billing: okBilling(billing), subscription: okSubscription(subscription) }
}
export const billingDevFixtures = {
healthy: withUsage('Healthy', { monthlyCapSpent: '89', remaining: '132' }),
'auto-refill-divergent': withUsage('Auto Refill Divergent', {
autoReload: {
...postTrainBillingState.auto_reload,
card: { kind: 'distinct', payment_method_id: 'pm_divergent_1', brand: 'mastercard', last4: '4444' },
enabled: true
},
remaining: '132'
}),
low: withUsage('Low', { remaining: '19.8' }),
boundary: withUsage('Boundary', { remaining: '22' }),
'empty-overdrawn': withUsage('Empty Overdrawn', { remaining: '-0.79' }),
'cap-near': withUsage('Cap Near', { monthlyCapSpent: '92', remaining: '132' }),
'cap-hit': withUsage('Cap Hit', { monthlyCapSpent: '100', remaining: '132' }),
'no-card': withUsage('No Card', { card: null, remaining: '132' }),
'no-subscription': withUsage('No Subscription', { remaining: '132', subscriptionCurrent: null }),
'logged-out': {
billing: okBilling(loggedOutBillingState),
subscription: okSubscription(loggedOutSubscriptionState)
},
refusal: {
billing: {
ok: false,
refusal: {
kind: 'temporarily_unavailable',
message: 'Billing is temporarily unavailable.',
retryAfter: 90
}
},
subscription: okSubscription(todaySubscriptionState)
},
'billing-off': {
billing: okBilling(todayBillingState),
subscription: okSubscription(todaySubscriptionState)
}
} satisfies Record<
string,
{
billing: BillingResult<BillingStateResponse>
subscription: BillingResult<SubscriptionStateResponse>
}
>
export type BillingDevFixtureName = keyof typeof billingDevFixtures
@@ -1,84 +0,0 @@
import type { KnownBillingRefusalCode } from '@hermes/shared/billing'
import { describe, expect, it } from 'vitest'
import type { BillingRefusal } from './api'
import { resolveRefusal } from './errors'
const expectedActions: Record<
KnownBillingRefusalCode | 'timeout' | 'transport',
'none' | 'portal' | 'retry' | 'step_up'
> = {
auto_top_up_disabled_failures: 'none',
cli_billing_disabled: 'portal',
consent_required: 'portal',
endpoint_unavailable: 'retry',
idempotency_conflict: 'none',
idempotency_key_required: 'none',
insufficient_scope: 'step_up',
internal_error: 'none',
invalid_charge_id: 'none',
invalid_request: 'none',
monthly_cap_exceeded: 'portal',
network_error: 'none',
no_payment_method: 'portal',
org_access_denied: 'none',
preview_rejected: 'none',
rate_limited: 'retry',
remote_spending_disabled: 'portal',
remote_spending_revoked: 'portal',
role_required: 'portal',
session_revoked: 'portal',
stripe_unavailable: 'retry',
temporarily_unavailable: 'retry',
timeout: 'retry',
transport: 'retry',
upgrade_cap_exceeded: 'none',
validation_failed: 'none'
}
describe('resolveRefusal', () => {
it('maps every known refusal kind to copy and the expected action', () => {
for (const [kind, actionType] of Object.entries(expectedActions)) {
const resolved = resolveRefusal({
kind: kind as BillingRefusal['kind'],
message: 'Server message.',
portalUrl: 'https://portal.nousresearch.com/billing',
retryAfter: 90
})
expect(resolved.title, kind).not.toHaveLength(0)
expect(resolved.message, kind).not.toHaveLength(0)
expect(resolved.action.type, kind).toBe(actionType)
}
})
it('includes monthly cap headroom when the server sends it', () => {
const resolved = resolveRefusal({
kind: 'monthly_cap_exceeded',
message: 'Monthly spend cap reached.',
payload: { remainingUsd: '4.50' }
})
expect(resolved.message).toContain('$4.50 headroom left')
})
it('includes Stripe retry timing when the server sends it', () => {
const resolved = resolveRefusal({
kind: 'stripe_unavailable',
message: 'Stripe is unavailable.',
retryAfter: 120
})
expect(resolved.message).toContain('try again in ~2 min')
})
it('falls back sanely for unknown refusal kinds', () => {
const resolved = resolveRefusal({ kind: 'new_billing_code', message: 'Something changed upstream.' })
expect(resolved).toEqual({
action: { type: 'none' },
message: 'Something changed upstream.',
title: 'Billing request failed'
})
})
})
@@ -1,162 +0,0 @@
import type { BillingRefusal } from './api'
export interface BillingRefusalPresentation {
action: { type: 'none' } | { type: 'portal'; url?: string } | { type: 'retry' } | { type: 'step_up' }
message: string
title: string
}
const portalAction = (url?: string): BillingRefusalPresentation['action'] => ({ type: 'portal', url })
const retryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
return `🟡 Too many charges right now${mins}. This isn't a payment failure.`
}
const stripeRetryMessage = (refusal: BillingRefusal): string => {
const mins = refusal.retryAfter ? ` (try again in ~${Math.max(1, Math.round(refusal.retryAfter / 60))} min)` : ''
return `Stripe is having trouble — try again shortly${mins}`
}
export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentation => {
switch (refusal.kind) {
case 'consent_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Confirm this card for terminal charges in the portal',
title: 'Card confirmation needed'
}
case 'insufficient_scope':
return {
action: { type: 'step_up' },
message: 'This needs terminal billing enabled. Start a top-up to enable it, then retry.',
title: 'Terminal billing needs approval'
}
case 'remote_spending_revoked': {
const who =
refusal.actor === 'admin'
? 'An admin turned off terminal billing for this terminal.'
: 'You turned off terminal billing for this terminal.'
return {
action: portalAction(refusal.portalUrl),
message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`,
title: 'Terminal billing was turned off'
}
}
case 'session_revoked':
return {
action: portalAction(refusal.portalUrl),
message: 'Your session was logged out. Sign in again from Settings → Gateway.',
title: 'Session logged out'
}
case 'cli_billing_disabled':
case 'remote_spending_disabled':
return {
action: portalAction(refusal.portalUrl),
message: 'Terminal billing is off for this account — an admin must enable it on the portal.',
title: 'Terminal billing is off'
}
case 'role_required':
return {
action: portalAction(refusal.portalUrl),
message: 'Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.',
title: 'Admin role required'
}
case 'idempotency_conflict':
return {
action: { type: 'none' },
message: '🔴 That charge key was already used for a different amount. Start a fresh top-up.',
title: 'Start a fresh top-up'
}
case 'no_payment_method':
return {
action: portalAction(refusal.portalUrl),
message:
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
"(one-time credit buys don't save a reusable card).",
title: 'No saved card'
}
case 'org_access_denied':
return {
action: { type: 'none' },
message: "This token isn't bound to an org you can manage",
title: 'Org access denied'
}
case 'monthly_cap_exceeded': {
const remaining = refusal.payload?.remainingUsd
return {
action: portalAction(refusal.portalUrl),
message:
remaining != null
? `🔴 Monthly spend cap reached — $${remaining} headroom left.`
: '🔴 Monthly spend cap reached.',
title: 'Monthly spend cap reached'
}
}
case 'rate_limited':
case 'temporarily_unavailable':
return {
action: { type: 'retry' },
message: retryMessage(refusal),
title: 'Too many charges right now'
}
case 'stripe_unavailable':
return {
action: { type: 'retry' },
message: stripeRetryMessage(refusal),
title: 'Stripe is having trouble'
}
case 'upgrade_cap_exceeded':
return {
action: { type: 'none' },
message: 'Daily plan-change limit reached — try again tomorrow',
title: 'Daily plan-change limit reached'
}
case 'endpoint_unavailable':
return {
action: { type: 'retry' },
message:
refusal.message ||
'Billing endpoint returned a non-JSON response (it may not be available on this deployment).',
title: 'Billing endpoint unavailable'
}
case 'timeout':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request timed out.',
title: 'Billing request timed out'
}
case 'transport':
return {
action: { type: 'retry' },
message: refusal.message || 'Billing request failed before reaching the gateway.',
title: 'Billing connection failed'
}
default:
return {
action: { type: 'none' },
message: refusal.message || 'Billing request failed.',
title: 'Billing request failed'
}
}
}
@@ -1,35 +0,0 @@
import type { BillingResult } from './api'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
export {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './dev-fixtures'
export const okBilling = (data: BillingStateResponse): BillingResult<BillingStateResponse> => ({ data, ok: true })
export const okSubscription = (data: SubscriptionStateResponse): BillingResult<SubscriptionStateResponse> => ({
data,
ok: true
})
export const endpointUnavailableBilling = {
ok: false,
refusal: {
kind: 'endpoint_unavailable',
message: 'Billing endpoint returned a non-JSON response.'
}
} satisfies BillingResult<BillingStateResponse>
export const endpointUnavailableSubscription = {
ok: false,
refusal: {
kind: 'endpoint_unavailable',
message: 'Subscription endpoint is not available.'
}
} satisfies BillingResult<SubscriptionStateResponse>
@@ -1,380 +0,0 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import {
billingDevFixtures,
loggedOutBillingState,
loggedOutSubscriptionState,
okBilling,
okSubscription,
postTrainBillingState,
postTrainSubscriptionState,
todayBillingState,
todaySubscriptionState
} from './fixtures.test-util'
import { formatUsageUpdatedAgo } from './use-billing-state'
import { BillingSettings } from './index'
const apiMocks = vi.hoisted(() => ({
charge: vi.fn(),
chargeStatus: vi.fn(),
fetchBillingState: vi.fn(),
fetchSubscriptionState: vi.fn(),
openExternal: vi.fn(),
stepUp: vi.fn(),
updateAutoReload: vi.fn()
}))
vi.mock('./api', () => ({
useBillingApi: () => ({
charge: apiMocks.charge,
chargeStatus: apiMocks.chargeStatus,
fetchBillingState: apiMocks.fetchBillingState,
fetchSubscriptionState: apiMocks.fetchSubscriptionState,
stepUp: apiMocks.stepUp,
updateAutoReload: apiMocks.updateAutoReload
})
}))
function renderBilling() {
const client = new QueryClient({ defaultOptions: { queries: { retry: false } } })
render(
<QueryClientProvider client={client}>
<BillingSettings />
</QueryClientProvider>
)
return client
}
beforeEach(() => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(todayBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(todaySubscriptionState))
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: {
openExternal: apiMocks.openExternal
}
})
})
afterEach(() => {
cleanup()
vi.clearAllMocks()
})
describe('BillingSettings', () => {
it('renders the deployed-today payload with buy controls hidden and usage rows visible', async () => {
renderBilling()
expect(await screen.findByText('$996.47')).toBeTruthy()
expect(screen.getByText('Ultra · $200/mo')).toBeTruthy()
expect(screen.getByText('Visa •••• 3206')).toBeTruthy()
expect(
screen.getByText('Terminal billing is off for this account — an admin must enable it on the portal.')
).toBeTruthy()
expect(screen.queryByRole('button', { name: '$100' })).toBeNull()
expect(screen.getByText('Refill $10 when 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)
expect(screen.getByText('Default ceiling')).toBeTruthy()
})
it('renders the post-train payload with enabled buy controls and card provenance', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect(await screen.findByText('$142.50')).toBeTruthy()
expect(screen.getByText('Visa •••• 4242 - subscription card')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(false)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' })).toBeTruthy()
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(false)
})
it('disables buy controls when no card is on file', async () => {
const fixture = billingDevFixtures['no-card']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect(await screen.findByText('No card on file')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: /^Buy$/ }))
expect(apiMocks.charge).not.toHaveBeenCalled()
})
it('saves enabled auto-refill edits and refreshes billing state', async () => {
const client = renderBilling()
const invalidate = vi.spyOn(client, 'invalidateQueries')
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
await waitFor(() =>
expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({
enabled: true,
reload_to_usd: '20',
threshold_usd: '15'
})
)
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'state'] }))
expect(await screen.findByText('Auto-refill updated.')).toBeTruthy()
})
it('rejects auto-refill amounts outside the billing bounds', async () => {
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '7.50' }
})
expect(screen.getByText('Threshold: minimum is $10.')).toBeTruthy()
expect(screen.getByRole('button', { name: 'Save' }).hasAttribute('disabled')).toBe(true)
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
})
it('requires inline confirmation before disabling auto-refill', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({ data: { ok: true }, ok: true })
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.click(screen.getByRole('button', { name: 'Disable' }))
expect(screen.getByText('Turn off auto-refill?')).toBeTruthy()
expect(apiMocks.updateAutoReload).not.toHaveBeenCalled()
fireEvent.click(screen.getByRole('button', { name: 'Turn off' }))
await waitFor(() => expect(apiMocks.updateAutoReload).toHaveBeenCalledWith({ enabled: false }))
})
it('renders auto-refill mutation refusals and step-up affordance', async () => {
renderBilling()
apiMocks.updateAutoReload.mockResolvedValue({
ok: false,
refusal: {
kind: 'insufficient_scope',
message: 'billing:manage required'
}
})
fireEvent.click(await screen.findByRole('button', { name: 'Manage' }))
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill threshold' }), {
target: { value: '15' }
})
fireEvent.change(screen.getByRole('spinbutton', { name: 'Auto-refill reload-to amount' }), {
target: { value: '20' }
})
fireEvent.click(screen.getByRole('button', { name: 'Save' }))
expect(await screen.findByText('Terminal billing needs approval:')).toBeTruthy()
expect(
screen.getByText('This needs terminal billing enabled. Start a top-up to enable it, then retry.')
).toBeTruthy()
expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy()
})
it('keeps disabled auto-refill portal-only with no enable control', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
renderBilling()
expect((await screen.findAllByText('Off')).length).toBeGreaterThan(0)
expect(screen.getByText('Turn on auto-refill from the portal')).toBeTruthy()
expect(screen.queryByRole('button', { name: /enable/i })).toBeNull()
expect(screen.queryByRole('button', { name: 'Manage' })).toBeNull()
})
it('disables buy controls while polling and renders the settled outcome', async () => {
let settleStatus: (value: unknown) => void = () => {}
const statusPromise = new Promise(resolve => {
settleStatus = resolve
})
apiMocks.fetchBillingState.mockResolvedValue(okBilling(postTrainBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(postTrainSubscriptionState))
apiMocks.charge.mockResolvedValue({
data: {
charge_id: 'ch_123',
ok: true
},
idempotencyKey: 'key-1',
ok: true
})
apiMocks.chargeStatus.mockReturnValue(statusPromise)
renderBilling()
fireEvent.click(await screen.findByRole('button', { name: /^Buy$/ }))
expect(await screen.findByText('Processing… checking settlement')).toBeTruthy()
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('spinbutton', { name: 'Custom credit amount' }).hasAttribute('disabled')).toBe(true)
expect(screen.getByRole('button', { name: /^Buy$/ }).hasAttribute('disabled')).toBe(true)
settleStatus({
data: {
amount_usd: '25',
ok: true,
status: 'settled'
},
ok: true
})
await waitFor(() => expect(screen.getByText('$25 added. Balance is refreshing.')).toBeTruthy())
})
it('renders logged-out as a connect card without normal account rows', async () => {
apiMocks.fetchBillingState.mockResolvedValue(okBilling(loggedOutBillingState))
apiMocks.fetchSubscriptionState.mockResolvedValue(okSubscription(loggedOutSubscriptionState))
renderBilling()
expect(await screen.findByText('Connect your Nous account')).toBeTruthy()
expect(screen.getByText('Run /portal in the TUI or open the Nous portal to connect your account.')).toBeTruthy()
expect(screen.queryByText('Payment method')).toBeNull()
expect(screen.queryByText('Usage')).toBeNull()
})
it('renders danger value text for overdrawn subscription credits', async () => {
const fixture = billingDevFixtures['empty-overdrawn']
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true)
expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true)
})
it('renders an empty neutral usage track when a row has no bar data', async () => {
const fixture = billingDevFixtures['no-subscription']
apiMocks.fetchBillingState.mockResolvedValue(
okBilling({
...todayBillingState,
monthly_cap: {
...todayBillingState.monthly_cap,
spent_display: '$0',
spent_this_month_usd: '0'
}
})
)
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
renderBilling()
await screen.findByText('Subscription credits')
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits usage' })
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
expect(monthlyCapTrack.classList.contains('dither')).toBe(true)
expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true)
})
it('refreshes both billing queries from the usage refresh button', async () => {
renderBilling()
await screen.findByText('$120 of $220 left')
expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1)
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1)
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2))
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2)
})
it('disables the usage refresh button while either query is fetching', async () => {
let settleBilling: (value: unknown) => void = () => {}
let settleSubscription: (value: unknown) => void = () => {}
apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce(
new Promise(resolve => {
settleBilling = resolve
})
)
apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce(
new Promise(resolve => {
settleSubscription = resolve
})
)
renderBilling()
const refresh = await screen.findByRole('button', { name: 'Refresh' })
fireEvent.click(refresh)
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true))
settleBilling(okBilling(todayBillingState))
settleSubscription(okSubscription(todaySubscriptionState))
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false))
})
})
describe('formatUsageUpdatedAgo', () => {
it('formats sub-second and current timestamps as just now', () => {
expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now')
expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now')
})
it('formats seconds below a minute', () => {
expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago')
})
it('rounds elapsed time to whole minutes from 61 seconds', () => {
expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago')
})
it('formats one hour and later as hours', () => {
expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago')
})
})
@@ -1,961 +0,0 @@
import { useQueryClient } from '@tanstack/react-query'
import { useEffect, useMemo, useState } from 'react'
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 { cn } from '@/lib/utils'
import { ListRow, Pill, SectionHeading, SettingsContent } from '../primitives'
import type { BillingRefusal } from './api'
import { useBillingApi } from './api'
import { type BillingDevFixtureName, billingDevFixtures } from './dev-fixtures'
import { resolveRefusal } from './errors'
import type { BillingAutoReload, BillingStateResponse } from './types'
import {
type BillingAccountRowView,
type BillingNoticeView,
type BillingUsageRowView,
deriveBillingView,
EMPTY_BILLING_VALUE,
formatUsageUpdatedAgo,
useBillingState,
useSubscriptionState
} from './use-billing-state'
import { useChargeFlow } from './use-charge-poller'
import { useStepUpFlow } from './use-step-up'
const FEATURE_BILLING_INVOICES = false
const BILLING_DEV_FIXTURE_NAMES = import.meta.env.DEV
? (Object.keys(billingDevFixtures) as BillingDevFixtureName[])
: []
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">
<div className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">{label}</div>
<div
className={cn(
'mt-1 min-w-0 truncate text-lg font-semibold tabular-nums',
tone === 'primary' ? 'text-(--ui-green)' : tone === 'muted' ? 'text-(--ui-text-tertiary)' : 'text-foreground'
)}
>
{value}
</div>
</div>
)
}
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
return (
<div className="mb-5 rounded-lg border border-border/70 bg-muted/20 p-4">
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{notice.title}</div>
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
{notice.message}
</div>
{notice.action && (
<Button
className="mt-3"
onClick={() => openExternal(notice.action?.url)}
size="sm"
type="button"
variant="outline"
>
{notice.action.label}
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
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} 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} />
}
if (row.id === 'auto_reload' && billing?.auto_reload) {
return <AutoReloadRow autoReload={billing.auto_reload} bounds={billing} row={row} />
}
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>
) : undefined
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
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(
() =>
billing.charge_presets.map((amount, index) => ({
amount,
label: billing.charge_presets_display[index] || formatMoney(amount)
})),
[billing.charge_presets, billing.charge_presets_display]
)
const initialAmount = presets[0]?.amount ?? billing.min_usd ?? ''
const [amount, setAmount] = useState(initialAmount)
const flow = useChargeFlow()
const busy = flow.phase === 'charging' || flow.phase === 'polling'
const controlsDisabled = busy || !billing.card
const clampedAmount = clampAmount(amount, billing)
const canBuy = !controlsDisabled && clampedAmount !== ''
const startBuy = () => {
if (!canBuy) {
return
}
setAmount(clampedAmount)
void flow.start(clampedAmount)
}
return (
<ListRow
action={
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
{presets.map(preset => (
<Button
aria-pressed={amount === preset.amount}
disabled={controlsDisabled}
key={preset.amount}
onClick={() => setAmount(preset.amount)}
size="sm"
type="button"
variant={amount === preset.amount ? 'default' : 'outline'}
>
{preset.label}
</Button>
))}
<Input
aria-label="Custom credit amount"
className="h-8 w-24"
disabled={controlsDisabled}
inputMode="decimal"
max={billing.max_usd ?? undefined}
min={billing.min_usd ?? undefined}
onBlur={() => setAmount(clampedAmount)}
onChange={event => {
flow.reset()
setAmount(event.target.value)
}}
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
step="0.01"
type="number"
value={amount}
/>
<Button disabled={!canBuy} onClick={startBuy} size="sm" type="button" variant="outline">
Buy
</Button>
</div>
}
below={
<BuyCreditsOutcome
amount={clampedAmount}
busy={busy}
onPortal={openExternal}
onRetry={() => {
if (!clampedAmount) {
return
}
void flow.start(clampedAmount)
}}
outcome={flow.outcome}
/>
}
description={row.description}
key={row.id}
title={row.title}
/>
)
}
function BuyCreditsOutcome({
amount,
busy,
onPortal,
onRetry,
outcome
}: {
amount: string
busy: boolean
onPortal: (url?: string) => void
onRetry: () => void
outcome: ReturnType<typeof useChargeFlow>['outcome']
}) {
const stepUp = useStepUpFlow()
if (busy) {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
Processing checking settlement
</div>
)
}
if (!outcome) {
return null
}
if (outcome.kind === 'success') {
return (
<div className="mt-2 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
{formatMoney(outcome.amountUsd ?? amount)} added. Balance is refreshing.
</div>
)
}
if (outcome.kind === 'ambiguous') {
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>
{outcome.title}: {outcome.message}
</span>
{outcome.portalUrl && (
<Button onClick={() => onPortal(outcome.portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
const portalUrl = outcome.action?.type === 'portal' ? outcome.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>
{outcome.title}: {outcome.message}
</span>
{outcome.action?.type === 'retry' && (
<Button onClick={onRetry} size="sm" type="button" variant="outline">
Retry
</Button>
)}
{outcome.action?.type === 'step_up' && <StepUpInlineAction flow={stepUp} />}
{portalUrl && (
<Button onClick={() => onPortal(portalUrl)} size="sm" type="button" variant="outline">
Open portal
<ExternalLink className="size-3.5" />
</Button>
)}
</div>
)
}
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`,
state: 'neutral',
tone: 'topup',
value: 0
}
const width = Math.round(resolvedBar.value * 100)
const isEmpty = resolvedBar.value === 0
const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0
return (
<div
aria-label={resolvedBar.label}
aria-valuemax={100}
aria-valuemin={0}
aria-valuenow={width}
className={cn(
// Radius follows the app-wide rounded-full progress-bar idiom.
'relative h-2 w-full overflow-hidden rounded-full',
resolvedBar.track === 'danger'
? 'dither text-destructive/60 bg-destructive/10'
: isEmpty
? 'dither bg-(--ui-bg-elevated)'
: 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
)}
role="progressbar"
>
{showDangerNub && <div className="absolute inset-y-0 left-0 z-10 w-2 rounded-full bg-destructive" />}
<div
className={cn(
'relative h-full rounded-full transition-[width] duration-300 ease-out',
resolvedBar.state === 'danger'
? 'bg-destructive'
: resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
? 'bg-(--ui-green)'
: 'bg-muted-foreground/45'
)}
style={{
minWidth: resolvedBar.value > 0 ? 4 : undefined,
width: `${width}%`
}}
/>
</div>
)
}
function UsageRow({ row }: { row: BillingUsageRowView }) {
return (
<div className="@container">
<div className="grid min-w-0 gap-2 py-3 @2xl:grid-cols-[minmax(0,180px)_minmax(0,1fr)_220px] @2xl:items-center @2xl:gap-4">
<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)] text-(--ui-text-tertiary)">
{row.caption}
</div>
</div>
<div className="min-w-0">
<UsageBar bar={row.bar} fallbackLabel={row.title} />
</div>
<div
className={cn(
'min-w-0 whitespace-nowrap text-[length:var(--conversation-text-font-size)] font-medium tabular-nums @2xl:w-[220px] @2xl:flex-none @2xl:text-right',
row.bar?.state === 'danger' ? 'text-destructive' : 'text-foreground'
)}
>
{row.value}
</div>
</div>
</div>
)
}
function UsageRefreshRow({
fixtureName,
isFetching,
onRefresh,
updatedAt
}: {
fixtureName?: BillingFixtureSelection
isFetching: boolean
onRefresh: () => void
updatedAt: number
}) {
const [now, setNow] = useState(() => Date.now())
useEffect(() => {
const interval = window.setInterval(() => setNow(Date.now()), 30_000)
return () => window.clearInterval(interval)
}, [])
if (fixtureName && fixtureName !== 'live') {
return (
<div className="flex items-center justify-end pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
fixture: {fixtureName}
</div>
)
}
return (
<div className="flex min-w-0 items-center justify-end gap-1.5 pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
<span>Updated {formatUsageUpdatedAgo(updatedAt, now)}</span>
<Tip label="Refresh">
<Button
aria-label="Refresh"
className="size-7 p-0 text-(--ui-text-tertiary)"
disabled={isFetching}
onClick={onRefresh}
size="sm"
type="button"
variant="ghost"
>
<RefreshCw className={cn('size-3.5', isFetching && 'animate-spin')} />
</Button>
</Tip>
</div>
)
}
function BillingFixtureSelect({
onValueChange,
value
}: {
onValueChange: (value: BillingFixtureSelection) => void
value: BillingFixtureSelection
}) {
return (
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
<SelectTrigger
aria-label="Billing fixture"
className="h-7 w-32 border-transparent bg-transparent px-1.5 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
size="sm"
>
<SelectValue />
</SelectTrigger>
<SelectContent align="end">
<SelectItem value="live">live</SelectItem>
{BILLING_DEV_FIXTURE_NAMES.map(name => (
<SelectItem key={name} value={name}>
{name}
</SelectItem>
))}
</SelectContent>
</Select>
)
}
function BillingHeader({
fixtureName,
onFixtureChange
}: {
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
return (
<div className="mb-2.5 flex items-center justify-between gap-3 pt-2 text-[length:var(--conversation-text-font-size)] font-medium">
<div className="flex min-w-0 items-center gap-2">
<BarChart3 className="size-4 shrink-0 text-muted-foreground" />
<span>Billing</span>
</div>
{import.meta.env.DEV && fixtureName && onFixtureChange ? (
<BillingFixtureSelect onValueChange={onFixtureChange} value={fixtureName} />
) : null}
</div>
)
}
function BillingSettingsContent({
fixtureName,
onFixtureChange
}: {
fixtureName?: BillingFixtureSelection
onFixtureChange?: (value: BillingFixtureSelection) => void
}) {
const fixture =
import.meta.env.DEV && fixtureName && fixtureName !== 'live' ? billingDevFixtures[fixtureName] : undefined
const billingState = useBillingState(!fixture)
const subscriptionState = useSubscriptionState(!fixture)
const billingResult = fixture?.billing ?? billingState.data
const subscriptionResult = fixture?.subscription ?? subscriptionState.data
const view = deriveBillingView(billingResult, subscriptionResult)
const billing = billingResult?.ok ? billingResult.data : undefined
const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt)
const usageIsFetching = billingState.isFetching || subscriptionState.isFetching
const refreshUsage = () => {
void Promise.all([billingState.refetch(), subscriptionState.refetch()])
}
return (
<SettingsContent>
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
<div className="@container mb-5">
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 @2xl:grid-cols-3">
{view.summary.map(item => (
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
))}
</div>
</div>
{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.usageRows.length > 0 && (
<>
<SectionHeading icon={BarChart3} title="Usage" />
<div className="@container rounded-lg border border-border/70 bg-muted/20 px-4 py-2">
{view.usageRows.map(row => (
<UsageRow key={row.id} row={row} />
))}
<UsageRefreshRow
fixtureName={fixtureName}
isFetching={usageIsFetching}
onRefresh={refreshUsage}
updatedAt={usageUpdatedAt}
/>
</div>
</>
)}
{
// no endpoint yet — NAS capability-board gap
FEATURE_BILLING_INVOICES ? <SectionHeading icon={BarChart3} title="Invoices" /> : null
}
</SettingsContent>
)
}
function BillingSettingsWithDevFixtures() {
const [fixtureName, setFixtureName] = useState<BillingFixtureSelection>('live')
return <BillingSettingsContent fixtureName={fixtureName} onFixtureChange={setFixtureName} />
}
export function BillingSettings() {
if (import.meta.env.DEV) {
return <BillingSettingsWithDevFixtures />
}
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)
}
@@ -1,117 +0,0 @@
import { describe, expect, it } from 'vitest'
import type { BillingStateResponse, SubscriptionStateResponse } from './types'
const fullBillingState = {
auto_reload: {
card: { kind: 'canonical' },
enabled: true,
reload_to_display: '$100',
reload_to_usd: '100',
threshold_display: '$25',
threshold_usd: '25'
},
balance_display: '$142.50',
balance_usd: '142.50',
can_charge: true,
card: {
brand: 'visa',
display: 'Visa ....4242 - the card on your subscription',
last4: '4242',
masked: 'visa ....4242',
resolved_via: 'subPin'
},
charge_presets: ['25', '50', '100'],
charge_presets_display: ['$25', '$50', '$100'],
cli_billing_enabled: true,
is_admin: true,
logged_in: true,
max_usd: '10000',
min_usd: '10',
monthly_cap: {
is_default_ceiling: false,
limit_display: '$1,000',
limit_usd: '1000',
spent_display: '$180',
spent_this_month_usd: '180'
},
ok: true,
org_name: 'Acme Research',
portal_url: 'https://portal.nousresearch.com/billing',
role: 'OWNER',
usage: {
available: true,
has_topup: true,
plan_bar: {
fill_fraction: 0.4,
kind: 'plan',
pct_used: 60,
remaining_display: '$40',
spent_display: '$60',
total_display: '$100'
},
plan_name: 'Pro',
renews_at: '2026-07-31T00:00:00Z',
renews_display: 'Jul 31',
status: 'active',
subscription_remaining_display: '$40',
topup_bar: {
fill_fraction: 0.75,
kind: 'topup',
pct_used: 25,
remaining_display: '$75',
spent_display: '$25',
total_display: '$100'
},
topup_remaining_display: '$75',
total_spendable_display: '$115'
}
} satisfies BillingStateResponse
const deployedTodayBillingState = {
auto_reload: null,
balance_display: '$0.00',
balance_usd: null,
can_charge: false,
card: {
brand: 'mastercard',
last4: '4444',
masked: 'mastercard ....4444'
},
charge_presets: [],
charge_presets_display: [],
cli_billing_enabled: false,
is_admin: true,
logged_in: true,
max_usd: null,
min_usd: null,
monthly_cap: null,
ok: true,
org_name: 'Fresh Deploy',
portal_url: null,
role: 'OWNER'
} satisfies BillingStateResponse
const loggedOutSubscriptionState = {
can_change_plan: false,
context: 'personal',
current: null,
is_admin: false,
logged_in: false,
ok: true,
org_id: null,
org_name: null,
portal_url: 'https://portal.nousresearch.com/login',
role: null,
tiers: []
} satisfies SubscriptionStateResponse
describe('desktop billing wire types', () => {
it('pins realistic billing and subscription RPC payload shapes', () => {
expect(fullBillingState.card?.resolved_via).toBe('subPin')
expect(deployedTodayBillingState.can_charge).toBe(false)
expect(deployedTodayBillingState.cli_billing_enabled).toBe(false)
expect(deployedTodayBillingState.card?.last4).toBe('4444')
expect(loggedOutSubscriptionState.logged_in).toBe(false)
})
})
@@ -1,33 +0,0 @@
import type {
BillingAutoReload,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
UsageModelData
} from '@hermes/shared/billing'
export type {
BillingAutoReload,
BillingCardInfo,
BillingChargeResponse,
BillingChargeStatusResponse,
BillingErrorPayload,
BillingMonthlyCap,
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
ChargeFailureReason,
SubscriptionStateResponse,
SubscriptionTierOption,
UsageBarData,
UsageModelData
}

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