Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7acd6c902c | ||
|
|
0cb42c10a4 | ||
|
|
169bfe20e4 |
@@ -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,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::"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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) }}
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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 }}"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: >-
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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 ----------
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
):
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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})
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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 upgrade→Checkout / downgrade→scheduled
|
||||
internally. ``org_id`` pins the page to the right account in multi-org
|
||||
situations. Returns ``None`` when no portal URL is resolvable.
|
||||
"""
|
||||
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}")
|
||||
|
||||
@@ -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 "
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
@@ -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
@@ -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"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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, [])
|
||||
})
|
||||
@@ -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 }
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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)')
|
||||
})
|
||||
})
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
})
|
||||
|
||||
-155
@@ -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
Reference in New Issue
Block a user