Compare commits
9
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c83f85abe6 | ||
|
|
1b54d38e75 | ||
|
|
34d7d3fe2e | ||
|
|
7e1e9d62c4 | ||
|
|
d8c00b8ce2 | ||
|
|
883fa67b7e | ||
|
|
7e06e61fc8 | ||
|
|
318910ce80 | ||
|
|
2704e6e39c |
@@ -165,67 +165,3 @@ jobs:
|
||||
sys.exit(1)
|
||||
print('All checks passed (or were skipped)')
|
||||
"
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# CI timing report: collect per-job/step durations from the GitHub API,
|
||||
# cache them on main (as a baseline), and on PRs generate an HTML diff
|
||||
# report with a gantt chart + per-step breakdown. The report is uploaded
|
||||
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
ci-timings:
|
||||
name: CI timing report
|
||||
needs: [all-checks-pass, docker]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Restore baseline cache (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ci-timings-baseline.json
|
||||
# Prefix-match: exact key will never hit (run_id differs), so
|
||||
# restore-keys finds the most recent baseline from main.
|
||||
key: ci-timings-baseline-never-exact
|
||||
restore-keys: |
|
||||
ci-timings-baseline-
|
||||
|
||||
- name: Collect timings and generate report
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--baseline ci-timings-baseline.json \
|
||||
--output ci-timings-report.html \
|
||||
--json-out ci-timings.json \
|
||||
--summary-out ci-timings-summary.md
|
||||
|
||||
- name: Upload HTML report
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
id: ci-timings-artifact
|
||||
with:
|
||||
name: ci-timings-report
|
||||
path: ci-timings-report.html
|
||||
retention-days: 14
|
||||
archive: false
|
||||
|
||||
- name: Output summary
|
||||
env:
|
||||
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
|
||||
run: |
|
||||
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
|
||||
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Save baseline cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
run: cp ci-timings.json ci-timings-baseline.json
|
||||
|
||||
- name: Upload baseline to cache (main only)
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
|
||||
uses: actions/cache/save@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
with:
|
||||
path: ci-timings-baseline.json
|
||||
key: ci-timings-baseline-${{ github.run_id }}
|
||||
|
||||
+176
-70
@@ -7,11 +7,15 @@ on:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
# Needed so the arm64 job can push/pull its registry-backed build cache
|
||||
# to ghcr.io (cache-to/cache-from type=registry). See the build-arm64
|
||||
# job for why registry cache replaced the gha cache on that arch.
|
||||
packages: write
|
||||
|
||||
# Concurrency: push/release runs are NEVER cancelled so every merge gets
|
||||
# its own image. PR runs reuse a PR-scoped group with
|
||||
# cancel-in-progress: true so rapid pushes to the same PR collapse to
|
||||
# the latest commit.
|
||||
# cancel-in-progress: true so rapid pushes to the same PR collapse to the
|
||||
# latest commit.
|
||||
concurrency:
|
||||
group: docker-${{ github.event.pull_request.number || github.ref }}
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
@@ -20,91 +24,39 @@ env:
|
||||
IMAGE_NAME: nousresearch/hermes-agent
|
||||
|
||||
jobs:
|
||||
# Build, test, and optionally push the image for each architecture.
|
||||
build:
|
||||
# Build, test, and optionally push the amd64 image.
|
||||
build-amd64:
|
||||
# Only run on the upstream repository, not on forks
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- arch: amd64
|
||||
runner: ubuntu-latest
|
||||
platform: linux/amd64
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
- arch: arm64
|
||||
runner: ubuntu-24.04-arm
|
||||
platform: linux/arm64
|
||||
cache-from: type=gha,scope=docker-arm64
|
||||
cache-to: type=gha,mode=max,scope=docker-arm64
|
||||
|
||||
runs-on: ${{ matrix.runner }}
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# The image build + integration tests run on every event
|
||||
# (PRs, push-to-main, release). Publish steps below are gated to
|
||||
# push-to-main / release only.
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Build once, load into the local daemon for testing. Cached
|
||||
# per-arch; the push step below reuses every layer from this build.
|
||||
- name: Build image (${{ matrix.arch }})
|
||||
# to gha with a per-arch scope; the push step below reuses every
|
||||
# layer from this build.
|
||||
- name: Build image (amd64)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: ${{ matrix.platform }}
|
||||
platforms: linux/amd64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Push by digest only (no tag). The merge job assembles the
|
||||
# tagged manifest list. `push-by-digest=true` is docker's recommended
|
||||
# pattern for multi-runner multi-platform builds.
|
||||
- name: Push ${{ matrix.arch }} by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: ${{ matrix.platform }}
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: ${{ matrix.cache-from }}
|
||||
cache-to: ${{ matrix.cache-to }}
|
||||
|
||||
# Write the digest to a file and upload it as an artifact so the
|
||||
# merge job can stitch both per-arch digests into a manifest list.
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digest-${{ matrix.arch }}
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
|
||||
# Run the docker-integration test suite against the freshly-built
|
||||
# image already loaded into the local daemon (`:test`).
|
||||
@@ -146,6 +98,160 @@ jobs:
|
||||
run: |
|
||||
scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
# Push amd64 by digest only (no tag). The merge job assembles the
|
||||
# tagged manifest list. `push-by-digest=true` is docker's recommended
|
||||
# pattern for multi-runner multi-platform builds.
|
||||
- name: Push amd64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/amd64
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=gha,scope=docker-amd64
|
||||
cache-to: type=gha,mode=max,scope=docker-amd64
|
||||
|
||||
# Write the digest to a file and upload it as an artifact so the
|
||||
# merge job can stitch both per-arch digests into a manifest list.
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digest-amd64
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Build, test, and optionally push the arm64 image.
|
||||
# ---------------------------------------------------------------------------
|
||||
build-arm64:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-24.04-arm
|
||||
timeout-minutes: 45
|
||||
outputs:
|
||||
digest: ${{ steps.push.outputs.digest }}
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Set up Docker Buildx
|
||||
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
|
||||
|
||||
# Log in to ghcr.io so the registry-backed build cache below can be
|
||||
# read (cache-from) on every event and written (cache-to) on
|
||||
# push/release. Uses the workflow's GITHUB_TOKEN, which is valid for
|
||||
# the whole job — unlike the gha cache backend's short-lived Azure SAS
|
||||
# token, which expired mid-build on slow cold-cache arm64 runs and
|
||||
# crashed the build before the tests ran (the reason the gha cache
|
||||
# was removed from arm64 PRs in the first place).
|
||||
- name: Log in to ghcr.io (build cache)
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
registry: ghcr.io
|
||||
username: ${{ github.actor }}
|
||||
password: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
# Build once, load into the local daemon for testing, then push
|
||||
# by digest below. Reads AND writes the registry-backed cache so the
|
||||
# push reuses layers from this build and the next build starts warm.
|
||||
#
|
||||
# Registry cache (type=registry on ghcr.io) is used instead of the gha
|
||||
# cache that previously broke here: its credential is the job-lifetime
|
||||
# GITHUB_TOKEN, not a short-lived SAS token, so the cold-build-outlives-
|
||||
# token failure mode cannot recur.
|
||||
- name: Build image (arm64, cached publish)
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
load: true
|
||||
platforms: linux/arm64
|
||||
tags: ${{ env.IMAGE_NAME }}:test
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Install uv for docker tests
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
|
||||
- name: Set up Python 3.11 for docker tests
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies for docker tests
|
||||
run: |
|
||||
uv sync --locked --python 3.11 --extra dev
|
||||
|
||||
- name: Run docker tests
|
||||
env:
|
||||
# Skip rebuild; use the image already loaded by the build step.
|
||||
HERMES_TEST_IMAGE: ${{ env.IMAGE_NAME }}:test
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
run: |
|
||||
scripts/run_tests.sh tests/docker/ --file-timeout 600
|
||||
|
||||
- name: Log in to Docker Hub
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
|
||||
with:
|
||||
username: ${{ secrets.DOCKERHUB_USERNAME }}
|
||||
password: ${{ secrets.DOCKERHUB_TOKEN }}
|
||||
|
||||
- name: Push arm64 by digest
|
||||
id: push
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
|
||||
with:
|
||||
context: .
|
||||
file: Dockerfile
|
||||
platforms: linux/arm64
|
||||
labels: |
|
||||
org.opencontainers.image.revision=${{ github.sha }}
|
||||
build-args: |
|
||||
HERMES_GIT_SHA=${{ github.sha }}
|
||||
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
|
||||
cache-from: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64
|
||||
cache-to: type=registry,ref=ghcr.io/nousresearch/hermes-agent:buildcache-arm64,mode=max
|
||||
|
||||
- name: Export digest
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
run: |
|
||||
mkdir -p /tmp/digests
|
||||
digest="${{ steps.push.outputs.digest }}"
|
||||
touch "/tmp/digests/${digest#sha256:}"
|
||||
|
||||
- name: Upload digest artifact
|
||||
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: digest-arm64
|
||||
path: /tmp/digests/*
|
||||
if-no-files-found: error
|
||||
retention-days: 1
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stitch both per-arch digests into a single tagged multi-arch manifest.
|
||||
# This is a registry-side operation — no building, no layer re-push —
|
||||
@@ -157,7 +263,7 @@ jobs:
|
||||
merge:
|
||||
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
|
||||
runs-on: ubuntu-latest
|
||||
needs: [build]
|
||||
needs: [build-amd64, build-arm64]
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download digests
|
||||
|
||||
@@ -109,6 +109,46 @@ jobs:
|
||||
--output .lint-reports/summary.md
|
||||
cat .lint-reports/summary.md >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
- name: Upload reports as artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: lint-reports
|
||||
path: .lint-reports/
|
||||
retention-days: 14
|
||||
|
||||
- name: Post / update PR comment
|
||||
if: inputs.event_name == 'pull_request' && github.event.pull_request.head.repo.full_name == github.repository
|
||||
continue-on-error: true
|
||||
uses: actions/github-script@60a0d83039c74a4aee543508d2ffcb1c3799cdea # v7
|
||||
with:
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const body = fs.readFileSync('.lint-reports/summary.md', 'utf8');
|
||||
const marker = '<!-- lint-diff-summary -->';
|
||||
const fullBody = marker + '\n' + body;
|
||||
|
||||
const { data: comments } = await github.rest.issues.listComments({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
});
|
||||
const existing = comments.find(c => c.body && c.body.includes(marker));
|
||||
if (existing) {
|
||||
await github.rest.issues.updateComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
comment_id: existing.id,
|
||||
body: fullBody,
|
||||
});
|
||||
} else {
|
||||
await github.rest.issues.createComment({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
body: fullBody,
|
||||
});
|
||||
}
|
||||
|
||||
ruff-blocking:
|
||||
# Enforce the rules in pyproject.toml [tool.ruff.lint.select]. Currently
|
||||
# PLW1514 (unspecified-encoding) — catches bare ``open()`` /
|
||||
|
||||
@@ -1289,22 +1289,65 @@ scripts/run_tests.sh # full suite, CI-parity
|
||||
scripts/run_tests.sh tests/gateway/ # one directory
|
||||
scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
|
||||
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
|
||||
scripts/run_tests.sh --no-isolate tests/foo/ # disable subprocess isolation (faster, for debugging)
|
||||
```
|
||||
|
||||
### Subprocess-per-test-file isolation
|
||||
### Subprocess-per-test isolation
|
||||
|
||||
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
|
||||
ContextVars from one test file cannot leak into the next.
|
||||
Every test runs in a freshly-spawned Python subprocess via the in-tree plugin
|
||||
at `tests/_isolate_plugin.py`. This means module-level dicts/sets and
|
||||
ContextVars from one test cannot leak into the next — the historic
|
||||
`_reset_module_state` autouse fixture is gone.
|
||||
|
||||
### Why the wrapper
|
||||
Implementation notes:
|
||||
|
||||
| | Without wrapper | With wrapper |
|
||||
| ------------------- | ------------------------------------------- | ----------------------------------------- |
|
||||
| Provider API keys | Whatever is in your env (auto-detects pool) | All env vars except a specific few unset. |
|
||||
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
|
||||
| Timezone | Local TZ (PDT etc.) | UTC |
|
||||
| Locale | Whatever is set | C.UTF-8 |
|
||||
- The plugin uses `multiprocessing.get_context("spawn")`, which works on
|
||||
Linux, macOS, and Windows alike (POSIX `fork` is not used).
|
||||
- Per-test overhead is ~0.5–1.0s (Python startup + pytest collection). xdist
|
||||
parallelism amortizes this across cores; on a 20-core box the full suite
|
||||
finishes in roughly the same wall time as before, but flake-free.
|
||||
- `isolate_timeout` (configured in `pyproject.toml`) caps each test at 30s.
|
||||
Hangs are killed and surfaced as a failure report.
|
||||
- Pass `--no-isolate` to disable isolation — useful when debugging a single
|
||||
test interactively, or when you specifically want to verify state leakage.
|
||||
- The plugin disables itself in child processes (sentinel envvar
|
||||
`HERMES_ISOLATE_CHILD=1`), so there's no fork-bomb risk.
|
||||
|
||||
### Why the wrapper (and why the old "just call pytest" doesn't work)
|
||||
|
||||
Five real sources of local-vs-CI drift the script closes:
|
||||
|
||||
| | Without wrapper | With wrapper |
|
||||
|---|---|---|
|
||||
| Provider API keys | Whatever is in your env (auto-detects pool) | All `*_API_KEY`/`*_TOKEN`/etc. unset |
|
||||
| HOME / `~/.hermes/` | Your real config+auth.json | Temp dir per test |
|
||||
| Timezone | Local TZ (PDT etc.) | UTC |
|
||||
| Locale | Whatever is set | C.UTF-8 |
|
||||
| xdist workers | `-n auto` = all cores | `-n auto` (safe — subprocess isolation prevents cross-worker flakes) |
|
||||
|
||||
`tests/conftest.py` also enforces points 1-4 as an autouse fixture so ANY pytest
|
||||
invocation (including IDE integrations) gets hermetic behavior — but the wrapper
|
||||
is belt-and-suspenders.
|
||||
|
||||
### Running without the wrapper (only if you must)
|
||||
|
||||
If you can't use the wrapper (e.g. inside an IDE that shells pytest directly),
|
||||
at minimum activate the venv. The isolation plugin loads automatically from
|
||||
`addopts` in `pyproject.toml`, so you get the same per-test process isolation
|
||||
either way.
|
||||
|
||||
```bash
|
||||
source .venv/bin/activate # or: source venv/bin/activate
|
||||
python -m pytest tests/ -q
|
||||
```
|
||||
|
||||
If you need to bypass isolation for fast feedback while debugging:
|
||||
|
||||
```bash
|
||||
python -m pytest tests/agent/test_foo.py -q --no-isolate
|
||||
```
|
||||
|
||||
Always run the full suite before pushing changes.
|
||||
|
||||
### Don't write change-detector tests
|
||||
|
||||
|
||||
@@ -353,29 +353,6 @@ def _coding_mode(config: Optional[dict[str, Any]]) -> str:
|
||||
return "auto"
|
||||
|
||||
|
||||
def _coding_instructions(config: Optional[dict[str, Any]]) -> str:
|
||||
"""Standing operator instructions for the coding posture (config).
|
||||
|
||||
``agent.coding_instructions`` — a string or list of strings appended to the
|
||||
coding brief as an extra stable system block, so a user can pin project-wide
|
||||
coding-workflow rules (e.g. "for UI work don't run tsc/lint until I approve;
|
||||
clean the diff before committing") without editing the shipped brief.
|
||||
Cache-safe: resolved once per session into the stable system-prompt tier,
|
||||
like the rest of the posture.
|
||||
"""
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
raw = ((config or {}).get("agent", {}) or {}).get("coding_instructions", "")
|
||||
if isinstance(raw, (list, tuple)):
|
||||
return "\n".join(str(item).strip() for item in raw if str(item).strip())
|
||||
return str(raw or "").strip()
|
||||
|
||||
|
||||
def _resolve_cwd(cwd: Optional[str | Path]) -> Path:
|
||||
if cwd:
|
||||
return Path(cwd).expanduser()
|
||||
@@ -482,9 +459,6 @@ class RuntimeMode:
|
||||
# only to steer edit-format guidance toward the model's family — see
|
||||
# ``_edit_format_line``. Fixed for the session, so cache-safe.
|
||||
model: Optional[str] = None
|
||||
# Standing operator instructions (``agent.coding_instructions``), appended
|
||||
# as an extra stable system block. Empty unless the user configures it.
|
||||
instructions: str = ""
|
||||
|
||||
@property
|
||||
def kind(self) -> str:
|
||||
@@ -531,10 +505,6 @@ class RuntimeMode:
|
||||
workspace = build_coding_workspace_block(self.cwd)
|
||||
if workspace:
|
||||
blocks.append(workspace)
|
||||
# Operator instructions ride their own block so the brief (block 0) stays
|
||||
# byte-stable and cache-keyed independently of user config.
|
||||
if self.instructions:
|
||||
blocks.append(f"Operator instructions (from config):\n{self.instructions}")
|
||||
return blocks
|
||||
|
||||
def compact_skill_categories(self) -> frozenset[str]:
|
||||
@@ -587,7 +557,6 @@ def resolve_runtime_mode(
|
||||
cwd=resolved_cwd,
|
||||
config_mode=mode,
|
||||
model=model,
|
||||
instructions=_coding_instructions(config),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -4810,55 +4810,6 @@ def run_conversation(
|
||||
agent._verification_stop_nudges)
|
||||
continue
|
||||
|
||||
# User verification-loop gate: when the agent edited code this
|
||||
# turn, let a registered `pre_verify` hook (plugin/shell) keep it
|
||||
# going one more turn. The shipped guidance is folded into the
|
||||
# evidence-based verify-on-stop nudge above, so this path has no
|
||||
# default continuation cost.
|
||||
_verify_nudge2 = None
|
||||
_edited = sorted(getattr(agent, "_turn_file_mutation_paths", set()) or [])
|
||||
_attempt = getattr(agent, "_pre_verify_nudges", 0)
|
||||
try:
|
||||
from agent.verify_hooks import max_verify_nudges
|
||||
from hermes_cli.plugins import get_pre_verify_continue_message, has_hook
|
||||
|
||||
if _edited and has_hook("pre_verify") and _attempt < max_verify_nudges():
|
||||
# Posture is fixed for the session — resolve once + cache.
|
||||
coding = getattr(agent, "_resolved_is_coding", None)
|
||||
if coding is None:
|
||||
from agent.coding_context import is_coding_context
|
||||
coding = bool(is_coding_context(platform=getattr(agent, "platform", "") or ""))
|
||||
agent._resolved_is_coding = coding
|
||||
_verify_nudge2 = get_pre_verify_continue_message(
|
||||
session_id=getattr(agent, "session_id", None) or "",
|
||||
platform=getattr(agent, "platform", "") or "",
|
||||
model=getattr(agent, "model", "") or "",
|
||||
coding=coding,
|
||||
attempt=_attempt,
|
||||
final_response=final_response,
|
||||
changed_paths=_edited,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("pre_verify hook check failed", exc_info=True)
|
||||
_verify_nudge2 = None
|
||||
|
||||
if _verify_nudge2:
|
||||
agent._pre_verify_nudges = _attempt + 1
|
||||
final_msg["finish_reason"] = "verify_hook_continue"
|
||||
# Same alternation contract as verify-on-stop: keep the
|
||||
# attempted answer in history, follow it with a synthetic
|
||||
# user nudge, and don't surface the premature answer.
|
||||
messages.append(final_msg)
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": _verify_nudge2,
|
||||
"_pre_verify_synthetic": True,
|
||||
})
|
||||
agent._session_messages = messages
|
||||
logger.debug("pre_verify nudge issued (attempt %d)",
|
||||
agent._pre_verify_nudges)
|
||||
continue
|
||||
|
||||
messages.append(final_msg)
|
||||
|
||||
_turn_exit_reason = f"text_response(finish_reason={finish_reason})"
|
||||
|
||||
@@ -537,122 +537,6 @@ def build_tool_preview(tool_name: str, args: dict, max_len: int | None = None) -
|
||||
return preview
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Friendly tool labels (human-phrased verbs for built-in tools)
|
||||
#
|
||||
# Turns "web_search <query>" into "Searching the web for <query>" — the
|
||||
# ChatGPT-style "Searching…/Reading…" surface. Curated and built-in only:
|
||||
# we know each core tool's semantics, so the verb is fixed, not computed.
|
||||
# Custom/plugin/MCP tools have no entry and fall back to the raw preview.
|
||||
# =========================================================================
|
||||
|
||||
# Each entry maps a built-in tool name to its present-participle verb phrase.
|
||||
# A trailing space-then-preview is appended by build_tool_label() when the
|
||||
# tool's argument preview is available (e.g. "Reading docs/api.md").
|
||||
_TOOL_VERBS: dict[str, str] = {
|
||||
"web_search": "Searching the web",
|
||||
"web_extract": "Reading",
|
||||
"browser_navigate": "Browsing",
|
||||
"browser_click": "Clicking",
|
||||
"browser_type": "Typing",
|
||||
"read_file": "Reading",
|
||||
"write_file": "Writing",
|
||||
"patch": "Editing",
|
||||
"search_files": "Searching files",
|
||||
"terminal": "Running",
|
||||
"execute_code": "Running code",
|
||||
"image_generate": "Generating image",
|
||||
"video_generate": "Generating video",
|
||||
"text_to_speech": "Generating speech",
|
||||
"vision_analyze": "Looking at the image",
|
||||
"session_search": "Searching past sessions",
|
||||
"skill_view": "Reading skill",
|
||||
"skills_list": "Listing skills",
|
||||
"skill_manage": "Updating skill",
|
||||
"delegate_task": "Delegating",
|
||||
"cronjob": "Scheduling",
|
||||
"clarify": "Asking",
|
||||
"memory": "Updating memory",
|
||||
"todo": "Updating tasks",
|
||||
}
|
||||
|
||||
# Verbs that read better without the raw argument preview appended.
|
||||
_TOOL_VERBS_NO_PREVIEW: frozenset[str] = frozenset({
|
||||
"skills_list",
|
||||
"session_search",
|
||||
})
|
||||
|
||||
# Verbs that take a "for" connector before the preview (search-style phrasing):
|
||||
# "Searching the web for <query>" reads better than "Searching the web <query>".
|
||||
_TOOL_VERBS_FOR_CONNECTOR: frozenset[str] = frozenset({
|
||||
"web_search",
|
||||
"search_files",
|
||||
})
|
||||
|
||||
_friendly_tool_labels: bool = True
|
||||
|
||||
|
||||
def set_friendly_tool_labels(enabled: bool) -> None:
|
||||
"""Toggle friendly human-phrased tool labels (display.friendly_tool_labels)."""
|
||||
global _friendly_tool_labels
|
||||
_friendly_tool_labels = bool(enabled)
|
||||
|
||||
|
||||
def get_friendly_tool_labels() -> bool:
|
||||
"""Return whether friendly tool labels are enabled."""
|
||||
return _friendly_tool_labels
|
||||
|
||||
|
||||
def get_tool_verb(tool_name: str) -> str | None:
|
||||
"""Return the friendly verb for a built-in tool, or None.
|
||||
|
||||
Returns None when friendly labels are disabled or the tool has no curated
|
||||
verb (custom/plugin/MCP tools). Callers that already hold a computed
|
||||
argument preview can compose ``f"{verb} {preview}"`` themselves; use
|
||||
:func:`tool_verb_connector` to pick the right joiner.
|
||||
"""
|
||||
if not _friendly_tool_labels:
|
||||
return None
|
||||
return _TOOL_VERBS.get(tool_name)
|
||||
|
||||
|
||||
def tool_verb_connector(tool_name: str) -> str:
|
||||
"""Return the connector between a verb and its preview (" for " or " ")."""
|
||||
return " for " if tool_name in _TOOL_VERBS_FOR_CONNECTOR else " "
|
||||
|
||||
|
||||
def verb_drops_preview(tool_name: str) -> bool:
|
||||
"""Whether the verb should render alone, without the argument preview."""
|
||||
return tool_name in _TOOL_VERBS_NO_PREVIEW
|
||||
|
||||
|
||||
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.
|
||||
|
||||
For built-in tools with a known verb (``web_search`` -> "Searching the
|
||||
web for ..."), returns the verb optionally followed by the argument
|
||||
preview. For everything else (custom/plugin/MCP tools, or when friendly
|
||||
labels are disabled) returns the raw preview, so callers can use this as a
|
||||
drop-in replacement for :func:`build_tool_preview`.
|
||||
"""
|
||||
if not _friendly_tool_labels:
|
||||
return build_tool_preview(tool_name, args, max_len=max_len)
|
||||
|
||||
verb = _TOOL_VERBS.get(tool_name)
|
||||
if not verb:
|
||||
return build_tool_preview(tool_name, args, max_len=max_len)
|
||||
|
||||
if tool_name in _TOOL_VERBS_NO_PREVIEW:
|
||||
return verb
|
||||
|
||||
preview = build_tool_preview(tool_name, args, max_len=max_len)
|
||||
if not preview:
|
||||
return verb
|
||||
if tool_name in _TOOL_VERBS_FOR_CONNECTOR:
|
||||
return f"{verb} for {preview}"
|
||||
return f"{verb} {preview}"
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Inline diff previews for write actions
|
||||
# =========================================================================
|
||||
|
||||
@@ -588,17 +588,6 @@ def _parse_response(event: str, stdout: str) -> Optional[Dict[str, Any]]:
|
||||
return {"action": "block", "message": _block_message(data.get("reason"), data.get("message"))}
|
||||
return None
|
||||
|
||||
if event == "pre_verify":
|
||||
# "continue" (Hermes) / "block" (Claude-Code Stop: block the stop) both
|
||||
# mean keep going; the message/reason is the follow-up for the model. A
|
||||
# continue with no message is a no-op — let the turn finish.
|
||||
action = str(data.get("action") or data.get("decision") or "").strip().lower()
|
||||
if action in {"continue", "block"}:
|
||||
message = data.get("message") or data.get("reason")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return {"action": "continue", "message": message.strip()}
|
||||
return None
|
||||
|
||||
context = data.get("context")
|
||||
if isinstance(context, str) and context.strip():
|
||||
return {"context": context}
|
||||
|
||||
@@ -24,7 +24,6 @@ from typing import Any, Optional
|
||||
from agent.display import (
|
||||
KawaiiSpinner,
|
||||
build_tool_preview as _build_tool_preview,
|
||||
build_tool_label as _build_tool_label,
|
||||
get_cute_tool_message as _get_cute_tool_message_impl,
|
||||
get_tool_emoji as _get_tool_emoji,
|
||||
redact_tool_args_for_display as _redact_tool_args_for_display,
|
||||
@@ -1225,7 +1224,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_label(function_name, display_args) or function_name
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_ce_result = None
|
||||
@@ -1259,7 +1258,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_label(function_name, display_args) or function_name
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_mem_result = None
|
||||
@@ -1291,7 +1290,7 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe
|
||||
face = random.choice(KawaiiSpinner.get_waiting_faces())
|
||||
emoji = _get_tool_emoji(function_name)
|
||||
display_args = _redact_tool_args_for_display(function_name, function_args) or function_args
|
||||
preview = _build_tool_label(function_name, display_args) or function_name
|
||||
preview = _build_tool_preview(function_name, display_args) or function_name
|
||||
spinner = KawaiiSpinner(f"{face} {emoji} {preview}", spinner_type='dots', print_fn=agent._print_fn)
|
||||
spinner.start()
|
||||
_spinner_result = None
|
||||
|
||||
@@ -443,7 +443,6 @@ def build_turn_context(
|
||||
agent._turn_failed_file_mutations = {}
|
||||
agent._turn_file_mutation_paths = set()
|
||||
agent._verification_stop_nudges = 0
|
||||
agent._pre_verify_nudges = 0
|
||||
|
||||
# Record the execution thread so interrupt()/clear_interrupt() can scope
|
||||
# the tool-level interrupt signal to THIS agent's thread only.
|
||||
|
||||
@@ -273,15 +273,6 @@ def build_verify_on_stop_nudge(
|
||||
if state == "passed":
|
||||
return None
|
||||
|
||||
# Optional shipped coding guidance, only paid when this evidence gate fires.
|
||||
try:
|
||||
from agent.verify_hooks import coding_verify_guidance
|
||||
|
||||
guidance = coding_verify_guidance()
|
||||
except Exception:
|
||||
guidance = None
|
||||
addendum = f"\n\n{guidance}" if guidance else ""
|
||||
|
||||
if verify_commands:
|
||||
command_instruction = (
|
||||
"Run the relevant verification command now ("
|
||||
@@ -306,8 +297,7 @@ def build_verify_on_stop_nudge(
|
||||
f"Verification status: {_status_detail(status)}\n\n"
|
||||
f"Changed paths:\n{_format_changed_paths(paths)}\n\n"
|
||||
f"{command_instruction} If verification is not possible, explain the "
|
||||
"concrete blocker instead of claiming the work is fully verified."
|
||||
f"{addendum}]"
|
||||
"concrete blocker instead of claiming the work is fully verified.]"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
"""Verification-loop helpers for the ``pre_verify`` round-end gate.
|
||||
|
||||
When the agent has edited code and is about to verify/finish, the loop fires the
|
||||
``pre_verify`` hook (user directives resolved by
|
||||
:func:`hermes_cli.plugins.get_pre_verify_continue_message`). A directive keeps
|
||||
the agent going one more turn — run a check, defer it, tidy the diff — instead of
|
||||
stopping immediately.
|
||||
|
||||
The shipped coding guidance lives on the evidence-based verification-stop nudge
|
||||
(``agent/verification_stop.py``), not as a second default stop gate. That keeps
|
||||
the default token cost tied to the existing "missing verification evidence"
|
||||
decision while preserving ``pre_verify`` for user/plugin policy.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
from utils import is_truthy_value
|
||||
|
||||
DEFAULT_MAX_VERIFY_NUDGES = 3
|
||||
|
||||
# Shipped guidance appended to the verification-stop nudge when code lacks fresh
|
||||
# verification evidence. Wording mirrors the user-facing "clean your work"
|
||||
# workflow, but does not create its own extra model turn.
|
||||
CODING_VERIFY_GUIDANCE = (
|
||||
"[Coding] Before you run tests/linters or call this done: if this is "
|
||||
"creative UI/visual work, hold off on tests and linters until the user says "
|
||||
"they like the result or you're about to commit. And before every commit, "
|
||||
"clean your work: keep it KISS/DRY, match the surrounding code style, and be "
|
||||
"elitist, shorthand, clever, concise, efficient, and elegant."
|
||||
)
|
||||
|
||||
|
||||
def max_verify_nudges(config: Optional[dict[str, Any]] = None) -> int:
|
||||
"""Bound on consecutive ``pre_verify`` continue directives per turn (>= 0)."""
|
||||
agent_cfg = _agent_cfg(config)
|
||||
raw = agent_cfg.get("max_verify_nudges")
|
||||
try:
|
||||
return max(0, int(raw))
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MAX_VERIFY_NUDGES
|
||||
|
||||
|
||||
def coding_verify_guidance(config: Optional[dict[str, Any]] = None) -> Optional[str]:
|
||||
"""Return the optional guidance appended to verification-stop nudges."""
|
||||
if not is_truthy_value(_agent_cfg(config).get("verify_guidance", True), default=True):
|
||||
return None
|
||||
return CODING_VERIFY_GUIDANCE
|
||||
|
||||
|
||||
def _agent_cfg(config: Optional[dict[str, Any]]) -> dict[str, Any]:
|
||||
if config is None:
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
config = load_config()
|
||||
except Exception:
|
||||
config = {}
|
||||
agent_cfg = (config or {}).get("agent") if isinstance(config, dict) else None
|
||||
return agent_cfg if isinstance(agent_cfg, dict) else {}
|
||||
|
||||
|
||||
__all__ = [
|
||||
"CODING_VERIFY_GUIDANCE",
|
||||
"DEFAULT_MAX_VERIFY_NUDGES",
|
||||
"coding_verify_guidance",
|
||||
"max_verify_nudges",
|
||||
]
|
||||
@@ -37,6 +37,20 @@
|
||||
const AT_COOKIE_VARIANTS = ['__Host-hermes_session_at', '__Secure-hermes_session_at', 'hermes_session_at']
|
||||
const RT_COOKIE_VARIANTS = ['__Host-hermes_session_rt', '__Secure-hermes_session_rt', 'hermes_session_rt']
|
||||
|
||||
// The Nous portal (NAS) does NOT use Hermes gateway session cookies — it is a
|
||||
// Privy-authed Next.js app. NAS `auth()` (src/server/auth/session.ts) reads the
|
||||
// `privy-token` access-token cookie (with `privy-id-token` alongside), which is
|
||||
// also exactly what the `/api/agents` cookie-auth path validates. So portal
|
||||
// sign-in / discovery liveness must look for the Privy cookie, NOT the gateway
|
||||
// cookies above. `privy-token` is the access token (the required signal);
|
||||
// variants cover the secured-prefix forms and the older `privy-session` name.
|
||||
const PRIVY_SESSION_COOKIE_VARIANTS = [
|
||||
'__Host-privy-token',
|
||||
'__Secure-privy-token',
|
||||
'privy-token',
|
||||
'privy-session'
|
||||
]
|
||||
|
||||
function normalizeRemoteBaseUrl(rawUrl) {
|
||||
const value = String(rawUrl || '').trim()
|
||||
|
||||
@@ -142,19 +156,30 @@ function normAuthMode(mode) {
|
||||
return mode === 'oauth' ? 'oauth' : 'token'
|
||||
}
|
||||
|
||||
// True for connection modes that resolve to a REMOTE backend. 'cloud' is a
|
||||
// Hermes Cloud connection (cloud-auto-discovery Q3/Q6): it carries a
|
||||
// remote-shaped block and reuses the entire remote connect/probe/reconnect
|
||||
// path, so every resolution site treats it exactly like 'remote'. The only
|
||||
// places that distinguish cloud from remote are the settings UI (which card to
|
||||
// show) and config persistence (remembering the provenance). Centralized here
|
||||
// so no resolution site forgets the third arm.
|
||||
function modeIsRemoteLike(mode) {
|
||||
return mode === 'remote' || mode === 'cloud'
|
||||
}
|
||||
|
||||
/**
|
||||
* Select a profile's explicit remote override from a connection config, or null
|
||||
* when it has none (so the caller falls back to env → global remote → local).
|
||||
*
|
||||
* The config may carry a `profiles` map keyed by name; an entry counts as an
|
||||
* override only with `mode === 'remote'` and a non-empty `url`. Pure: `token`
|
||||
* is the raw stored secret; main.cjs decrypts it. Returns
|
||||
* override only with a remote-like `mode` (remote or cloud) and a non-empty
|
||||
* `url`. Pure: `token` is the raw stored secret; main.cjs decrypts it. Returns
|
||||
* `{ url, authMode, token } | null`.
|
||||
*/
|
||||
function profileRemoteOverride(config, profile) {
|
||||
const key = connectionScopeKey(profile)
|
||||
const entry = key ? config?.profiles?.[key] : null
|
||||
if (!entry || typeof entry !== 'object' || entry.mode !== 'remote') {
|
||||
if (!entry || typeof entry !== 'object' || !modeIsRemoteLike(entry.mode)) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -264,15 +289,31 @@ function cookiesHaveLiveSession(cookies) {
|
||||
return cookies.some(c => c && c.value && (AT_COOKIE_VARIANTS.includes(c.name) || RT_COOKIE_VARIANTS.includes(c.name)))
|
||||
}
|
||||
|
||||
/**
|
||||
* True if the cookie jar holds a live Nous PORTAL (Privy) session — a non-empty
|
||||
* `privy-token` (access-token) cookie, or a variant. This is the portal
|
||||
* analogue of `cookiesHaveLiveSession`: the portal authenticates via Privy, not
|
||||
* the Hermes gateway session cookies, so cloud sign-in / discovery liveness
|
||||
* must check THIS, not the gateway helpers. (NAS `auth()` and the `/api/agents`
|
||||
* cookie path both key off `privy-token`.)
|
||||
*/
|
||||
function cookiesHavePrivySession(cookies) {
|
||||
if (!Array.isArray(cookies)) return false
|
||||
return cookies.some(c => c && c.value && PRIVY_SESSION_COOKIE_VARIANTS.includes(c.name))
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
AT_COOKIE_VARIANTS,
|
||||
RT_COOKIE_VARIANTS,
|
||||
PRIVY_SESSION_COOKIE_VARIANTS,
|
||||
authModeFromStatus,
|
||||
buildGatewayWsUrl,
|
||||
buildGatewayWsUrlWithTicket,
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
modeIsRemoteLike,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
|
||||
@@ -22,6 +22,8 @@ const {
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
modeIsRemoteLike,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
@@ -47,6 +49,19 @@ test('normAuthMode coerces to token unless explicitly oauth', () => {
|
||||
assert.equal(normAuthMode('weird'), 'token')
|
||||
})
|
||||
|
||||
// --- modeIsRemoteLike ---
|
||||
|
||||
test('modeIsRemoteLike is true for remote and cloud, false otherwise', () => {
|
||||
// cloud resolves to a remote backend under the hood (Q6), so every resolution
|
||||
// site treats it like remote.
|
||||
assert.equal(modeIsRemoteLike('remote'), true)
|
||||
assert.equal(modeIsRemoteLike('cloud'), true)
|
||||
assert.equal(modeIsRemoteLike('local'), false)
|
||||
assert.equal(modeIsRemoteLike(undefined), false)
|
||||
assert.equal(modeIsRemoteLike(null), false)
|
||||
assert.equal(modeIsRemoteLike('weird'), false)
|
||||
})
|
||||
|
||||
// --- profileRemoteOverride ---
|
||||
|
||||
test('profileRemoteOverride returns null when no profile is given', () => {
|
||||
@@ -85,6 +100,21 @@ test('profileRemoteOverride preserves an explicit oauth auth mode', () => {
|
||||
assert.equal(profileRemoteOverride(config, 'coder').authMode, 'oauth')
|
||||
})
|
||||
|
||||
test('profileRemoteOverride treats a cloud entry as a remote override', () => {
|
||||
// A 'cloud' per-profile entry resolves to the same remote backend a 'remote'
|
||||
// entry would (Q6) — the override must be returned, not dropped.
|
||||
const config = {
|
||||
profiles: {
|
||||
coder: { mode: 'cloud', url: 'https://agent-1.agents.nousresearch.com', authMode: 'oauth' }
|
||||
}
|
||||
}
|
||||
assert.deepEqual(profileRemoteOverride(config, 'coder'), {
|
||||
url: 'https://agent-1.agents.nousresearch.com',
|
||||
authMode: 'oauth',
|
||||
token: undefined
|
||||
})
|
||||
})
|
||||
|
||||
test('profileRemoteOverride tolerates a missing/!object profiles map', () => {
|
||||
assert.equal(profileRemoteOverride({}, 'coder'), null)
|
||||
assert.equal(profileRemoteOverride({ profiles: null }, 'coder'), null)
|
||||
@@ -331,6 +361,35 @@ test('cookiesHaveLiveSession is false for unrelated cookies and non-arrays', ()
|
||||
assert.equal(cookiesHaveLiveSession([]), false)
|
||||
})
|
||||
|
||||
// --- cookiesHavePrivySession (Nous portal / Privy auth, NOT gateway cookies) ---
|
||||
|
||||
test('cookiesHavePrivySession detects the privy-token access cookie', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: 'jwt' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession detects __Host-/__Secure- prefixes and the legacy privy-session name', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Host-privy-token', value: 'x' }]), true)
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Secure-privy-token', value: 'x' }]), true)
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-session', value: 'x' }]), true)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession is false for an empty value', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'privy-token', value: '' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession does NOT treat hermes gateway cookies as a portal session', () => {
|
||||
// The whole point of Q7: a gateway session cookie is NOT a portal sign-in.
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'hermes_session_at', value: 'x' }]), false)
|
||||
assert.equal(cookiesHavePrivySession([{ name: '__Host-hermes_session_rt', value: 'x' }]), false)
|
||||
})
|
||||
|
||||
test('cookiesHavePrivySession is false for unrelated cookies and non-arrays', () => {
|
||||
assert.equal(cookiesHavePrivySession([{ name: 'other', value: 'x' }]), false)
|
||||
assert.equal(cookiesHavePrivySession(null), false)
|
||||
assert.equal(cookiesHavePrivySession(undefined), false)
|
||||
assert.equal(cookiesHavePrivySession([]), false)
|
||||
})
|
||||
|
||||
// --- tokenPreview ---
|
||||
|
||||
test('tokenPreview returns null for empty', () => {
|
||||
|
||||
@@ -1,96 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const { scanGitRepos } = require('./git-repo-scan.cjs')
|
||||
const {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} = require('./git-review-ops.cjs')
|
||||
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
|
||||
// Register the git/worktree/review IPC handlers. Thin delegators to the
|
||||
// git-*-ops sibling modules; the git/gh binary resolution lives in the main
|
||||
// process (Windows PATH discovery) and is injected so this module stays pure.
|
||||
function registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary }) {
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
module.exports = { registerGitIpc }
|
||||
@@ -1,61 +0,0 @@
|
||||
'use strict'
|
||||
|
||||
const assert = require('node:assert/strict')
|
||||
const test = require('node:test')
|
||||
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
|
||||
function fakeIpcMain() {
|
||||
const handlers = new Map()
|
||||
|
||||
return {
|
||||
handlers,
|
||||
handle(channel, handler) {
|
||||
assert.ok(!handlers.has(channel), `duplicate registration for ${channel}`)
|
||||
handlers.set(channel, handler)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
test('registerGitIpc wires only hermes:git:* channels, each to a handler fn', () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
|
||||
registerGitIpc({ ipcMain, resolveGitBinary: () => 'git', resolveGhBinary: () => 'gh' })
|
||||
|
||||
assert.ok(ipcMain.handlers.size >= 19, `expected the full git surface, got ${ipcMain.handlers.size}`)
|
||||
|
||||
for (const [channel, handler] of ipcMain.handlers) {
|
||||
assert.match(channel, /^hermes:git:/, `${channel} is not a git channel`)
|
||||
assert.equal(typeof handler, 'function', `${channel} should register a handler`)
|
||||
}
|
||||
|
||||
// Spot-check the load-bearing channels across the worktree / review / scan groups.
|
||||
for (const channel of ['hermes:git:worktreeList', 'hermes:git:review:commit', 'hermes:git:scanRepos']) {
|
||||
assert.ok(ipcMain.handlers.has(channel), `missing ${channel}`)
|
||||
}
|
||||
})
|
||||
|
||||
test('handlers thread the injected resolver into the ops layer', async () => {
|
||||
const ipcMain = fakeIpcMain()
|
||||
const calls = []
|
||||
|
||||
registerGitIpc({
|
||||
ipcMain,
|
||||
resolveGitBinary: () => {
|
||||
calls.push('git')
|
||||
|
||||
return 'git'
|
||||
},
|
||||
resolveGhBinary: () => 'gh'
|
||||
})
|
||||
|
||||
// The resolver is consulted synchronously to build the ops call; whatever the
|
||||
// ops layer does with a non-repo path is irrelevant to the wiring.
|
||||
try {
|
||||
await ipcMain.handlers.get('hermes:git:worktreeList')({}, '/definitely/not/a/repo')
|
||||
} catch {
|
||||
// ops layer may reject on a bad path — not what this test asserts.
|
||||
}
|
||||
|
||||
assert.deepEqual(calls, ['git'])
|
||||
})
|
||||
+492
-31
@@ -58,7 +58,23 @@ const {
|
||||
buildRelaunchScript
|
||||
} = require('./update-relaunch.cjs')
|
||||
const { gitRootForIpc } = require('./git-root.cjs')
|
||||
const { registerGitIpc } = require('./git-ipc.cjs')
|
||||
const { addWorktree, listBranches, listWorktrees, removeWorktree, switchBranch } = require('./git-worktree-ops.cjs')
|
||||
const {
|
||||
fileDiffVsHead,
|
||||
repoStatus,
|
||||
reviewCommit,
|
||||
reviewCommitContext,
|
||||
reviewCreatePr,
|
||||
reviewDiff,
|
||||
reviewList,
|
||||
reviewPush,
|
||||
reviewRevParse,
|
||||
reviewRevert,
|
||||
reviewShipInfo,
|
||||
reviewStage,
|
||||
reviewUnstage
|
||||
} = require('./git-review-ops.cjs')
|
||||
const { scanGitRepos } = require('./git-repo-scan.cjs')
|
||||
const { OFFICIAL_REPO_HTTPS_URL, isOfficialSshRemote } = require('./update-remote.cjs')
|
||||
const { resolveBehindCount, shouldCountCommits } = require('./update-count.cjs')
|
||||
const { runRebuildWithRetry } = require('./update-rebuild.cjs')
|
||||
@@ -86,6 +102,8 @@ const {
|
||||
connectionScopeKey,
|
||||
cookiesHaveSession,
|
||||
cookiesHaveLiveSession,
|
||||
cookiesHavePrivySession,
|
||||
modeIsRemoteLike,
|
||||
normAuthMode,
|
||||
normalizeRemoteBaseUrl,
|
||||
pathWithGlobalRemoteProfile,
|
||||
@@ -1345,7 +1363,10 @@ function backendSupportsServe(backend) {
|
||||
let supported = null
|
||||
if (backend.root) {
|
||||
try {
|
||||
const src = fs.readFileSync(path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'), 'utf8')
|
||||
const src = fs.readFileSync(
|
||||
path.join(backend.root, 'hermes_cli', 'subcommands', 'dashboard.py'),
|
||||
'utf8'
|
||||
)
|
||||
supported = sourceDeclaresServe(src)
|
||||
} catch {
|
||||
supported = null // source unreadable — fall through to the probe
|
||||
@@ -2273,7 +2294,9 @@ async function handOffWindowsBootstrapRecovery(reason) {
|
||||
// --repair (full venv recreate) and drove reinstall loops. The venv interpreter
|
||||
// and the bootstrap-complete marker are present earlier and are better signals.
|
||||
const haveRealInstall =
|
||||
fileExists(venvPython) || fileExists(venvHermes) || fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
fileExists(venvPython) ||
|
||||
fileExists(venvHermes) ||
|
||||
fileExists(path.join(updateRoot, '.hermes-bootstrap-complete'))
|
||||
const updaterArgs = haveRealInstall ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
@@ -4377,7 +4400,23 @@ async function clearOauthSession(baseUrl) {
|
||||
// reject if the user closes the window first. The window navigates through the
|
||||
// IDP and back to /auth/callback, which sets the session cookies on the
|
||||
// partition; we poll the cookie jar rather than try to read the HttpOnly value.
|
||||
function openOauthLoginWindow(baseUrl) {
|
||||
// Open a gateway login window in the OAuth session partition, resolving once
|
||||
// the access-token cookie appears (login done) or rejecting if the user closes
|
||||
// the window first.
|
||||
//
|
||||
// `silent` selects the URL the window loads, which decides interactive-vs-silent:
|
||||
// - silent=false (default): load ``/login`` — the public interstitial that
|
||||
// renders the "Log in with X" provider chooser. This is the interactive
|
||||
// remote-gateway login the settings UI drives.
|
||||
// - silent=true: load the PROTECTED root ``/`` instead. ``/login`` is a public
|
||||
// route, so loading it NEVER triggers the gate's auto-SSO and always shows
|
||||
// the chooser. Loading a protected page with no session cookie makes the
|
||||
// gate run ``_auto_sso_response``: single registered provider + a live
|
||||
// portal session in this partition → a silent 302 through
|
||||
// ``/auth/login`` → portal ``/oauth/authorize`` (auto-approves org members)
|
||||
// → ``/auth/callback``, which sets the gateway cookie with NO interactive
|
||||
// prompt. This is the per-agent cloud cascade (decisions.md Q5).
|
||||
function openOauthLoginWindow(baseUrl, { silent = false } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!app.isReady()) {
|
||||
reject(new Error('Desktop is not ready to start an OAuth login.'))
|
||||
@@ -4392,11 +4431,13 @@ function openOauthLoginWindow(baseUrl) {
|
||||
let settled = false
|
||||
let win = null
|
||||
let pollTimer = null
|
||||
let revealTimer = null
|
||||
|
||||
const finish = err => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
if (revealTimer) clearTimeout(revealTimer)
|
||||
try {
|
||||
if (win && !win.isDestroyed()) win.destroy()
|
||||
} catch {
|
||||
@@ -4415,8 +4456,14 @@ function openOauthLoginWindow(baseUrl) {
|
||||
win = new BrowserWindow({
|
||||
width: 520,
|
||||
height: 720,
|
||||
title: 'Sign in to Hermes gateway',
|
||||
title: silent ? 'Connecting to Hermes Cloud agent…' : 'Sign in to Hermes gateway',
|
||||
autoHideMenuBar: true,
|
||||
// Silent cascade: start HIDDEN. The auto-SSO 302 chain completes in
|
||||
// well under a second, so the window normally never needs to show. We
|
||||
// only reveal it as a fallback if the cascade DOESN'T complete quickly
|
||||
// (e.g. the portal session lapsed and the gate fell through to the
|
||||
// interactive chooser) — see the reveal timer below.
|
||||
show: !silent,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
@@ -4438,6 +4485,23 @@ function openOauthLoginWindow(baseUrl) {
|
||||
win.webContents.on('did-frame-navigate', () => void checkCookie())
|
||||
pollTimer = setInterval(() => void checkCookie(), 750)
|
||||
|
||||
// Silent-mode reveal fallback: if the cascade hasn't settled shortly, the
|
||||
// auto-SSO didn't go through silently (no portal session, multi-provider,
|
||||
// loop-guard tripped, etc.) and the window is now showing an interactive
|
||||
// page. Reveal it so the user can complete sign-in manually rather than
|
||||
// staring at nothing. Cleared on finish().
|
||||
if (silent && win) {
|
||||
revealTimer = setTimeout(() => {
|
||||
try {
|
||||
if (!settled && win && !win.isDestroyed() && !win.isVisible()) {
|
||||
win.show()
|
||||
}
|
||||
} catch {
|
||||
// window torn down
|
||||
}
|
||||
}, 2500)
|
||||
}
|
||||
|
||||
win.on('closed', () => {
|
||||
if (!settled) finish(new Error('Login window closed before authentication completed.'))
|
||||
})
|
||||
@@ -4445,7 +4509,11 @@ function openOauthLoginWindow(baseUrl) {
|
||||
// ``next`` is intentionally omitted: the gateway lands on ``/`` after
|
||||
// login, which is a valid authenticated page that sets the cookies. We
|
||||
// only care that the cookie jar is populated.
|
||||
const loginUrl = `${normalizeRemoteBaseUrl(baseUrl)}/login`
|
||||
//
|
||||
// silent=true loads the protected root so the gate auto-SSOs (no chooser);
|
||||
// silent=false loads the public ``/login`` chooser for interactive sign-in.
|
||||
const normalizedBase = normalizeRemoteBaseUrl(baseUrl)
|
||||
const loginUrl = silent ? `${normalizedBase}/` : `${normalizedBase}/login`
|
||||
win.loadURL(loginUrl).catch(error => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
@@ -4574,6 +4642,258 @@ async function freshGatewayWsUrl(profile) {
|
||||
return connection.wsUrl
|
||||
}
|
||||
|
||||
// --- Hermes Cloud discovery + silent per-agent sign-in (cloud-auto-discovery
|
||||
// Phase 3) ---------------------------------------------------------------
|
||||
//
|
||||
// The "cloud" connection mode lets a user sign in to the Nous portal ONCE in
|
||||
// the OAuth session partition, then (a) discover their hosted agents and (b)
|
||||
// connect to any of them with no second interactive sign-in. Both ride the one
|
||||
// portal session cookie living in `persist:hermes-remote-oauth`:
|
||||
// - discovery → GET {portal}/api/agents over the partition-bound net; the
|
||||
// portal session cookie authenticates it (NAS Phase 2.5 accepts the cookie).
|
||||
// - cascade → opening an agent's own /login in the same partition hits the
|
||||
// portal's silent auto-approve (org member, existing session) and 302s back
|
||||
// with that agent's session cookie — no prompt. Each agent still completes
|
||||
// its own PKCE exchange; SSO removes the human click, not a security check.
|
||||
|
||||
// Canonical Nous portal base URL, overridable for staging/dev. Mirrors the CLI
|
||||
// convention (hermes_cli/auth.py DEFAULT_NOUS_PORTAL_URL + the same env names)
|
||||
// so a single override flips every Hermes surface to the same portal.
|
||||
const DEFAULT_NOUS_PORTAL_URL = 'https://portal.nousresearch.com'
|
||||
|
||||
function resolvePortalBaseUrl() {
|
||||
const raw =
|
||||
process.env.HERMES_PORTAL_BASE_URL || process.env.NOUS_PORTAL_BASE_URL || DEFAULT_NOUS_PORTAL_URL
|
||||
return String(raw).trim().replace(/\/+$/, '')
|
||||
}
|
||||
|
||||
// Whether the OAuth partition currently holds a live Nous portal session — the
|
||||
// credential that powers both discovery and the silent cascade. The portal
|
||||
// authenticates via PRIVY, not the Hermes gateway session cookies, so this
|
||||
// checks for the `privy-token` cookie on the portal host (NOT
|
||||
// hasLiveOauthSession, which looks for hermes_session_at/rt that the portal
|
||||
// never sets). See connection-config.cjs cookiesHavePrivySession.
|
||||
async function hasLivePortalSession() {
|
||||
const sess = getOauthSession()
|
||||
if (!sess) return false
|
||||
const portalBaseUrl = resolvePortalBaseUrl()
|
||||
const parsed = new URL(portalBaseUrl)
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ url: portalBaseUrl })
|
||||
return cookiesHavePrivySession(cookies)
|
||||
} catch {
|
||||
try {
|
||||
const cookies = await sess.cookies.get({ domain: parsed.hostname })
|
||||
return cookiesHavePrivySession(cookies)
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Drive a one-time interactive portal sign-in in the OAuth partition. Unlike
|
||||
// openOauthLoginWindow (which targets a gateway's /login), this lands on the
|
||||
// portal itself so the resulting session cookie is portal-scoped — the cookie
|
||||
// that authenticates discovery AND is reused for every silent per-agent
|
||||
// cascade. Resolves once the portal session cookie appears.
|
||||
function openPortalLoginWindow() {
|
||||
const portalBaseUrl = resolvePortalBaseUrl()
|
||||
return new Promise((resolve, reject) => {
|
||||
if (!app.isReady()) {
|
||||
reject(new Error('Desktop is not ready to start a Hermes Cloud sign-in.'))
|
||||
return
|
||||
}
|
||||
const sess = getOauthSession()
|
||||
if (!sess) {
|
||||
reject(new Error('OAuth session partition is unavailable.'))
|
||||
return
|
||||
}
|
||||
|
||||
let settled = false
|
||||
let win = null
|
||||
let pollTimer = null
|
||||
|
||||
const finish = err => {
|
||||
if (settled) return
|
||||
settled = true
|
||||
if (pollTimer) clearInterval(pollTimer)
|
||||
try {
|
||||
if (win && !win.isDestroyed()) win.destroy()
|
||||
} catch {
|
||||
// window already torn down
|
||||
}
|
||||
if (err) reject(err)
|
||||
else resolve({ portalBaseUrl, ok: true })
|
||||
}
|
||||
|
||||
const checkCookie = async () => {
|
||||
if (settled) return
|
||||
// A live portal (Privy) session cookie means sign-in completed.
|
||||
if (await hasLivePortalSession()) finish(null)
|
||||
}
|
||||
|
||||
try {
|
||||
win = new BrowserWindow({
|
||||
width: 520,
|
||||
height: 720,
|
||||
title: 'Sign in to Hermes Cloud',
|
||||
autoHideMenuBar: true,
|
||||
webPreferences: {
|
||||
contextIsolation: true,
|
||||
nodeIntegration: false,
|
||||
sandbox: true,
|
||||
session: sess,
|
||||
webSecurity: true
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
return
|
||||
}
|
||||
|
||||
win.webContents.on('did-navigate', () => void checkCookie())
|
||||
win.webContents.on('did-redirect-navigation', () => void checkCookie())
|
||||
win.webContents.on('did-frame-navigate', () => void checkCookie())
|
||||
pollTimer = setInterval(() => void checkCookie(), 750)
|
||||
|
||||
win.on('closed', () => {
|
||||
if (!settled) finish(new Error('Sign-in window closed before authentication completed.'))
|
||||
})
|
||||
|
||||
// Land on the portal root; any authenticated portal page sets the session
|
||||
// cookie. We only care that the partition cookie jar is populated.
|
||||
win.loadURL(portalBaseUrl).catch(error => {
|
||||
finish(error instanceof Error ? error : new Error(String(error)))
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// Discover the hosted (Hermes Cloud) agents the signed-in user can see. Calls
|
||||
// the NAS trimmed-summary endpoint over the partition-bound net, so the portal
|
||||
// session cookie is attached automatically (no bearer needed — NAS accepts the
|
||||
// cookie). Returns { agents } on success, or { needsOrgSelection: true, orgs }
|
||||
// when the user belongs to multiple orgs and hasn't picked one yet (NAS 409
|
||||
// org_selection_required). Pass `org` (a slug/id from a prior org list) to
|
||||
// scope discovery to that org. Throws a needsCloudLogin-tagged error when no
|
||||
// portal session is present.
|
||||
async function discoverCloudAgents(org) {
|
||||
const portalBaseUrl = resolvePortalBaseUrl()
|
||||
if (!(await hasLivePortalSession())) {
|
||||
const err = new Error(
|
||||
'You are not signed in to Hermes Cloud. Open Settings → Gateway, choose Hermes Cloud, and sign in.'
|
||||
)
|
||||
err.needsCloudLogin = true
|
||||
throw err
|
||||
}
|
||||
|
||||
const orgQuery = org ? `?org=${encodeURIComponent(org)}` : ''
|
||||
let body
|
||||
try {
|
||||
body = await fetchJsonViaOauthSession(`${portalBaseUrl}/api/agents${orgQuery}`, {
|
||||
method: 'GET',
|
||||
timeoutMs: 15_000
|
||||
})
|
||||
} catch (error) {
|
||||
// A 401 means the portal session lapsed between the liveness check and the
|
||||
// call — surface it as a re-login, not a generic failure.
|
||||
if (error && error.statusCode === 401) {
|
||||
const err = new Error('Your Hermes Cloud session has expired. Open Settings → Gateway and sign in again.')
|
||||
err.needsCloudLogin = true
|
||||
err.cause = error
|
||||
throw err
|
||||
}
|
||||
// A 409 means we're a multi-org user who hasn't picked an org. The body
|
||||
// carries the user's org list; surface it so the renderer shows a picker
|
||||
// and re-calls discovery with the chosen org. (fetchJsonViaOauthSession
|
||||
// throws on >=400 with err.statusCode + err.message "409: <json body>".)
|
||||
if (error && error.statusCode === 409) {
|
||||
const orgs = parseOrgSelectionError(error)
|
||||
if (orgs) {
|
||||
return { needsOrgSelection: true, orgs }
|
||||
}
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
return { agents: trimCloudAgents(body), org: trimCloudOrg(body?.org) }
|
||||
}
|
||||
|
||||
// Project a NAS response org ({ id, slug, name, isPersonal }) to the trimmed
|
||||
// shape the renderer persists, or null when absent/malformed.
|
||||
function trimCloudOrg(org) {
|
||||
if (!org || typeof org !== 'object' || typeof org.id !== 'string') return null
|
||||
return {
|
||||
id: org.id,
|
||||
slug: typeof org.slug === 'string' ? org.slug : null,
|
||||
name: typeof org.name === 'string' ? org.name : org.id,
|
||||
isPersonal: Boolean(org.isPersonal),
|
||||
role: typeof org.role === 'string' ? org.role : 'MEMBER'
|
||||
}
|
||||
}
|
||||
|
||||
// Extract the org list from a 409 org_selection_required error body. The error
|
||||
// message is "409: <raw json>" (see fetchJsonViaOauthSession); parse defensively
|
||||
// and return null if it isn't the shape we expect (caller then rethrows).
|
||||
function parseOrgSelectionError(error) {
|
||||
const msg = String(error?.message || '')
|
||||
const jsonStart = msg.indexOf('{')
|
||||
if (jsonStart < 0) return null
|
||||
let parsed
|
||||
try {
|
||||
parsed = JSON.parse(msg.slice(jsonStart))
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
if (parsed?.error !== 'org_selection_required' || !Array.isArray(parsed.orgs)) return null
|
||||
return parsed.orgs
|
||||
.filter(o => o && typeof o === 'object' && typeof o.id === 'string')
|
||||
.map(o => ({
|
||||
id: o.id,
|
||||
slug: typeof o.slug === 'string' ? o.slug : null,
|
||||
name: typeof o.name === 'string' ? o.name : o.id,
|
||||
isPersonal: Boolean(o.isPersonal),
|
||||
role: typeof o.role === 'string' ? o.role : 'MEMBER'
|
||||
}))
|
||||
}
|
||||
|
||||
// Project NAS's agent rows to the trimmed DTO the renderer consumes.
|
||||
function trimCloudAgents(body) {
|
||||
const agents = Array.isArray(body?.agents) ? body.agents : []
|
||||
return agents
|
||||
.filter(a => a && typeof a === 'object' && typeof a.id === 'string')
|
||||
.map(a => ({
|
||||
id: a.id,
|
||||
name: typeof a.name === 'string' ? a.name : a.id,
|
||||
status: typeof a.status === 'string' ? a.status : 'unknown',
|
||||
dashboardUrl: typeof a.dashboardUrl === 'string' ? a.dashboardUrl : null,
|
||||
dashboardGatewayState:
|
||||
typeof a.dashboardGatewayState === 'string' ? a.dashboardGatewayState : 'unknown'
|
||||
}))
|
||||
}
|
||||
|
||||
// Silent per-agent sign-in: open the selected agent dashboard's /login in the
|
||||
// SAME OAuth partition. Because the user already holds a live portal session
|
||||
// there, the agent's /oauth/authorize auto-approves (org member) and 302s back,
|
||||
// setting that agent's gateway session cookie WITHOUT a second interactive
|
||||
// prompt. Reuses openOauthLoginWindow — the window self-closes the instant the
|
||||
// agent's session cookie lands (a silent flow finishes in well under a second;
|
||||
// if the portal session were absent it would fall through to an interactive
|
||||
// login, which the discovery gate already prevents). Returns once the agent's
|
||||
// gateway session cookie is present.
|
||||
async function cloudAgentSilentSignIn(dashboardUrl) {
|
||||
const baseUrl = normalizeRemoteBaseUrl(dashboardUrl)
|
||||
// Pre-req: a live portal session must exist, or this would surface an
|
||||
// interactive prompt rather than a silent cascade. Discovery already gates on
|
||||
// this, but a selection can arrive after the session lapsed.
|
||||
if (!(await hasLivePortalSession())) {
|
||||
const err = new Error('Your Hermes Cloud session has expired. Sign in to Hermes Cloud again.')
|
||||
err.needsCloudLogin = true
|
||||
throw err
|
||||
}
|
||||
await openOauthLoginWindow(baseUrl, { silent: true })
|
||||
return { baseUrl, connected: await hasOauthSessionCookie(baseUrl) }
|
||||
}
|
||||
|
||||
function encryptDesktopSecret(value) {
|
||||
return encryptDesktopSecretStrict(value, safeStorage)
|
||||
}
|
||||
@@ -4617,7 +4937,7 @@ function sanitizeConnectionProfiles(raw) {
|
||||
continue
|
||||
}
|
||||
|
||||
const cleaned = { mode: entry.mode === 'remote' ? 'remote' : 'local' }
|
||||
const cleaned = { mode: modeIsRemoteLike(entry.mode) ? entry.mode : 'local' }
|
||||
const url = String(entry.url || '').trim()
|
||||
if (url) {
|
||||
cleaned.url = url
|
||||
@@ -4626,6 +4946,14 @@ function sanitizeConnectionProfiles(raw) {
|
||||
if (entry.token && typeof entry.token === 'object') {
|
||||
cleaned.token = entry.token
|
||||
}
|
||||
// Preserve the Hermes Cloud org tag on cloud-mode entries so Settings can
|
||||
// reopen into the same org for a per-profile cloud connection.
|
||||
if (cleaned.mode === 'cloud') {
|
||||
const org = String(entry.org || '').trim()
|
||||
if (org) {
|
||||
cleaned.org = org
|
||||
}
|
||||
}
|
||||
out[name] = cleaned
|
||||
}
|
||||
|
||||
@@ -4660,7 +4988,7 @@ function readDesktopConnectionConfig() {
|
||||
// backward compatibility with configs written before OAuth support.
|
||||
remote.authMode = remote.authMode === 'oauth' ? 'oauth' : 'token'
|
||||
config = {
|
||||
mode: parsed.mode === 'remote' ? 'remote' : 'local',
|
||||
mode: modeIsRemoteLike(parsed.mode) ? parsed.mode : 'local',
|
||||
remote,
|
||||
// Per-profile remote overrides: each profile may point at its own
|
||||
// backend (local spawn or its own remote URL). Preserved verbatim so
|
||||
@@ -4731,7 +5059,11 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
const remoteToken = decryptDesktopSecret(block.token)
|
||||
const authMode = normAuthMode(block.authMode)
|
||||
const remoteUrl = envOverride ? String(process.env.HERMES_DESKTOP_REMOTE_URL || '') : String(block.url || '')
|
||||
const mode = envOverride || (key ? scoped?.mode : config.mode) === 'remote' ? 'remote' : 'local'
|
||||
// The env override forces a plain remote connection. Otherwise reflect the
|
||||
// saved mode, preserving 'cloud' (a Hermes Cloud connection — Q6) so the UI
|
||||
// reopens into the cloud picker; any non-remote-like value collapses to local.
|
||||
const savedMode = key ? scoped?.mode : config.mode
|
||||
const mode = envOverride ? 'remote' : modeIsRemoteLike(savedMode) ? savedMode : 'local'
|
||||
|
||||
let remoteOauthConnected = false
|
||||
if (authMode === 'oauth' && remoteUrl) {
|
||||
@@ -4753,6 +5085,9 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
remoteAuthMode: authMode,
|
||||
remoteOauthConnected,
|
||||
remoteUrl,
|
||||
// The persisted Hermes Cloud org (slug/id) for a cloud connection, or '' for
|
||||
// remote/local. Lets Settings → Gateway reopen into the same org.
|
||||
cloudOrg: mode === 'cloud' ? String(block.org || '') : '',
|
||||
remoteTokenPreview: tokenPreview(remoteToken),
|
||||
remoteTokenSet: Boolean(remoteToken),
|
||||
// The env override only forces the global/primary connection; a per-profile
|
||||
@@ -4764,23 +5099,49 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
// Build + validate a `{ url, authMode, token }` remote block. OAuth gateways
|
||||
// authenticate via the login-window session cookie (verified at connect time in
|
||||
// resolveRemoteBackend), so only token-auth remotes require a saved token.
|
||||
function buildRemoteBlock(remoteUrl, authMode, token) {
|
||||
// `org` (optional) is the Hermes Cloud org slug/id the instance was discovered
|
||||
// under — persisted so Settings can reopen into the same org; omitted from the
|
||||
// block when empty so plain remote connections stay unchanged.
|
||||
function buildRemoteBlock(remoteUrl, authMode, token, org) {
|
||||
if (authMode !== 'oauth' && !decryptDesktopSecret(token)) {
|
||||
throw new Error('Remote gateway session token is required.')
|
||||
}
|
||||
return { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
|
||||
const block = { url: normalizeRemoteBaseUrl(remoteUrl), authMode, token }
|
||||
const orgValue = typeof org === 'string' ? org.trim() : ''
|
||||
if (orgValue) {
|
||||
block.org = orgValue
|
||||
}
|
||||
return block
|
||||
}
|
||||
|
||||
function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnectionConfig(), options = {}) {
|
||||
const persistToken = options.persistToken !== false
|
||||
const key = connectionScopeKey(input.profile)
|
||||
const mode = input.mode === 'remote' ? 'remote' : 'local'
|
||||
// 'cloud' and 'remote' both persist a remote-shaped block; 'cloud' is
|
||||
// remembered as its own provenance (Q6) and resolves to remote downstream.
|
||||
// Anything else collapses to local.
|
||||
const mode = modeIsRemoteLike(input.mode) ? input.mode : 'local'
|
||||
const remoteLike = modeIsRemoteLike(mode)
|
||||
|
||||
// The block being edited: a per-profile entry or the global remote block.
|
||||
const existingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
|
||||
const rawExistingBlock = key ? existing.profiles?.[key] || {} : existing.remote || {}
|
||||
// Leaving a CLOUD connection unselects it: a cloud block's url/org/token
|
||||
// describe a discovered Hermes Cloud instance, NOT a user-owned remote gateway,
|
||||
// so switching to local or remote must NOT inherit them (otherwise the stale
|
||||
// cloud URL lingers and re-selecting Cloud looks "already connected"). When the
|
||||
// saved block was cloud and the new mode is not cloud, start from an empty
|
||||
// block. (remote↔local toggles still preserve a real remote URL as before.)
|
||||
const existingMode = key ? existing.profiles?.[key]?.mode : existing.mode
|
||||
const leavingCloud = existingMode === 'cloud' && mode !== 'cloud'
|
||||
const existingBlock = leavingCloud ? {} : rawExistingBlock
|
||||
const remoteUrl = String(input.remoteUrl ?? existingBlock.url ?? '').trim()
|
||||
// authMode: explicit input wins; otherwise inherit the saved value, default 'token'.
|
||||
const authMode = resolveAuthMode(input.remoteAuthMode, existingBlock.authMode)
|
||||
// Cloud org: only meaningful for 'cloud' mode. Explicit input wins; otherwise
|
||||
// inherit the saved org. A plain 'remote' connection never carries an org
|
||||
// (switching cloud→remote drops it), so it stays unset unless mode is cloud.
|
||||
const cloudOrg =
|
||||
mode === 'cloud' ? String(input.cloudOrg ?? existingBlock.org ?? '').trim() : ''
|
||||
const incomingToken = typeof input.remoteToken === 'string' ? input.remoteToken.trim() : ''
|
||||
const nextToken = incomingToken
|
||||
? persistToken
|
||||
@@ -4789,21 +5150,25 @@ function coerceDesktopConnectionConfig(input = {}, existing = readDesktopConnect
|
||||
: existingBlock.token
|
||||
|
||||
if (key) {
|
||||
// Per-profile scope: a remote entry pins this profile to its own backend; a
|
||||
// local entry clears the override so the profile inherits the default.
|
||||
// Per-profile scope: a remote/cloud entry pins this profile to its own
|
||||
// backend; a local entry clears the override so the profile inherits the
|
||||
// default. The mode tag (remote vs cloud) is preserved on the entry.
|
||||
const profiles = { ...(existing.profiles || {}) }
|
||||
if (mode === 'remote') {
|
||||
profiles[key] = { mode: 'remote', ...buildRemoteBlock(remoteUrl, authMode, nextToken) }
|
||||
if (remoteLike) {
|
||||
profiles[key] = { mode, ...buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg) }
|
||||
} else {
|
||||
delete profiles[key]
|
||||
}
|
||||
return { mode: existing.mode === 'remote' ? 'remote' : 'local', remote: existing.remote || {}, profiles }
|
||||
return {
|
||||
mode: modeIsRemoteLike(existing.mode) ? existing.mode : 'local',
|
||||
remote: existing.remote || {},
|
||||
profiles
|
||||
}
|
||||
}
|
||||
|
||||
const nextRemote =
|
||||
mode === 'remote'
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken)
|
||||
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
|
||||
const nextRemote = remoteLike
|
||||
? buildRemoteBlock(remoteUrl, authMode, nextToken, cloudOrg)
|
||||
: { url: remoteUrl ? normalizeRemoteBaseUrl(remoteUrl) : remoteUrl, authMode, token: nextToken }
|
||||
|
||||
// Preserve per-profile overrides when saving the global connection.
|
||||
return { mode, remote: nextRemote, profiles: existing.profiles || {} }
|
||||
@@ -4907,8 +5272,8 @@ async function resolveRemoteBackend(profile) {
|
||||
return buildRemoteConnection(rawEnvUrl, 'token', rawEnvToken, 'env')
|
||||
}
|
||||
|
||||
// 3. Global remote.
|
||||
if (config.mode !== 'remote') {
|
||||
// 3. Global remote (or cloud — cloud resolves to a remote backend, Q6).
|
||||
if (!modeIsRemoteLike(config.mode)) {
|
||||
return null
|
||||
}
|
||||
const authMode = normAuthMode(config.remote?.authMode)
|
||||
@@ -4930,13 +5295,14 @@ function configuredRemoteProfileNames() {
|
||||
}
|
||||
|
||||
// True when the app is in app-global remote mode (Settings → "All profiles" →
|
||||
// Remote, or the env override): a SINGLE remote backend serves every profile via
|
||||
// ?profile=. Distinct from per-profile overrides — here there's one host for all.
|
||||
// Remote/Cloud, or the env override): a SINGLE remote backend serves every
|
||||
// profile via ?profile=. Cloud counts — it resolves to a remote backend (Q6).
|
||||
// Distinct from per-profile overrides — here there's one host for all.
|
||||
function globalRemoteActive() {
|
||||
if (process.env.HERMES_DESKTOP_REMOTE_URL) {
|
||||
return true
|
||||
}
|
||||
return readDesktopConnectionConfig().mode === 'remote'
|
||||
return modeIsRemoteLike(readDesktopConnectionConfig().mode)
|
||||
}
|
||||
|
||||
// GET a profile's resolved backend (remote pool or local primary), parsed JSON.
|
||||
@@ -5024,7 +5390,9 @@ async function testDesktopConnectionConfig(input = {}) {
|
||||
// already normalized the URL and resolved token inheritance for the scope.
|
||||
const block = key ? config.profiles?.[key] || null : config.remote
|
||||
const wantRemote =
|
||||
block?.mode === 'remote' || (!key && config.mode === 'remote') || (input.mode === 'remote' && block)
|
||||
modeIsRemoteLike(block?.mode) ||
|
||||
(!key && modeIsRemoteLike(config.mode)) ||
|
||||
(modeIsRemoteLike(input.mode) && block)
|
||||
// ``/api/status`` is public on every gateway (no creds needed), so a
|
||||
// reachability test works for local, token, and oauth modes alike — we only
|
||||
// need a base URL. For a remote config we normalize the URL from the input;
|
||||
@@ -6252,6 +6620,33 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) =
|
||||
// as still-connected rather than silently signed-out.
|
||||
return { ok: true, connected: baseUrl ? await hasLiveOauthSession(baseUrl) : false }
|
||||
})
|
||||
|
||||
// --- Hermes Cloud (cloud-auto-discovery Phase 3) ---
|
||||
// One portal login in the OAuth partition powers both discovery and the silent
|
||||
// per-agent cascade. See the discovery/cascade helpers above.
|
||||
ipcMain.handle('hermes:cloud:status', async () => ({
|
||||
portalBaseUrl: resolvePortalBaseUrl(),
|
||||
signedIn: await hasLivePortalSession()
|
||||
}))
|
||||
ipcMain.handle('hermes:cloud:login', async () => {
|
||||
await openPortalLoginWindow()
|
||||
return { ok: true, signedIn: await hasLivePortalSession() }
|
||||
})
|
||||
ipcMain.handle('hermes:cloud:logout', async () => {
|
||||
await clearOauthSession(resolvePortalBaseUrl())
|
||||
return { ok: true, signedIn: await hasLivePortalSession() }
|
||||
})
|
||||
ipcMain.handle('hermes:cloud:discover', async (_event, org) => {
|
||||
// Returns { agents } or { needsOrgSelection: true, orgs }. `org` (optional)
|
||||
// scopes discovery to a chosen org for multi-org users.
|
||||
return discoverCloudAgents(typeof org === 'string' && org ? org : undefined)
|
||||
})
|
||||
ipcMain.handle('hermes:cloud:agent-sign-in', async (_event, dashboardUrl) => {
|
||||
// Silent per-agent sign-in via the shared portal session. Returns the agent's
|
||||
// gateway baseUrl + whether its session cookie landed; the renderer then
|
||||
// saves a cloud-mode connection pointed at this dashboardUrl.
|
||||
return cloudAgentSilentSignIn(dashboardUrl)
|
||||
})
|
||||
ipcMain.handle('hermes:connection-config:save', async (_event, payload) => {
|
||||
const config = coerceDesktopConnectionConfig(payload)
|
||||
writeDesktopConnectionConfig(config)
|
||||
@@ -6987,9 +7382,75 @@ ipcMain.handle('hermes:fs:trash', async (_event, targetPath) => {
|
||||
return true
|
||||
})
|
||||
|
||||
// Git/worktree/review IPC lives in git-ipc.cjs; the git + gh binary resolvers
|
||||
// stay here (Windows PATH discovery) and are injected into the registrar.
|
||||
registerGitIpc({ ipcMain, resolveGitBinary, resolveGhBinary })
|
||||
// Git-driven worktree management ("Start work" flow). Errors surface to the
|
||||
// renderer as rejected promises so it can toast a friendly message.
|
||||
ipcMain.handle('hermes:git:worktreeList', async (_event, repoPath) => listWorktrees(repoPath, resolveGitBinary()))
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeAdd', async (_event, repoPath, options) =>
|
||||
addWorktree(repoPath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:worktreeRemove', async (_event, repoPath, worktreePath, options) =>
|
||||
removeWorktree(repoPath, worktreePath, options || {}, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchSwitch', async (_event, repoPath, branch) =>
|
||||
switchBranch(repoPath, branch, resolveGitBinary())
|
||||
)
|
||||
|
||||
ipcMain.handle('hermes:git:branchList', async (_event, repoPath) => listBranches(repoPath, resolveGitBinary()))
|
||||
|
||||
// Compact repo status (branch, ahead/behind, change counts + files) for the
|
||||
// composer coding rail. Returns null on a non-repo / remote backend so the rail
|
||||
// hides cleanly rather than erroring.
|
||||
ipcMain.handle('hermes:git:repoStatus', async (_event, repoPath) => repoStatus(repoPath, resolveGitBinary()))
|
||||
|
||||
// Codex-style review pane: list changed files for a scope, fetch one file's
|
||||
// unified diff, and stage / unstage / revert. Reads return empty on failure;
|
||||
// mutations reject so the renderer can toast.
|
||||
ipcMain.handle('hermes:git:review:list', async (_event, repoPath, scope, baseRef) =>
|
||||
reviewList(repoPath, scope, baseRef, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:diff', async (_event, repoPath, filePath, scope, baseRef, staged) =>
|
||||
reviewDiff(repoPath, filePath, scope, baseRef, staged, resolveGitBinary())
|
||||
)
|
||||
// Working-tree-vs-HEAD diff for one file (the preview's "show the diff" view).
|
||||
ipcMain.handle('hermes:git:fileDiff', async (_event, repoPath, filePath) =>
|
||||
fileDiffVsHead(repoPath, filePath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:stage', async (_event, repoPath, filePath) =>
|
||||
reviewStage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:unstage', async (_event, repoPath, filePath) =>
|
||||
reviewUnstage(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revert', async (_event, repoPath, filePath) =>
|
||||
reviewRevert(repoPath, filePath ?? null, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:revParse', async (_event, repoPath, ref) =>
|
||||
reviewRevParse(repoPath, ref, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commit', async (_event, repoPath, message, push) =>
|
||||
reviewCommit(repoPath, message, Boolean(push), resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:commitContext', async (_event, repoPath) =>
|
||||
reviewCommitContext(repoPath, resolveGitBinary())
|
||||
)
|
||||
ipcMain.handle('hermes:git:review:push', async (_event, repoPath) => reviewPush(repoPath, resolveGitBinary()))
|
||||
ipcMain.handle('hermes:git:review:shipInfo', async (_event, repoPath) => reviewShipInfo(repoPath, resolveGhBinary()))
|
||||
ipcMain.handle('hermes:git:review:createPr', async (_event, repoPath) =>
|
||||
reviewCreatePr(repoPath, resolveGitBinary(), resolveGhBinary())
|
||||
)
|
||||
|
||||
// Repo-first project discovery: scan bounded roots for git repos (pure fs walk,
|
||||
// no native addon). Never throws to the renderer — failures yield an empty list.
|
||||
ipcMain.handle('hermes:git:scanRepos', async (_event, roots, options) => {
|
||||
try {
|
||||
return await scanGitRepos(roots || [], options || {})
|
||||
} catch {
|
||||
return []
|
||||
}
|
||||
})
|
||||
|
||||
ipcMain.handle('hermes:terminal:start', async (event, payload = {}) => {
|
||||
if (!nodePty) {
|
||||
|
||||
@@ -41,6 +41,15 @@ contextBridge.exposeInMainWorld('hermesDesktop', {
|
||||
probeConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:probe', remoteUrl),
|
||||
oauthLoginConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-login', remoteUrl),
|
||||
oauthLogoutConnectionConfig: remoteUrl => ipcRenderer.invoke('hermes:connection-config:oauth-logout', remoteUrl),
|
||||
// Hermes Cloud: one portal login powers discovery + silent per-agent sign-in
|
||||
// (cloud-auto-discovery Phase 3).
|
||||
cloud: {
|
||||
status: () => ipcRenderer.invoke('hermes:cloud:status'),
|
||||
login: () => ipcRenderer.invoke('hermes:cloud:login'),
|
||||
logout: () => ipcRenderer.invoke('hermes:cloud:logout'),
|
||||
discover: org => ipcRenderer.invoke('hermes:cloud:discover', org),
|
||||
agentSignIn: dashboardUrl => ipcRenderer.invoke('hermes:cloud:agent-sign-in', dashboardUrl)
|
||||
},
|
||||
profile: {
|
||||
get: () => ipcRenderer.invoke('hermes:profile:get'),
|
||||
set: name => ipcRenderer.invoke('hermes:profile:set', name)
|
||||
|
||||
@@ -37,7 +37,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-ipc.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs",
|
||||
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-env.test.cjs electron/backend-probes.test.cjs electron/backend-ready.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/dashboard-token.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/link-title-window.test.cjs electron/workspace-cwd.test.cjs electron/fs-read-dir.test.cjs electron/git-root.test.cjs electron/git-worktree-ops.test.cjs electron/windows-child-process.test.cjs electron/update-remote.test.cjs electron/update-count.test.cjs electron/update-rebuild.test.cjs electron/update-marker.test.cjs electron/update-relaunch.test.cjs electron/windows-user-env.test.cjs electron/wsl-clipboard-image.test.cjs electron/titlebar-overlay-width.test.cjs electron/window-state.test.cjs electron/windows-hermes-resolution.test.cjs",
|
||||
"typecheck": "tsc -p . --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -8,7 +8,6 @@
|
||||
* user can grab several.
|
||||
*/
|
||||
|
||||
import { useStore } from '@nanostores/react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
@@ -19,7 +18,6 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { Check, Download, Loader2, Palette } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import { $marketplaceInstalls } from '@/themes/user-themes'
|
||||
|
||||
const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', maximumFractionDigits: 1 })
|
||||
|
||||
@@ -45,8 +43,8 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debouncedSearch = useDebounced(search.trim(), 300)
|
||||
const installs = useStore($marketplaceInstalls)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installed, setInstalled] = useState<Record<string, true>>({})
|
||||
const [installError, setInstallError] = useState<string | null>(null)
|
||||
|
||||
const query = useQuery({
|
||||
@@ -55,20 +53,6 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
// Already installed → just re-activate it; never re-download what we have.
|
||||
const select = (item: DesktopMarketplaceSearchItem) => {
|
||||
const owned = installs.get(item.extensionId)
|
||||
|
||||
if (owned) {
|
||||
triggerHaptic('crisp')
|
||||
onPickTheme(owned.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void install(item)
|
||||
}
|
||||
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
@@ -81,6 +65,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setInstalled(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onPickTheme(theme.name)
|
||||
} catch (error) {
|
||||
setInstallError(error instanceof Error ? error.message : copy.error)
|
||||
@@ -108,7 +93,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
{installError && <p className="px-2 pb-1 pt-1.5 text-[0.6875rem] text-(--ui-red)">{installError}</p>}
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installs.has(item.extensionId)
|
||||
const done = installed[item.extensionId]
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -119,7 +104,7 @@ export function MarketplaceThemePage({ search, onPickTheme }: MarketplaceThemePa
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => select(item)}
|
||||
onClick={() => void install(item)}
|
||||
onMouseDown={event => event.preventDefault()}
|
||||
role="option"
|
||||
type="button"
|
||||
|
||||
@@ -17,8 +17,7 @@ import { $toolViewMode, setToolViewMode } from '@/store/tool-view'
|
||||
import { $translucency, setTranslucency } from '@/store/translucency'
|
||||
import { getBaseColors, useTheme } from '@/themes/context'
|
||||
import { installVscodeThemeFromMarketplace } from '@/themes/install'
|
||||
import type { DesktopTheme } from '@/themes/types'
|
||||
import { $marketplaceInstalls, isUserTheme, removeUserTheme } from '@/themes/user-themes'
|
||||
import { isUserTheme, removeUserTheme } from '@/themes/user-themes'
|
||||
|
||||
import { MODE_OPTIONS } from './constants'
|
||||
import { PetSettings } from './pet-settings'
|
||||
@@ -83,17 +82,18 @@ const compactNumber = new Intl.NumberFormat(undefined, { notation: 'compact', ma
|
||||
*/
|
||||
function MarketplaceThemeResults({
|
||||
query,
|
||||
installs,
|
||||
installedExtIds,
|
||||
onInstalled
|
||||
}: {
|
||||
query: string
|
||||
installs: ReadonlyMap<string, DesktopTheme>
|
||||
installedExtIds: Set<string>
|
||||
onInstalled: (name: string) => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const copy = t.commandCenter.installTheme
|
||||
const debounced = useDebounced(query.trim(), 300)
|
||||
const [installingId, setInstallingId] = useState<string | null>(null)
|
||||
const [installedHere, setInstalledHere] = useState<Record<string, true>>({})
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const search = useQuery({
|
||||
@@ -103,20 +103,6 @@ function MarketplaceThemeResults({
|
||||
staleTime: 5 * 60 * 1000
|
||||
})
|
||||
|
||||
// Already installed → just re-activate it; never re-download what we have.
|
||||
const select = (item: DesktopMarketplaceSearchItem) => {
|
||||
const owned = installs.get(item.extensionId)
|
||||
|
||||
if (owned) {
|
||||
triggerHaptic('crisp')
|
||||
onInstalled(owned.name)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
void install(item)
|
||||
}
|
||||
|
||||
const install = async (item: DesktopMarketplaceSearchItem) => {
|
||||
if (installingId) {
|
||||
return
|
||||
@@ -129,6 +115,7 @@ function MarketplaceThemeResults({
|
||||
const theme = await installVscodeThemeFromMarketplace(item.extensionId)
|
||||
|
||||
triggerHaptic('crisp')
|
||||
setInstalledHere(prev => ({ ...prev, [item.extensionId]: true }))
|
||||
onInstalled(theme.name)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : copy.error)
|
||||
@@ -186,7 +173,7 @@ function MarketplaceThemeResults({
|
||||
<div className="grid gap-2 sm:grid-cols-2">
|
||||
{results.map(item => {
|
||||
const busy = installingId === item.extensionId
|
||||
const done = installs.has(item.extensionId)
|
||||
const done = installedHere[item.extensionId] || installedExtIds.has(item.extensionId)
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -196,7 +183,7 @@ function MarketplaceThemeResults({
|
||||
)}
|
||||
disabled={Boolean(installingId) && !busy}
|
||||
key={item.extensionId}
|
||||
onClick={() => select(item)}
|
||||
onClick={() => void install(item)}
|
||||
type="button"
|
||||
>
|
||||
<Palette className="size-4 shrink-0 text-(--ui-text-tertiary)" />
|
||||
@@ -233,7 +220,6 @@ export function AppearanceSettings() {
|
||||
const embedMode = useStore($embedMode)
|
||||
const embedAllowed = useStore($embedAllowed)
|
||||
const translucency = useStore($translucency)
|
||||
const installs = useStore($marketplaceInstalls)
|
||||
const profiles = useStore($profiles)
|
||||
const activeProfileKey = normalizeProfileKey(useStore($activeGatewayProfile))
|
||||
const a = t.settings.appearance
|
||||
@@ -256,6 +242,20 @@ export function AppearanceSettings() {
|
||||
// Active theme first; stable sort keeps the rest in their original order.
|
||||
.sort((a, b) => Number(b.name === themeName) - Number(a.name === themeName))
|
||||
|
||||
// Marketplace imports describe themselves as "VS Code · <publisher.extension>";
|
||||
// pull those ids back out so search results already imported show as installed.
|
||||
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
|
||||
|
||||
const installedExtIds = new Set(
|
||||
availableThemes
|
||||
.map(theme =>
|
||||
theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
|
||||
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
|
||||
: ''
|
||||
)
|
||||
.filter(Boolean)
|
||||
)
|
||||
|
||||
// Themes save per profile. Surface that only when the user actually has more
|
||||
// than one profile (single-profile installs never see the distinction).
|
||||
const showProfileNote = profiles.length > 1
|
||||
@@ -365,7 +365,11 @@ export function AppearanceSettings() {
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<MarketplaceThemeResults installs={installs} onInstalled={name => setTheme(name)} query={query} />
|
||||
<MarketplaceThemeResults
|
||||
installedExtIds={installedExtIds}
|
||||
onInstalled={name => setTheme(name)}
|
||||
query={query}
|
||||
/>
|
||||
</div>
|
||||
{showProfileNote && (
|
||||
<p className="mt-3 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
|
||||
@@ -3,9 +3,15 @@ import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import type { DesktopAuthProvider, DesktopConnectionProbeResult } from '@/global'
|
||||
import type {
|
||||
DesktopAuthProvider,
|
||||
DesktopCloudAgent,
|
||||
DesktopCloudOrg,
|
||||
DesktopConnectionProbeResult
|
||||
} from '@/global'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertCircle, Check, FileText, Globe, Loader2, LogIn, Monitor } from '@/lib/icons'
|
||||
import { ExternalLink } from '@/lib/external-link'
|
||||
import { AlertCircle, Check, Cloud, FileText, Globe, Loader2, LogIn, Monitor, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
@@ -13,9 +19,11 @@ import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
|
||||
type Mode = 'local' | 'remote'
|
||||
type Mode = 'local' | 'remote' | 'cloud'
|
||||
type AuthMode = 'oauth' | 'token'
|
||||
type ProbeStatus = 'idle' | 'probing' | 'done' | 'error'
|
||||
// Hermes Cloud discovery lifecycle for the cloud-mode panel.
|
||||
type CloudDiscoverStatus = 'idle' | 'loading' | 'done' | 'error'
|
||||
|
||||
interface GatewaySettingsState {
|
||||
envOverride: boolean
|
||||
@@ -25,6 +33,7 @@ interface GatewaySettingsState {
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
cloudOrg: string
|
||||
}
|
||||
|
||||
const EMPTY_STATE: GatewaySettingsState = {
|
||||
@@ -34,7 +43,8 @@ const EMPTY_STATE: GatewaySettingsState = {
|
||||
remoteOauthConnected: false,
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: ''
|
||||
remoteUrl: '',
|
||||
cloudOrg: ''
|
||||
}
|
||||
|
||||
function ModeCard({
|
||||
@@ -105,6 +115,32 @@ export function GatewaySettings() {
|
||||
const [remoteToken, setRemoteToken] = useState('')
|
||||
const [lastTest, setLastTest] = useState<null | string>(null)
|
||||
|
||||
// --- Hermes Cloud (cloud mode) state ---
|
||||
// One portal session powers discovery + the silent per-agent cascade. These
|
||||
// track the cloud panel: whether we're signed in, the discovered agent list,
|
||||
// and which agent is mid-connect.
|
||||
const [cloudSignedIn, setCloudSignedIn] = useState(false)
|
||||
const [cloudSigningIn, setCloudSigningIn] = useState(false)
|
||||
const [cloudAgents, setCloudAgents] = useState<DesktopCloudAgent[]>([])
|
||||
const [cloudDiscover, setCloudDiscover] = useState<CloudDiscoverStatus>('idle')
|
||||
const [cloudConnectingId, setCloudConnectingId] = useState<null | string>(null)
|
||||
// Multi-org users: when discovery returns needsOrgSelection, we hold the org
|
||||
// list here and show a picker. `cloudOrg` is the chosen org slug/id (null =
|
||||
// not yet chosen / single-org user).
|
||||
const [cloudOrgs, setCloudOrgs] = useState<DesktopCloudOrg[]>([])
|
||||
const [cloudOrg, setCloudOrgState] = useState<null | string>(null)
|
||||
// Mirror the selected org into a ref so connect reads the CURRENT value, not a
|
||||
// value captured in a stale render closure. discoverCloud() resolves the org
|
||||
// asynchronously (from the NAS response) and a user can click Connect in the
|
||||
// same render tick; without the ref, connectCloudAgent could persist a null
|
||||
// org even though discovery just resolved one. Always set both together.
|
||||
const cloudOrgRef = useRef<null | string>(null)
|
||||
|
||||
const setCloudOrg = (value: null | string) => {
|
||||
cloudOrgRef.current = value
|
||||
setCloudOrgState(value)
|
||||
}
|
||||
|
||||
// Connection scope: null = the global/default connection (the original
|
||||
// behavior); a profile name = that profile's per-profile remote override, so
|
||||
// each profile can point at its own backend.
|
||||
@@ -163,6 +199,21 @@ export function GatewaySettings() {
|
||||
// OAuth login button or the session-token entry box. The effective auth mode
|
||||
// prefers a fresh probe result over the saved value.
|
||||
const trimmedUrl = state.remoteUrl.trim()
|
||||
|
||||
// The dashboardUrl of the currently-connected cloud instance (the saved
|
||||
// cloud connection's remoteUrl), normalized for comparison against each
|
||||
// discovered agent's dashboardUrl so we can highlight the active one and hide
|
||||
// its Connect button. Empty unless the saved connection is a cloud one.
|
||||
const connectedCloudUrl =
|
||||
state.mode === 'cloud' ? state.remoteUrl.trim().replace(/\/+$/, '') : ''
|
||||
|
||||
const isConnectedAgent = (agent: DesktopCloudAgent) =>
|
||||
Boolean(
|
||||
connectedCloudUrl &&
|
||||
agent.dashboardUrl &&
|
||||
agent.dashboardUrl.trim().replace(/\/+$/, '') === connectedCloudUrl
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'remote' || !trimmedUrl || !/^https?:\/\//i.test(trimmedUrl)) {
|
||||
setProbeStatus('idle')
|
||||
@@ -379,6 +430,235 @@ export function GatewaySettings() {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Hermes Cloud handlers ---
|
||||
|
||||
// Pull the discovered agent list over the shared portal session. Tolerant of
|
||||
// a lapsed session: a needsCloudLogin error flips us back to signed-out.
|
||||
// `org` scopes discovery for multi-org users; when discovery comes back with
|
||||
// needsOrgSelection we surface the org list and show a picker instead.
|
||||
const discoverCloud = async (org?: string) => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudDiscover('loading')
|
||||
|
||||
try {
|
||||
const result = await desktop.cloud.discover(org)
|
||||
|
||||
if ('needsOrgSelection' in result && result.needsOrgSelection) {
|
||||
// Multi-org user with no org chosen yet: show the picker. Don't clear a
|
||||
// previously-chosen org list on a refresh.
|
||||
setCloudOrgs(result.orgs)
|
||||
setCloudAgents([])
|
||||
setCloudDiscover('done')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Single org (or org now chosen): we have agents.
|
||||
setCloudAgents('agents' in result ? result.agents : [])
|
||||
|
||||
// Record the org AUTHORITATIVELY from the response (NAS echoes the org the
|
||||
// list was scoped to), falling back to the org we requested. This is what
|
||||
// gets persisted on connect, so it must be set even on single-membership
|
||||
// auto-resolve where no picker ran and no `org` arg was passed.
|
||||
const resolvedOrgRef =
|
||||
'org' in result && result.org ? (result.org.slug ?? result.org.id) : null
|
||||
|
||||
if (resolvedOrgRef) {
|
||||
setCloudOrg(resolvedOrgRef)
|
||||
} else if (org) {
|
||||
setCloudOrg(org)
|
||||
}
|
||||
|
||||
setCloudDiscover('done')
|
||||
} catch (err) {
|
||||
setCloudAgents([])
|
||||
setCloudDiscover('error')
|
||||
|
||||
// A lapsed/absent portal session means we're effectively signed out.
|
||||
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
|
||||
notifyError(err, g.cloudDiscoverFailed)
|
||||
}
|
||||
}
|
||||
|
||||
// User picked an org from the multi-org picker: remember it and re-run
|
||||
// discovery scoped to it.
|
||||
const selectCloudOrg = (org: DesktopCloudOrg) => {
|
||||
const ref = org.slug ?? org.id
|
||||
setCloudOrg(ref)
|
||||
void discoverCloud(ref)
|
||||
}
|
||||
|
||||
// "Change org": clear the selected org and re-discover with no org arg. A
|
||||
// multi-org user gets NAS's 409 → the picker; a single-org user auto-resolves
|
||||
// back to their one org. Also clear the agent list so the current org's
|
||||
// agents don't linger under the picker while discovery re-runs.
|
||||
const changeCloudOrg = () => {
|
||||
setCloudOrg(null)
|
||||
setCloudAgents([])
|
||||
void discoverCloud()
|
||||
}
|
||||
|
||||
// On entering cloud mode (or scope change), read the portal session status and
|
||||
// auto-discover when already signed in, so the picker is populated on open.
|
||||
useEffect(() => {
|
||||
if (state.mode !== 'cloud') {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
let cancelled = false
|
||||
desktop.cloud
|
||||
.status()
|
||||
.then(status => {
|
||||
if (cancelled) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSignedIn(status.signedIn)
|
||||
|
||||
if (status.signedIn) {
|
||||
// Restore the persisted org (if any) so we reopen straight into that
|
||||
// org's agent list instead of the picker; discoverCloud(org) also
|
||||
// records it as the selected org. Empty → normal discovery (single-org
|
||||
// resolves automatically; multi-org shows the picker).
|
||||
const savedOrg = state.cloudOrg || ''
|
||||
|
||||
if (savedOrg) {
|
||||
setCloudOrg(savedOrg)
|
||||
}
|
||||
|
||||
void discoverCloud(savedOrg || undefined)
|
||||
} else {
|
||||
setCloudAgents([])
|
||||
setCloudOrgs([])
|
||||
setCloudOrg(null)
|
||||
setCloudDiscover('idle')
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
})
|
||||
|
||||
return () => void (cancelled = true)
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps -- reload on mode/scope change only
|
||||
}, [state.mode, scope])
|
||||
|
||||
const cloudSignIn = async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSigningIn(true)
|
||||
|
||||
try {
|
||||
const result = await desktop.cloud.login()
|
||||
setCloudSignedIn(result.signedIn)
|
||||
|
||||
if (result.signedIn) {
|
||||
await discoverCloud()
|
||||
}
|
||||
} catch (err) {
|
||||
notifyError(err, g.cloudSignInFailed)
|
||||
} finally {
|
||||
setCloudSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
const cloudSignOut = async () => {
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudSigningIn(true)
|
||||
|
||||
try {
|
||||
await desktop.cloud.logout()
|
||||
setCloudSignedIn(false)
|
||||
setCloudAgents([])
|
||||
setCloudOrgs([])
|
||||
setCloudOrg(null)
|
||||
setCloudDiscover('idle')
|
||||
notify({ kind: 'success', title: g.cloudSignedOutTitle, message: g.cloudSignedOutMessage })
|
||||
} catch (err) {
|
||||
notifyError(err, g.signOutFailed)
|
||||
} finally {
|
||||
setCloudSigningIn(false)
|
||||
}
|
||||
}
|
||||
|
||||
// Select a discovered agent: drive the silent per-agent cascade (no second
|
||||
// prompt — the shared portal session auto-approves), then persist a cloud-mode
|
||||
// connection pointed at its dashboardUrl and apply it (reconnects the window).
|
||||
const connectCloudAgent = async (agent: DesktopCloudAgent) => {
|
||||
if (!agent.dashboardUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
|
||||
if (!desktop?.cloud) {
|
||||
return
|
||||
}
|
||||
|
||||
setCloudConnectingId(agent.id)
|
||||
|
||||
try {
|
||||
const result = await desktop.cloud.agentSignIn(agent.dashboardUrl)
|
||||
|
||||
if (!result.connected) {
|
||||
notify({
|
||||
kind: 'warning',
|
||||
title: t.boot.failure.signInIncompleteTitle,
|
||||
message: t.boot.failure.signInIncompleteMessage
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Persist a cloud-mode connection (remote-shaped, oauth) and reconnect.
|
||||
// Include the selected org so Settings reopens into the same org + instance.
|
||||
// Read the REF (not the cloudOrg state) so a just-resolved org from
|
||||
// discovery in this same render tick is captured, not a stale null.
|
||||
const next = await desktop.applyConnectionConfig({
|
||||
mode: 'cloud',
|
||||
profile: scope ?? undefined,
|
||||
remoteAuthMode: 'oauth',
|
||||
remoteUrl: agent.dashboardUrl,
|
||||
cloudOrg: cloudOrgRef.current ?? undefined
|
||||
})
|
||||
|
||||
setState(next)
|
||||
notify({ kind: 'success', title: g.cloudConnectedTitle, message: g.cloudConnectedTo(agent.name) })
|
||||
} catch (err) {
|
||||
if (err && typeof err === 'object' && 'needsCloudLogin' in err) {
|
||||
setCloudSignedIn(false)
|
||||
}
|
||||
|
||||
notifyError(err, g.cloudConnectFailed)
|
||||
} finally {
|
||||
setCloudConnectingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
const testRemote = async () => {
|
||||
if (!canUseRemote) {
|
||||
notify({
|
||||
@@ -465,7 +745,7 @@ export function GatewaySettings() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<div className="grid gap-3 sm:grid-cols-3">
|
||||
<ModeCard
|
||||
active={state.mode === 'local'}
|
||||
description={g.localDesc}
|
||||
@@ -474,6 +754,14 @@ export function GatewaySettings() {
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'local' }))}
|
||||
title={g.localTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'cloud'}
|
||||
description={g.cloudDesc}
|
||||
disabled={state.envOverride}
|
||||
icon={Cloud}
|
||||
onSelect={() => setState(current => ({ ...current, mode: 'cloud' }))}
|
||||
title={g.cloudTitle}
|
||||
/>
|
||||
<ModeCard
|
||||
active={state.mode === 'remote'}
|
||||
description={g.remoteDesc}
|
||||
@@ -484,6 +772,155 @@ export function GatewaySettings() {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Hermes Cloud panel: one portal sign-in, then a discovered-agent picker
|
||||
whose selection drives the silent per-agent cascade + a cloud
|
||||
connection. Replaces the URL/token form while in cloud mode. */}
|
||||
{state.mode === 'cloud' && !state.envOverride ? (
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
cloudSignedIn ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Pill tone="primary">
|
||||
<Check className="size-3" /> {g.cloudSignedIn}
|
||||
</Pill>
|
||||
<Button disabled={cloudSigningIn} onClick={() => void cloudSignOut()} variant="outline">
|
||||
{cloudSigningIn ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.signOut}
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<Button disabled={cloudSigningIn} onClick={() => void cloudSignIn()}>
|
||||
{cloudSigningIn ? <Loader2 className="animate-spin" /> : <LogIn />}
|
||||
{g.cloudSignIn}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={cloudSignedIn ? g.cloudSignedInDesc : g.cloudNeedsSignIn}
|
||||
title={g.cloudSignInTitle}
|
||||
/>
|
||||
|
||||
{cloudSignedIn ? (
|
||||
cloudOrgs.length > 0 && !cloudOrg ? (
|
||||
// Multi-org user who hasn't picked an org yet: show the org picker
|
||||
// instead of the agent list. Selecting one re-runs discovery
|
||||
// scoped to it.
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
{g.cloudOrgPickerTitle}
|
||||
</div>
|
||||
<div className="grid gap-1">
|
||||
{cloudOrgs.map(orgEntry => (
|
||||
<ListRow
|
||||
action={
|
||||
<Button onClick={() => selectCloudOrg(orgEntry)} size="sm">
|
||||
{g.cloudOrgSelect}
|
||||
</Button>
|
||||
}
|
||||
description={g.cloudOrgRole(orgEntry.role)}
|
||||
key={orgEntry.id}
|
||||
title={orgEntry.name}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<div className="text-[length:var(--conversation-caption-font-size)] font-medium text-(--ui-text-secondary)">
|
||||
{g.cloudAgentsTitle}
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{cloudOrg ? (
|
||||
// Let the user switch orgs. Gating on cloudOrgs.length would
|
||||
// hide this after a restore-open (which discovers straight
|
||||
// into the saved org and never populates the org list). So
|
||||
// show it whenever an org is selected: clicking clears the
|
||||
// org and re-runs discovery with no org arg — a multi-org
|
||||
// user gets the picker (NAS 409), a single-org user simply
|
||||
// auto-resolves back to their one org (harmless).
|
||||
<Button onClick={() => changeCloudOrg()} size="sm" variant="text">
|
||||
{g.cloudOrgChange}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
disabled={cloudDiscover === 'loading'}
|
||||
onClick={() => void discoverCloud(cloudOrg ?? undefined)}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{cloudDiscover === 'loading' ? <Loader2 className="animate-spin" /> : <RefreshCw />}
|
||||
{g.cloudRefresh}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{cloudDiscover === 'loading' ? (
|
||||
<div className="flex items-center gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<Loader2 className="size-4 animate-spin" />
|
||||
{g.cloudLoadingAgents}
|
||||
</div>
|
||||
) : cloudAgents.length === 0 ? (
|
||||
<div className="flex items-start gap-2 py-3 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<AlertCircle className="mt-0.5 size-4 shrink-0" />
|
||||
<span>
|
||||
{g.cloudNoAgents.before}
|
||||
<ExternalLink href="https://portal.nousresearch.com/agents" showExternalIcon={false}>
|
||||
{g.cloudNoAgents.linkText}
|
||||
</ExternalLink>
|
||||
{g.cloudNoAgents.after}
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid gap-1">
|
||||
{cloudAgents.map(agent => {
|
||||
const connected = isConnectedAgent(agent)
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'rounded-md px-2',
|
||||
connected && 'bg-primary/5 ring-1 ring-primary/25'
|
||||
)}
|
||||
key={agent.id}
|
||||
>
|
||||
<ListRow
|
||||
action={
|
||||
connected ? (
|
||||
<Pill tone="primary">
|
||||
<Check className="mr-1 inline size-3" />
|
||||
{g.cloudConnectedPill}
|
||||
</Pill>
|
||||
) : (
|
||||
<Button
|
||||
disabled={!agent.dashboardUrl || cloudConnectingId !== null}
|
||||
onClick={() => void connectCloudAgent(agent)}
|
||||
size="sm"
|
||||
>
|
||||
{cloudConnectingId === agent.id ? <Loader2 className="animate-spin" /> : null}
|
||||
{agent.dashboardUrl
|
||||
? cloudConnectingId === agent.id
|
||||
? g.cloudConnecting
|
||||
: g.cloudConnect
|
||||
: g.cloudAgentProvisioning}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
description={g.cloudStatusLabel(agent.dashboardGatewayState)}
|
||||
title={agent.name}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{state.mode === 'remote' && !state.envOverride ? (
|
||||
<div className="mt-5 grid gap-1">
|
||||
<ListRow
|
||||
action={
|
||||
@@ -568,28 +1005,36 @@ export function GatewaySettings() {
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{lastTest ? <div className="mt-4 text-xs text-primary">{lastTest}</div> : null}
|
||||
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
</div>
|
||||
{/* Test/Save apply to local + remote. Cloud connects via the agent picker
|
||||
above (which applies a cloud connection on select), so its only
|
||||
bottom-row action would be redundant — hidden in cloud mode. */}
|
||||
{state.mode !== 'cloud' ? (
|
||||
<div className="mt-6 flex flex-wrap items-center justify-end gap-4">
|
||||
{state.mode === 'remote' ? (
|
||||
<Button
|
||||
className="mr-auto"
|
||||
disabled={state.envOverride || testing || !canUseRemote}
|
||||
onClick={() => void testRemote()}
|
||||
size="sm"
|
||||
variant="text"
|
||||
>
|
||||
{testing ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.testRemote}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(false)} size="sm" variant="textStrong">
|
||||
{g.saveForRestart}
|
||||
</Button>
|
||||
<Button disabled={state.envOverride || saving} onClick={() => void save(true)} size="sm">
|
||||
{saving ? <Loader2 className="animate-spin" /> : null}
|
||||
{g.saveAndReconnect}
|
||||
</Button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="mt-6 grid gap-1">
|
||||
<ListRow
|
||||
|
||||
@@ -14,6 +14,7 @@ function config(overrides: Partial<DesktopConnectionConfig> = {}): DesktopConnec
|
||||
remoteTokenPreview: null,
|
||||
remoteTokenSet: false,
|
||||
remoteUrl: 'https://box:9119',
|
||||
cloudOrg: '',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
@@ -31,6 +32,16 @@ describe('isRemoteReauthFailure', () => {
|
||||
expect(isRemoteReauthFailure(config({ mode: 'local' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('true for a cloud connection with a lapsed session (cloud resolves to remote oauth)', () => {
|
||||
// A 'cloud' connection is a remote oauth backend under the hood (Q6), so a
|
||||
// lapsed cloud session is the same reauth failure as a lapsed remote one.
|
||||
expect(isRemoteReauthFailure(config({ mode: 'cloud' }))).toBe(true)
|
||||
})
|
||||
|
||||
it('false for a connected cloud session', () => {
|
||||
expect(isRemoteReauthFailure(config({ mode: 'cloud', remoteOauthConnected: true }))).toBe(false)
|
||||
})
|
||||
|
||||
it('false for a token (non-gated) remote gateway', () => {
|
||||
expect(isRemoteReauthFailure(config({ remoteAuthMode: 'token' }))).toBe(false)
|
||||
})
|
||||
|
||||
@@ -31,14 +31,16 @@ const DEFAULT_SIGN_IN_COPY: SignInCopy = {
|
||||
// dashboard restarted) and the local-recovery buttons (Retry/Repair) can't
|
||||
// fix it — only re-establishing the remote session can. A connected oauth
|
||||
// session, or a token/local gateway, boots for some other reason the
|
||||
// local-recovery buttons address, so those return false here.
|
||||
// local-recovery buttons address, so those return false here. 'cloud' counts
|
||||
// as remote here — it resolves to a remote oauth backend (cloud-auto-discovery
|
||||
// Q6), so a lapsed cloud session is the same reauth failure.
|
||||
export function isRemoteReauthFailure(config: DesktopConnectionConfig | null | undefined): boolean {
|
||||
if (!config) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (
|
||||
config.mode === 'remote' &&
|
||||
(config.mode === 'remote' || config.mode === 'cloud') &&
|
||||
config.remoteAuthMode === 'oauth' &&
|
||||
!config.remoteOauthConnected &&
|
||||
Boolean(config.remoteUrl)
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { chooseMove, dwellMs, type DwellRange, HOP_CHANCE, pickStrollTarget, REST_CHANCE, type Rng } from './roam-behavior'
|
||||
import type { Ledge } from './roam-geometry'
|
||||
|
||||
// Deterministic rng that replays a fixed sequence (last value sticks).
|
||||
const seq =
|
||||
(...vals: number[]): Rng =>
|
||||
() =>
|
||||
vals.shift() ?? vals[vals.length - 1] ?? 0
|
||||
|
||||
const RANGE: DwellRange = { maxMs: 13000, meanMs: 4000, minMs: 1500 }
|
||||
const ledge = (left: number, right: number, y = 0): Ledge => ({ left, right, y })
|
||||
|
||||
describe('dwellMs', () => {
|
||||
it('clamps the degenerate draws to the floor and ceiling', () => {
|
||||
// rng→0 ⇒ u=1 ⇒ -ln(1)·mean = 0, raised to the floor.
|
||||
expect(dwellMs(RANGE, () => 0)).toBe(RANGE.minMs)
|
||||
// rng→~1 ⇒ u→0 ⇒ -ln(u) blows up, capped at the ceiling.
|
||||
expect(dwellMs(RANGE, () => 1 - 1e-9)).toBe(RANGE.maxMs)
|
||||
})
|
||||
|
||||
it('returns the mean at the exponential median point', () => {
|
||||
// rng = 1 - 1/e ⇒ u = 1/e ⇒ -ln(u) = 1 ⇒ exactly the mean.
|
||||
expect(dwellMs(RANGE, () => 1 - 1 / Math.E)).toBeCloseTo(RANGE.meanMs, 6)
|
||||
})
|
||||
|
||||
it('stays within [min, max] across the whole rng domain', () => {
|
||||
let state = 0.123456789
|
||||
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
state = (state * 9301 + 0.49297) % 1 // cheap deterministic walk
|
||||
const ms = dwellMs(RANGE, () => state)
|
||||
expect(ms).toBeGreaterThanOrEqual(RANGE.minMs)
|
||||
expect(ms).toBeLessThanOrEqual(RANGE.maxMs)
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('chooseMove', () => {
|
||||
it('rests whenever the first draw lands under restChance — even where it could hop', () => {
|
||||
expect(chooseMove(true, seq(0))).toBe('rest')
|
||||
expect(chooseMove(false, seq(REST_CHANCE - 1e-9))).toBe('rest')
|
||||
})
|
||||
|
||||
it('strolls when moving with nowhere to hop', () => {
|
||||
expect(chooseMove(false, seq(0.99))).toBe('stroll')
|
||||
})
|
||||
|
||||
it('hops only when moving, a ledge is reachable, and the second draw says so', () => {
|
||||
expect(chooseMove(true, seq(0.99, HOP_CHANCE - 1e-9))).toBe('hop')
|
||||
expect(chooseMove(true, seq(0.99, HOP_CHANCE))).toBe('stroll')
|
||||
})
|
||||
|
||||
it('treats restChance as a strict lower bound (boundary stays a move)', () => {
|
||||
expect(chooseMove(false, seq(REST_CHANCE))).toBe('stroll')
|
||||
})
|
||||
|
||||
it('loafs far more than it roams over a long run (the whole point)', () => {
|
||||
let state = 0.314159
|
||||
const rng: Rng = () => (state = (state * 16807 + 0.5) % 1)
|
||||
let rests = 0
|
||||
const N = 20000
|
||||
|
||||
for (let i = 0; i < N; i++) {
|
||||
if (chooseMove(true, rng) === 'rest') {
|
||||
rests++
|
||||
}
|
||||
}
|
||||
|
||||
// ~62% rests; assert the contract (majority loafing), not the exact rate.
|
||||
expect(rests / N).toBeGreaterThan(0.5)
|
||||
})
|
||||
})
|
||||
|
||||
describe('pickStrollTarget', () => {
|
||||
it('collapses to the left edge on a ledge too narrow to walk', () => {
|
||||
expect(pickStrollTarget(ledge(100, 102), 100, seq(0))).toBe(100)
|
||||
})
|
||||
|
||||
it('lands inside the ledge and clears the minimum travel distance', () => {
|
||||
const wide = ledge(0, 1000)
|
||||
const from = 500
|
||||
const x = pickStrollTarget(wide, from, seq(0.5, 0))
|
||||
|
||||
expect(x).toBeGreaterThanOrEqual(wide.left)
|
||||
expect(x).toBeLessThanOrEqual(wide.right)
|
||||
expect(Math.abs(x - from)).toBeGreaterThanOrEqual(110) // STROLL_MIN_PX
|
||||
})
|
||||
|
||||
it('heads toward the side with more room', () => {
|
||||
// Pinned near the right wall, the roomier side is left. First draw clears the
|
||||
// rare double-back coin ⇒ it commits to the roomy (left) side ⇒ target < x.
|
||||
const x = pickStrollTarget(ledge(0, 1000), 950, seq(0.5, 0))
|
||||
expect(x).toBeLessThan(950)
|
||||
})
|
||||
})
|
||||
@@ -1,97 +0,0 @@
|
||||
/**
|
||||
* Pure decision helpers for the floating pet's wander — the "what to do & when"
|
||||
* layer, split out from the geometry (`roam-geometry.ts`) and the RAF/DOM loop
|
||||
* (`use-pet-roam.ts`) so the *rhythm* of the roam is tunable in one place and
|
||||
* unit testable (every function takes an injectable `rng`).
|
||||
*
|
||||
* The goal is a calm, believable critter rather than a fidgeting one. Two ideas
|
||||
* from ambient game-AI carry the weight (see GameAIPro ch.36 "Breathing Life
|
||||
* into Your Background Characters" + standard idle/wander state machines):
|
||||
*
|
||||
* 1. **Loaf, don't pace.** A background character that picks a new walk on
|
||||
* every beat reads as nervous. Most decision beats just keep resting;
|
||||
* movement is the exception, not the default (`REST_CHANCE`).
|
||||
* 2. **Memoryless dwell times.** Uniform pauses feel metronomic. An
|
||||
* exponential dwell — the classic model for idle durations — gives mostly
|
||||
* short rests with the occasional long loaf, so the cadence never reads as a
|
||||
* fixed pattern (`dwellMs` / `PAUSE_DWELL`).
|
||||
*/
|
||||
|
||||
import type { Ledge } from './roam-geometry'
|
||||
|
||||
export type Rng = () => number
|
||||
|
||||
/** What the pet does when a rest beat ends. */
|
||||
export type RoamMove = 'rest' | 'stroll' | 'hop'
|
||||
|
||||
export interface DwellRange {
|
||||
/** Mean of the exponential draw — the "typical" rest length. */
|
||||
meanMs: number
|
||||
/** Floor, so a near-zero draw never produces a jittery micro-pause. */
|
||||
minMs: number
|
||||
/** Ceiling, so a fat-tail draw (or a throttled tab) can't freeze the pet. */
|
||||
maxMs: number
|
||||
}
|
||||
|
||||
// Rest length between beats: mostly short loafs, the occasional long one.
|
||||
export const PAUSE_DWELL: DwellRange = { maxMs: 13000, meanMs: 4200, minMs: 1500 }
|
||||
// Most beats the pet just keeps loafing — a critter that re-walks every beat
|
||||
// reads as nervous, not alive.
|
||||
export const REST_CHANCE = 0.62
|
||||
// When it *does* move, chance it hops to another ledge vs. strolling this one.
|
||||
export const HOP_CHANCE = 0.2
|
||||
// Strolls should cover ground, not shuffle: travel at least this fraction of the
|
||||
// ledge (or this many px, whichever is larger), up to the room available.
|
||||
const STROLL_MIN_FRACTION = 0.45
|
||||
const STROLL_MIN_PX = 110
|
||||
// Bias toward the roomier side so the pet crosses the app instead of pacing one
|
||||
// spot; the long tail of the coin still lets it double back now and then.
|
||||
const STROLL_TOWARD_ROOM = 0.85
|
||||
|
||||
/**
|
||||
* Exponential (memoryless) dwell time, clamped to `[minMs, maxMs]`. With rng→0
|
||||
* this returns `minMs`; with rng→1 it saturates at `maxMs`; in between it's
|
||||
* `-ln(u)·meanMs`, so short rests dominate and long loafs are rare but possible.
|
||||
*/
|
||||
export function dwellMs({ meanMs, minMs, maxMs }: DwellRange, rng: Rng = Math.random): number {
|
||||
const u = 1 - rng() // map [0,1) → (0,1] so the log stays finite
|
||||
|
||||
return Math.min(maxMs, Math.max(minMs, -Math.log(u) * meanMs))
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide a beat: rest (the common case), or — when the pet is actually going to
|
||||
* move — hop to a reachable ledge if one exists and the dice say so, else stroll
|
||||
* the current ledge. `canHop` is false when no neighbouring surface overlaps, so
|
||||
* the pet never "hops" in place.
|
||||
*/
|
||||
export function chooseMove(canHop: boolean, rng: Rng = Math.random): RoamMove {
|
||||
if (rng() < REST_CHANCE) {
|
||||
return 'rest'
|
||||
}
|
||||
|
||||
return canHop && rng() < HOP_CHANCE ? 'hop' : 'stroll'
|
||||
}
|
||||
|
||||
/**
|
||||
* A stroll destination (absolute x) on `ledge` that actually goes somewhere:
|
||||
* lean toward the side with more room and guarantee a decent minimum travel, so
|
||||
* the pet crosses the app rather than shuffling in place.
|
||||
*/
|
||||
export function pickStrollTarget(ledge: Ledge, fromX: number, rng: Rng = Math.random): number {
|
||||
const span = ledge.right - ledge.left
|
||||
|
||||
if (span <= 4) {
|
||||
return ledge.left
|
||||
}
|
||||
|
||||
const roomLeft = fromX - ledge.left
|
||||
const roomRight = ledge.right - fromX
|
||||
// Usually head to the roomier side; the long tail of the coin doubles back.
|
||||
const goRight = (rng() < STROLL_TOWARD_ROOM) === (roomRight >= roomLeft)
|
||||
const room = Math.max(0, goRight ? roomRight : roomLeft)
|
||||
const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX))
|
||||
const dist = minDist + rng() * Math.max(0, room - minDist)
|
||||
|
||||
return goRight ? fromX + dist : fromX - dist
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { GROUND_EPS, groundTop, type Ledge, overlapsX, resolveLedge } from './roam-geometry'
|
||||
|
||||
const ledge = (y: number, left = 0, right = 1000): Ledge => ({ left, right, y })
|
||||
|
||||
describe('groundTop', () => {
|
||||
it('sinks the feet by the padding offset so they meet the surface', () => {
|
||||
// y - petH + FEET_DROP_PX(4)
|
||||
expect(groundTop(ledge(500), 100)).toBe(404)
|
||||
})
|
||||
})
|
||||
|
||||
describe('overlapsX', () => {
|
||||
it('is true only when the walkable ranges share real width', () => {
|
||||
expect(overlapsX(ledge(0, 0, 100), ledge(0, 50, 200))).toBe(true)
|
||||
expect(overlapsX(ledge(0, 0, 100), ledge(0, 100, 200))).toBe(false) // touching, not overlapping
|
||||
expect(overlapsX(ledge(0, 0, 100), ledge(0, 300, 400))).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('resolveLedge', () => {
|
||||
const floor = ledge(600)
|
||||
const shelf = ledge(300, 100, 400)
|
||||
|
||||
it('returns the highest surface at or below the feet under the current x', () => {
|
||||
// Standing on the shelf line, under the shelf's x-span ⇒ the shelf.
|
||||
const petH = 100
|
||||
const onShelf = resolveLedge([floor, shelf], 200, shelf.y - petH, petH)
|
||||
expect(onShelf).toBe(shelf)
|
||||
})
|
||||
|
||||
it('ignores surfaces the pet is not horizontally over', () => {
|
||||
const petH = 100
|
||||
// x=800 is past the shelf ⇒ only the floor qualifies.
|
||||
const onFloor = resolveLedge([floor, shelf], 800, floor.y - petH, petH)
|
||||
expect(onFloor).toBe(floor)
|
||||
})
|
||||
|
||||
it('falls back to the floor (ledges[0]) when below everything', () => {
|
||||
const petH = 100
|
||||
const below = resolveLedge([floor, shelf], 200, 5000, petH)
|
||||
expect(below).toBe(floor)
|
||||
})
|
||||
|
||||
it('counts a surface within GROUND_EPS of the feet as standing on it', () => {
|
||||
const petH = 100
|
||||
const justAbove = resolveLedge([floor], 10, floor.y - petH - GROUND_EPS + 0.5, petH)
|
||||
expect(justAbove).toBe(floor)
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
/**
|
||||
* The "where can it stand" layer of the floating pet's wander: it measures the
|
||||
* live DOM for walkable surfaces and answers pure questions about them. Split
|
||||
* from the decision logic (`roam-behavior.ts`) and the RAF/DOM loop
|
||||
* (`use-pet-roam.ts`) so the loop reads as physics, not geometry, and the pure
|
||||
* helpers (`overlapsX`, `resolveLedge`, `groundTop`) stay unit testable.
|
||||
*/
|
||||
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
|
||||
/**
|
||||
* A horizontal surface the pet can stand and walk on. `y` is the surface line
|
||||
* (where the pet's feet rest); `left`/`right` bound the pet's top-left x so the
|
||||
* whole sprite stays on the ledge.
|
||||
*/
|
||||
export interface Ledge {
|
||||
y: number
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
|
||||
// Elements the pet can perch on top of, measured fresh each beat. The bottom
|
||||
// floor is always a ledge; these add app furniture the pet can climb onto (the
|
||||
// composer, the profile rail). Add a `data-slot` here to grow the playground.
|
||||
const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile-rail"]']
|
||||
|
||||
// A full-width bar pinned to the window bottom (the status bar). When present,
|
||||
// the pet walks along its TOP edge instead of the window edge, so it stands on
|
||||
// the bar rather than covering it.
|
||||
const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]'
|
||||
|
||||
// Sprites carry a few px of transparent padding below the feet; sink the pet by
|
||||
// this much so the visible feet meet the surface instead of hovering above it.
|
||||
const FEET_DROP_PX = 4
|
||||
// Snap distance: how close the feet must be to count as "on this ledge".
|
||||
export const GROUND_EPS = 2
|
||||
|
||||
const vw = (): number => window.innerWidth || 800
|
||||
const vh = (): number => window.innerHeight || 600
|
||||
|
||||
/** The y a pet of height `petH` rests at when standing on `ledge`. */
|
||||
export const groundTop = (ledge: Ledge, petH: number): number => ledge.y - petH + FEET_DROP_PX
|
||||
|
||||
/**
|
||||
* Do the pet's walkable x-ranges on two ledges overlap enough to step across?
|
||||
* (Pure — the wander uses it to find hop-reachable neighbours.)
|
||||
*/
|
||||
export const overlapsX = (from: Ledge, to: Ledge): boolean =>
|
||||
Math.min(from.right, to.right) > Math.max(from.left, to.left) + 2
|
||||
|
||||
/**
|
||||
* The highest surface at or below the pet's feet under its current x — i.e. what
|
||||
* it's standing on, or what it would fall onto. Pure; falls back to the floor
|
||||
* (always `ledges[0]`) if the pet is somehow below everything.
|
||||
*/
|
||||
export function resolveLedge(ledges: Ledge[], x: number, y: number, petH: number): Ledge {
|
||||
const bottom = y + petH
|
||||
let best: Ledge | null = null
|
||||
|
||||
for (const ledge of ledges) {
|
||||
if (x < ledge.left - 2 || x > ledge.right + 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (ledge.y >= bottom - GROUND_EPS && (!best || ledge.y < best.y)) {
|
||||
best = ledge
|
||||
}
|
||||
}
|
||||
|
||||
return best ?? ledges[0]!
|
||||
}
|
||||
|
||||
/** The bottom ground line: the top of the status bar if it's pinned full-width
|
||||
* across the window bottom, otherwise the window edge. */
|
||||
function floorY(width: number, height: number, petH: number): number {
|
||||
const bar = document.querySelector(FLOOR_BAR_SELECTOR)
|
||||
|
||||
if (bar) {
|
||||
const rect = bar.getBoundingClientRect()
|
||||
|
||||
if (rect.width >= width * 0.5 && height - rect.bottom < 4 && rect.top - petH >= 0) {
|
||||
return rect.top
|
||||
}
|
||||
}
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
/** Snapshot the walkable surfaces right now: the bottom floor plus any on-screen
|
||||
* perch element with room above it for the pet to stand. */
|
||||
export function snapshotLedges(petW: number, petH: number): Ledge[] {
|
||||
const width = vw()
|
||||
const height = vh()
|
||||
const ledges: Ledge[] = [{ left: 0, right: Math.max(0, width - petW), y: floorY(width, height, petH) }]
|
||||
|
||||
for (const selector of PERCH_SELECTORS) {
|
||||
const el = document.querySelector(selector)
|
||||
|
||||
if (!el) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const left = Math.max(0, rect.left)
|
||||
const right = Math.min(width - petW, rect.right - petW)
|
||||
|
||||
// Skip surfaces that are too narrow for the pet, have no headroom above, or
|
||||
// sit off-screen / flush with the floor (no daylight between them).
|
||||
if (right <= left + 2 || rect.top - petH < 0 || rect.top > height - 8 || height - rect.top < 12) {
|
||||
continue
|
||||
}
|
||||
|
||||
ledges.push({ left, right, y: rect.top })
|
||||
}
|
||||
|
||||
return ledges
|
||||
}
|
||||
|
||||
/**
|
||||
* While a full-screen route overlay is up it's the only walkable surface: a
|
||||
* single ledge at the overlay card's bottom inner edge. The card uses
|
||||
* `OverlayView`'s equal inset on every side — `titlebar-height + padding` — so
|
||||
* we derive it from that rather than measuring.
|
||||
*/
|
||||
export function overlayLedge(petW: number): Ledge {
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||
const inset = TITLEBAR_HEIGHT + (vw() >= 640 ? 0.875 : 0.625) * rem
|
||||
|
||||
return { left: inset, right: Math.max(0, vw() - inset - petW), y: vh() - inset }
|
||||
}
|
||||
@@ -1,15 +1,34 @@
|
||||
import { type RefObject, useEffect } from 'react'
|
||||
|
||||
import { TITLEBAR_HEIGHT } from '@/app/shell/titlebar'
|
||||
import { $petMotion, $petRoamDir, type PetState } from '@/store/pet'
|
||||
|
||||
import { chooseMove, dwellMs, PAUSE_DWELL, pickStrollTarget } from './roam-behavior'
|
||||
import { GROUND_EPS, groundTop, type Ledge, overlapsX, overlayLedge, resolveLedge, snapshotLedges } from './roam-geometry'
|
||||
|
||||
interface Point {
|
||||
x: number
|
||||
y: number
|
||||
}
|
||||
|
||||
/**
|
||||
* A horizontal surface the pet can stand and walk on. `y` is the surface line
|
||||
* (where the pet's feet rest); `left`/`right` bound the pet's top-left x so the
|
||||
* whole sprite stays on the ledge.
|
||||
*/
|
||||
interface Ledge {
|
||||
y: number
|
||||
left: number
|
||||
right: number
|
||||
}
|
||||
|
||||
// Elements the pet can perch on top of, measured fresh each beat. The bottom
|
||||
// floor is always a ledge; these add app furniture the pet can climb onto (the
|
||||
// composer, the profile rail). Add a `data-slot` here to grow the playground.
|
||||
const PERCH_SELECTORS = ['[data-slot="composer-surface"]', '[data-slot="profile-rail"]']
|
||||
|
||||
// A full-width bar pinned to the window bottom (the status bar). When present,
|
||||
// the pet walks along its TOP edge instead of the window edge, so it stands on
|
||||
// the bar rather than covering it.
|
||||
const FLOOR_BAR_SELECTOR = '[data-slot="statusbar"]'
|
||||
|
||||
// Foot-sync: advance this many body-widths per animation loop so the walk reads
|
||||
// as steps, not a glide. Actual px/s is derived from the sprite's loop duration
|
||||
// and on-screen size (see `walkSpeedPxS`).
|
||||
@@ -18,10 +37,22 @@ const STRIDE_PER_LOOP = 0.8
|
||||
const GRAVITY_PX_S2 = 5200
|
||||
// Time to spring up onto a higher ledge.
|
||||
const JUMP_DUR_MS = 460
|
||||
const PAUSE_MIN_MS = 1800
|
||||
const PAUSE_MAX_MS = 5200
|
||||
// Tiny settle after a drag release before the pet re-plans (and usually falls),
|
||||
// so dropping it in mid-air snaps down promptly instead of hanging for a beat.
|
||||
const DROP_SETTLE_MS = 90
|
||||
// Arrived at a walk target.
|
||||
// Chance a beat hops to another ledge instead of strolling the current one.
|
||||
const HOP_CHANCE = 0.45
|
||||
// Strolls should cover ground, not shuffle: travel at least this fraction of the
|
||||
// ledge (or this many px, whichever is larger), up to the room available.
|
||||
const STROLL_MIN_FRACTION = 0.45
|
||||
const STROLL_MIN_PX = 110
|
||||
// Sprites carry a few px of transparent padding below the feet; sink the pet by
|
||||
// this much so the visible feet meet the surface instead of hovering above it.
|
||||
const FEET_DROP_PX = 4
|
||||
// Snap distances: "on this ledge" / arrived at a walk target.
|
||||
const GROUND_EPS = 2
|
||||
const ARRIVE_EPS = 1.5
|
||||
// Cap dt so a backgrounded/throttled tab can't teleport the pet on resume.
|
||||
const MAX_DT_S = 0.05
|
||||
@@ -32,6 +63,60 @@ const rand = (min: number, max: number): number => min + Math.random() * (max -
|
||||
const easeOutCubic = (t: number): number => 1 - (1 - t) ** 3
|
||||
const signDir = (n: number): -1 | 0 | 1 => (n > 0 ? 1 : n < 0 ? -1 : 0)
|
||||
|
||||
function vw(): number {
|
||||
return window.innerWidth || 800
|
||||
}
|
||||
|
||||
function vh(): number {
|
||||
return window.innerHeight || 600
|
||||
}
|
||||
|
||||
/** The bottom ground line: the top of the status bar if it's pinned full-width
|
||||
* across the window bottom, otherwise the window edge. */
|
||||
function floorY(width: number, height: number, petH: number): number {
|
||||
const bar = document.querySelector(FLOOR_BAR_SELECTOR)
|
||||
|
||||
if (bar) {
|
||||
const rect = bar.getBoundingClientRect()
|
||||
|
||||
if (rect.width >= width * 0.5 && height - rect.bottom < 4 && rect.top - petH >= 0) {
|
||||
return rect.top
|
||||
}
|
||||
}
|
||||
|
||||
return height
|
||||
}
|
||||
|
||||
/** Snapshot the walkable surfaces right now: the bottom floor plus any on-screen
|
||||
* perch element with room above it for the pet to stand. */
|
||||
function snapshotLedges(petW: number, petH: number): Ledge[] {
|
||||
const width = vw()
|
||||
const height = vh()
|
||||
const ledges: Ledge[] = [{ left: 0, right: Math.max(0, width - petW), y: floorY(width, height, petH) }]
|
||||
|
||||
for (const selector of PERCH_SELECTORS) {
|
||||
const el = document.querySelector(selector)
|
||||
|
||||
if (!el) {
|
||||
continue
|
||||
}
|
||||
|
||||
const rect = el.getBoundingClientRect()
|
||||
const left = Math.max(0, rect.left)
|
||||
const right = Math.min(width - petW, rect.right - petW)
|
||||
|
||||
// Skip surfaces that are too narrow for the pet, have no headroom above, or
|
||||
// sit off-screen / flush with the floor (no daylight between them).
|
||||
if (right <= left + 2 || rect.top - petH < 0 || rect.top > height - 8 || height - rect.top < 12) {
|
||||
continue
|
||||
}
|
||||
|
||||
ledges.push({ left, right, y: rect.top })
|
||||
}
|
||||
|
||||
return ledges
|
||||
}
|
||||
|
||||
interface PetRoamOptions {
|
||||
/** Run the wander loop (roam opt-in + pet active + in-window + agent at rest). */
|
||||
enabled: boolean
|
||||
@@ -49,13 +134,13 @@ interface PetRoamOptions {
|
||||
}
|
||||
|
||||
/**
|
||||
* Drive the floating pet's wander as a platformer state machine: it loafs, then
|
||||
* walks along surfaces (the window floor, the top of the composer, …), hops up
|
||||
* onto higher ledges, and drops off them — instead of drifting through empty
|
||||
* space. The walkable surfaces come from `roam-geometry` (re-measured from the
|
||||
* live DOM every beat, so the pet tracks the composer growing, the sidebar
|
||||
* opening, the window resizing) and the beat-to-beat choices from
|
||||
* `roam-behavior`; this hook owns only the physics and the DOM writes.
|
||||
* Make the floating pet wander the app like a platformer character: it walks
|
||||
* along surfaces (the window floor, the top of the composer, …), hops up onto
|
||||
* higher ledges, and drops off them — instead of drifting diagonally through
|
||||
* empty space. Surfaces are re-measured from the live DOM at the start of every
|
||||
* beat (`snapshotLedges`), so the pet tracks the composer growing, the sidebar
|
||||
* opening, the window resizing, and even falls back to the floor when its perch
|
||||
* disappears.
|
||||
*
|
||||
* Movement mutates `el.style.left/top` directly each frame — like the drag
|
||||
* handler — so a steady wander triggers no React re-renders, and because it
|
||||
@@ -95,7 +180,28 @@ export function usePetRoam({
|
||||
|
||||
// Pace the stride to the sprite: one body-width per animation loop.
|
||||
const walkSpeedPxS = (petW * STRIDE_PER_LOOP) / (loopMs / 1000)
|
||||
const restY = (ledge: Ledge): number => groundTop(ledge, petH)
|
||||
|
||||
const groundTop = (ledge: Ledge): number => ledge.y - petH + FEET_DROP_PX
|
||||
|
||||
// A stroll destination on `ledge` that actually goes somewhere: lean toward
|
||||
// the side with more room (so the pet crosses the app rather than shuffling
|
||||
// in place) and guarantee a decent minimum travel distance.
|
||||
const pickStrollTarget = (ledge: Ledge): number => {
|
||||
const span = ledge.right - ledge.left
|
||||
|
||||
if (span <= 4) {
|
||||
return ledge.left
|
||||
}
|
||||
|
||||
const roomLeft = cur.x - ledge.left
|
||||
const roomRight = ledge.right - cur.x
|
||||
const goRight = roomRight >= roomLeft ? Math.random() < 0.85 : Math.random() < 0.15
|
||||
const room = Math.max(0, goRight ? roomRight : roomLeft)
|
||||
const minDist = Math.min(room, Math.max(span * STROLL_MIN_FRACTION, STROLL_MIN_PX))
|
||||
const dist = minDist + Math.random() * Math.max(0, room - minDist)
|
||||
|
||||
return goRight ? cur.x + dist : cur.x - dist
|
||||
}
|
||||
|
||||
// Seed from the live DOM rect so we resume from wherever the pet actually is
|
||||
// (after a drag, reclamp, or activity pause) rather than a stale closure.
|
||||
@@ -131,14 +237,14 @@ export function usePetRoam({
|
||||
|
||||
const beginPause = (now: number) => {
|
||||
phase = 'pause'
|
||||
pauseUntil = now + dwellMs(PAUSE_DWELL)
|
||||
pauseUntil = now + rand(PAUSE_MIN_MS, PAUSE_MAX_MS)
|
||||
signal(null, 0)
|
||||
commit({ ...cur })
|
||||
}
|
||||
|
||||
// Land flush on a ledge, then settle into the next idle beat.
|
||||
const settleOn = (ledge: Ledge, now: number) => {
|
||||
cur.y = restY(ledge)
|
||||
cur.y = groundTop(ledge)
|
||||
curLedge = ledge
|
||||
applyDom()
|
||||
beginPause(now)
|
||||
@@ -147,7 +253,7 @@ export function usePetRoam({
|
||||
const beginVertical = (ledge: Ledge) => {
|
||||
targetLedge = ledge
|
||||
|
||||
if (restY(ledge) < cur.y - 1) {
|
||||
if (groundTop(ledge) < cur.y - 1) {
|
||||
// Up onto a higher ledge: a quick spring.
|
||||
phase = 'jump'
|
||||
jumpFromY = cur.y
|
||||
@@ -161,14 +267,51 @@ export function usePetRoam({
|
||||
signal('jump', 0)
|
||||
}
|
||||
|
||||
const planNext = (now: number) => {
|
||||
// Does the pet, standing at cur.x, have a stretch of `to` it can step across
|
||||
// to from `from` (their walkable x-ranges overlap)?
|
||||
const overlapsX = (from: Ledge, to: Ledge): boolean =>
|
||||
Math.min(from.right, to.right) > Math.max(from.left, to.left) + 2
|
||||
|
||||
// Find the highest surface at or below the pet's feet under its current x —
|
||||
// i.e. what it's standing on, or what it would fall onto.
|
||||
const resolveLedge = (ledges: Ledge[]): Ledge => {
|
||||
const bottom = cur.y + petH
|
||||
let best: Ledge | null = null
|
||||
|
||||
for (const ledge of ledges) {
|
||||
if (cur.x < ledge.left - 2 || cur.x > ledge.right + 2) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (ledge.y >= bottom - GROUND_EPS && (!best || ledge.y < best.y)) {
|
||||
best = ledge
|
||||
}
|
||||
}
|
||||
|
||||
// Floor always spans the clamped x-range, so this only falls back if the
|
||||
// pet is somehow below everything — drop it to the floor.
|
||||
return best ?? ledges[0]!
|
||||
}
|
||||
|
||||
// While an overlay is up, it's the only walkable surface: a single ledge at
|
||||
// the overlay card's bottom inner edge. The card uses `OverlayView`'s equal
|
||||
// inset on every side — `titlebar-height + padding` — so derive it from that
|
||||
// (never measured).
|
||||
const overlayLedge = (): Ledge => {
|
||||
const rem = parseFloat(getComputedStyle(document.documentElement).fontSize) || 16
|
||||
const inset = TITLEBAR_HEIGHT + (vw() >= 640 ? 0.875 : 0.625) * rem
|
||||
|
||||
return { left: inset, right: Math.max(0, vw() - inset - petW), y: vh() - inset }
|
||||
}
|
||||
|
||||
const planNext = () => {
|
||||
// An open overlay swaps the surface set to just its bottom edge, so the pet
|
||||
// patrols along it; closing it restores the normal surfaces (and the pet
|
||||
// drops to whatever's below).
|
||||
const ledges = overlayOpen ? [overlayLedge(petW)] : snapshotLedges(petW, petH)
|
||||
curLedge = resolveLedge(ledges, cur.x, cur.y, petH)
|
||||
const ledges = overlayOpen ? [overlayLedge()] : snapshotLedges(petW, petH)
|
||||
curLedge = resolveLedge(ledges)
|
||||
|
||||
if (Math.abs(cur.y - restY(curLedge)) > GROUND_EPS) {
|
||||
if (Math.abs(cur.y - groundTop(curLedge)) > GROUND_EPS) {
|
||||
// Dragged into the air, or the surface moved out from under it: fall.
|
||||
beginVertical(curLedge)
|
||||
|
||||
@@ -176,16 +319,8 @@ export function usePetRoam({
|
||||
}
|
||||
|
||||
const reachable = ledges.filter(ledge => ledge !== curLedge && overlapsX(curLedge!, ledge))
|
||||
const move = chooseMove(reachable.length > 0)
|
||||
|
||||
if (move === 'rest') {
|
||||
// Stay put and loaf another beat — movement is the exception.
|
||||
beginPause(now)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (move === 'hop') {
|
||||
if (reachable.length > 0 && Math.random() < HOP_CHANCE) {
|
||||
const next = reachable[Math.floor(Math.random() * reachable.length)]!
|
||||
const lo = Math.max(curLedge.left, next.left)
|
||||
const hi = Math.min(curLedge.right, next.right)
|
||||
@@ -193,7 +328,7 @@ export function usePetRoam({
|
||||
walkTargetX = lo + Math.random() * (hi - lo)
|
||||
} else {
|
||||
pendingHop = null
|
||||
walkTargetX = pickStrollTarget(curLedge, cur.x)
|
||||
walkTargetX = pickStrollTarget(curLedge)
|
||||
}
|
||||
|
||||
phase = 'walk'
|
||||
@@ -223,7 +358,7 @@ export function usePetRoam({
|
||||
switch (phase) {
|
||||
case 'pause': {
|
||||
if (now >= pauseUntil) {
|
||||
planNext(now)
|
||||
planNext()
|
||||
}
|
||||
|
||||
break
|
||||
@@ -262,7 +397,7 @@ export function usePetRoam({
|
||||
fallVel += GRAVITY_PX_S2 * dt
|
||||
cur.y += fallVel * dt
|
||||
|
||||
if (cur.y >= restY(targetLedge)) {
|
||||
if (cur.y >= groundTop(targetLedge)) {
|
||||
settleOn(targetLedge, now)
|
||||
} else {
|
||||
applyDom()
|
||||
@@ -280,7 +415,7 @@ export function usePetRoam({
|
||||
|
||||
jumpElapsed += dt * 1000
|
||||
const t = Math.min(1, jumpElapsed / JUMP_DUR_MS)
|
||||
cur.y = jumpFromY + (restY(targetLedge) - jumpFromY) * easeOutCubic(t)
|
||||
cur.y = jumpFromY + (groundTop(targetLedge) - jumpFromY) * easeOutCubic(t)
|
||||
|
||||
if (t >= 1) {
|
||||
settleOn(targetLedge, now)
|
||||
|
||||
Vendored
+73
-2
@@ -55,6 +55,15 @@ declare global {
|
||||
probeConnectionConfig: (remoteUrl: string) => Promise<DesktopConnectionProbeResult>
|
||||
oauthLoginConnectionConfig: (remoteUrl: string) => Promise<DesktopOauthLoginResult>
|
||||
oauthLogoutConnectionConfig: (remoteUrl?: string) => Promise<DesktopOauthLogoutResult>
|
||||
// Hermes Cloud: one portal login powers discovery + silent per-agent
|
||||
// sign-in (cloud-auto-discovery Phase 3).
|
||||
cloud: {
|
||||
status: () => Promise<DesktopCloudStatus>
|
||||
login: () => Promise<DesktopCloudStatus & { ok: boolean }>
|
||||
logout: () => Promise<DesktopCloudStatus & { ok: boolean }>
|
||||
discover: (org?: string) => Promise<DesktopCloudDiscoverResult>
|
||||
agentSignIn: (dashboardUrl: string) => Promise<DesktopCloudAgentSignInResult>
|
||||
}
|
||||
profile: {
|
||||
get: () => Promise<DesktopActiveProfile>
|
||||
// Persists the desktop's profile choice and relaunches the local
|
||||
@@ -354,6 +363,9 @@ export interface DesktopUpdateProgress {
|
||||
export interface HermesConnection {
|
||||
baseUrl: string
|
||||
isFullscreen: boolean
|
||||
// The live, RESOLVED connection mode. Only ever 'local' or 'remote' — a
|
||||
// 'cloud' saved-config entry resolves to a 'remote' connection under the hood
|
||||
// (cloud-auto-discovery Q3/Q6), so this never carries 'cloud'.
|
||||
mode?: 'local' | 'remote'
|
||||
authMode?: 'oauth' | 'token'
|
||||
nativeOverlayWidth: number
|
||||
@@ -386,7 +398,12 @@ export interface DesktopActiveProfile {
|
||||
|
||||
export interface DesktopConnectionConfig {
|
||||
envOverride: boolean
|
||||
mode: 'local' | 'remote'
|
||||
// The saved connection mode. 'cloud' is a Hermes Cloud connection: it carries
|
||||
// a remote-shaped block (remoteUrl = the selected agent's dashboardUrl,
|
||||
// remoteAuthMode 'oauth') but is remembered as cloud so settings reopens into
|
||||
// the cloud picker. Resolution treats cloud exactly as remote
|
||||
// (cloud-auto-discovery Q3/Q6).
|
||||
mode: 'local' | 'remote' | 'cloud'
|
||||
// The profile this config describes, or null for the global/default
|
||||
// connection. Per-profile entries let a profile point at its own backend.
|
||||
profile: null | string
|
||||
@@ -395,16 +412,23 @@ export interface DesktopConnectionConfig {
|
||||
remoteTokenPreview: string | null
|
||||
remoteTokenSet: boolean
|
||||
remoteUrl: string
|
||||
// For a 'cloud' connection: the persisted Hermes Cloud org (slug or id) the
|
||||
// connected instance was discovered under, so Settings → Gateway can reopen
|
||||
// into that org. Empty string for remote/local.
|
||||
cloudOrg: string
|
||||
}
|
||||
|
||||
export interface DesktopConnectionConfigInput {
|
||||
mode: 'local' | 'remote'
|
||||
mode: 'local' | 'remote' | 'cloud'
|
||||
// When set, the save/apply/test targets this profile's per-profile remote
|
||||
// override instead of the global connection.
|
||||
profile?: null | string
|
||||
remoteAuthMode?: 'oauth' | 'token'
|
||||
remoteToken?: string
|
||||
remoteUrl?: string
|
||||
// For a 'cloud' connection: the selected Hermes Cloud org (slug or id) to
|
||||
// persist so Settings can reopen into it. Ignored for remote/local modes.
|
||||
cloudOrg?: string
|
||||
}
|
||||
|
||||
export interface DesktopConnectionTestResult {
|
||||
@@ -443,6 +467,53 @@ export interface DesktopOauthLogoutResult {
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
// --- Hermes Cloud (cloud-auto-discovery Phase 3) ---
|
||||
|
||||
export interface DesktopCloudStatus {
|
||||
// The portal base URL the desktop talks to (default or env-overridden).
|
||||
portalBaseUrl: string
|
||||
// Whether the OAuth partition holds a live portal session (AT-or-RT).
|
||||
signedIn: boolean
|
||||
}
|
||||
|
||||
// A discovered Hermes Cloud agent — the trimmed DTO from NAS GET /api/agents.
|
||||
export interface DesktopCloudAgent {
|
||||
id: string
|
||||
name: string
|
||||
status: string
|
||||
// null until the agent has a provisioned dashboard (show "provisioning…").
|
||||
dashboardUrl: string | null
|
||||
// "active" | "degraded" | "down" | "unknown".
|
||||
dashboardGatewayState: string
|
||||
}
|
||||
|
||||
// An org the signed-in user belongs to — for the org picker shown when a
|
||||
// multi-org user's discovery call needs disambiguation (NAS 409).
|
||||
export interface DesktopCloudOrg {
|
||||
id: string
|
||||
slug: string | null
|
||||
name: string
|
||||
isPersonal: boolean
|
||||
// "OWNER" | "MEMBER".
|
||||
role: string
|
||||
}
|
||||
|
||||
// Discovery result: either the agent list, OR a request to pick an org first
|
||||
// (multi-org user, no org chosen yet). The renderer shows a picker on the
|
||||
// latter and re-calls discover(org). On the agents branch, `org` echoes the
|
||||
// authoritatively-resolved org the list was scoped to (from NAS), so the
|
||||
// desktop persists it without relying on transient picker state.
|
||||
export type DesktopCloudDiscoverResult =
|
||||
| { agents: DesktopCloudAgent[]; org?: DesktopCloudOrg | null; needsOrgSelection?: false }
|
||||
| { needsOrgSelection: true; orgs: DesktopCloudOrg[] }
|
||||
|
||||
export interface DesktopCloudAgentSignInResult {
|
||||
// The agent gateway base URL the silent sign-in targeted.
|
||||
baseUrl: string
|
||||
// Whether the agent's gateway session cookie landed (silent cascade done).
|
||||
connected: boolean
|
||||
}
|
||||
|
||||
export interface DesktopBootProgress {
|
||||
error: string | null
|
||||
fakeMode: boolean
|
||||
|
||||
@@ -530,6 +530,38 @@ export const en: Translations = {
|
||||
remoteTitle: 'Remote gateway',
|
||||
remoteDesc:
|
||||
'Connect this desktop shell to a remote Hermes backend. Hosted gateways use OAuth or a username and password; self-hosted ones may use a session token.',
|
||||
cloudTitle: 'Hermes Cloud',
|
||||
cloudDesc:
|
||||
'Sign in once to Hermes Cloud and pick from the agents on your account — no URL to paste. Connects to the one you choose.',
|
||||
cloudSignInTitle: 'Hermes Cloud',
|
||||
cloudSignIn: 'Sign in to Hermes Cloud',
|
||||
cloudSignedIn: 'Signed in to Hermes Cloud',
|
||||
cloudNeedsSignIn: 'Sign in to Hermes Cloud to discover the agents on your account.',
|
||||
cloudSignedInDesc: 'You are signed in. Pick an agent below; the session refreshes automatically.',
|
||||
cloudAgentsTitle: 'Your agents',
|
||||
cloudOrgPickerTitle: 'Choose an organization',
|
||||
cloudOrgSelect: 'Select',
|
||||
cloudOrgChange: 'Change org',
|
||||
cloudOrgRole: role => `Role: ${role}`,
|
||||
cloudLoadingAgents: 'Loading your agents…',
|
||||
cloudNoAgents: {
|
||||
before: 'No agents found on this account. Create one in the ',
|
||||
linkText: 'Nous portal',
|
||||
after: ', then refresh.'
|
||||
},
|
||||
cloudRefresh: 'Refresh',
|
||||
cloudConnect: 'Connect',
|
||||
cloudConnecting: 'Connecting…',
|
||||
cloudDiscoverFailed: 'Could not load your Hermes Cloud agents',
|
||||
cloudConnectFailed: 'Could not connect to that agent',
|
||||
cloudSignInFailed: 'Hermes Cloud sign-in failed',
|
||||
cloudSignedOutTitle: 'Signed out of Hermes Cloud',
|
||||
cloudSignedOutMessage: 'Cleared the Hermes Cloud session.',
|
||||
cloudConnectedTitle: 'Connected',
|
||||
cloudConnectedPill: 'Connected',
|
||||
cloudConnectedTo: name => `Connected to ${name}.`,
|
||||
cloudAgentProvisioning: 'Provisioning…',
|
||||
cloudStatusLabel: status => `Status: ${status}`,
|
||||
remoteUrlTitle: 'Remote URL',
|
||||
remoteUrlDesc: 'Base URL for the remote dashboard backend. Path prefixes are supported, for example /hermes.',
|
||||
probing: 'Checking how this gateway authenticates…',
|
||||
|
||||
@@ -442,6 +442,33 @@ export interface Translations {
|
||||
localDesc: string
|
||||
remoteTitle: string
|
||||
remoteDesc: string
|
||||
cloudTitle: string
|
||||
cloudDesc: string
|
||||
cloudSignInTitle: string
|
||||
cloudSignIn: string
|
||||
cloudSignedIn: string
|
||||
cloudNeedsSignIn: string
|
||||
cloudSignedInDesc: string
|
||||
cloudAgentsTitle: string
|
||||
cloudOrgPickerTitle: string
|
||||
cloudOrgSelect: string
|
||||
cloudOrgChange: string
|
||||
cloudOrgRole: (role: string) => string
|
||||
cloudLoadingAgents: string
|
||||
cloudNoAgents: { before: string; linkText: string; after: string }
|
||||
cloudRefresh: string
|
||||
cloudConnect: string
|
||||
cloudConnecting: string
|
||||
cloudDiscoverFailed: string
|
||||
cloudConnectFailed: string
|
||||
cloudSignInFailed: string
|
||||
cloudSignedOutTitle: string
|
||||
cloudSignedOutMessage: string
|
||||
cloudConnectedTitle: string
|
||||
cloudConnectedPill: string
|
||||
cloudConnectedTo: (name: string) => string
|
||||
cloudAgentProvisioning: string
|
||||
cloudStatusLabel: (status: string) => string
|
||||
remoteUrlTitle: string
|
||||
remoteUrlDesc: string
|
||||
probing: string
|
||||
|
||||
@@ -720,6 +720,37 @@ export const zh: Translations = {
|
||||
remoteTitle: '远程网关',
|
||||
remoteDesc:
|
||||
'将此桌面外壳连接到远程 Hermes 后端。托管网关使用 OAuth 或用户名密码;自托管网关也可能使用会话 token。',
|
||||
cloudTitle: 'Hermes Cloud',
|
||||
cloudDesc: '只需登录 Hermes Cloud 一次,即可从你账户下的智能体中选择——无需粘贴 URL。连接到你所选的那一个。',
|
||||
cloudSignInTitle: 'Hermes Cloud',
|
||||
cloudSignIn: '登录 Hermes Cloud',
|
||||
cloudSignedIn: '已登录 Hermes Cloud',
|
||||
cloudNeedsSignIn: '登录 Hermes Cloud 以发现你账户下的智能体。',
|
||||
cloudSignedInDesc: '你已登录。在下方选择一个智能体;会话会自动刷新。',
|
||||
cloudAgentsTitle: '你的智能体',
|
||||
cloudOrgPickerTitle: '选择一个组织',
|
||||
cloudOrgSelect: '选择',
|
||||
cloudOrgChange: '切换组织',
|
||||
cloudOrgRole: role => `角色:${role}`,
|
||||
cloudLoadingAgents: '正在加载你的智能体…',
|
||||
cloudNoAgents: {
|
||||
before: '此账户下未找到智能体。请在',
|
||||
linkText: 'Nous 门户',
|
||||
after: '中创建一个,然后刷新。'
|
||||
},
|
||||
cloudRefresh: '刷新',
|
||||
cloudConnect: '连接',
|
||||
cloudConnecting: '正在连接…',
|
||||
cloudDiscoverFailed: '无法加载你的 Hermes Cloud 智能体',
|
||||
cloudConnectFailed: '无法连接到该智能体',
|
||||
cloudSignInFailed: 'Hermes Cloud 登录失败',
|
||||
cloudSignedOutTitle: '已退出 Hermes Cloud',
|
||||
cloudSignedOutMessage: '已清除 Hermes Cloud 会话。',
|
||||
cloudConnectedTitle: '已连接',
|
||||
cloudConnectedPill: '已连接',
|
||||
cloudConnectedTo: name => `已连接到 ${name}。`,
|
||||
cloudAgentProvisioning: '正在配置…',
|
||||
cloudStatusLabel: status => `状态:${status}`,
|
||||
remoteUrlTitle: '远程 URL',
|
||||
remoteUrlDesc: '远程 dashboard 后端的基础 URL。支持路径前缀,例如 /hermes。',
|
||||
probing: '正在检查此网关的认证方式…',
|
||||
|
||||
@@ -26,6 +26,7 @@ import {
|
||||
IconCircle as CircleIcon,
|
||||
IconClipboard as Clipboard,
|
||||
IconClock as Clock,
|
||||
IconCloud as Cloud,
|
||||
IconCommand as Command,
|
||||
IconCopy as Copy,
|
||||
IconCopy as CopyIcon,
|
||||
@@ -139,6 +140,7 @@ export {
|
||||
CircleIcon,
|
||||
Clipboard,
|
||||
Clock,
|
||||
Cloud,
|
||||
Command,
|
||||
Copy,
|
||||
CopyIcon,
|
||||
|
||||
@@ -1,27 +1,15 @@
|
||||
import { beforeEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import { BUILTIN_THEMES, DEFAULT_SKIN_NAME } from './presets'
|
||||
import {
|
||||
$marketplaceInstalls,
|
||||
$userThemes,
|
||||
installUserTheme,
|
||||
isUserTheme,
|
||||
listAllThemes,
|
||||
marketplaceIdOf,
|
||||
removeUserTheme,
|
||||
resolveTheme
|
||||
} from './user-themes'
|
||||
import { $userThemes, installUserTheme, isUserTheme, listAllThemes, removeUserTheme, resolveTheme } from './user-themes'
|
||||
import { convertVscodeColorTheme } from './vscode'
|
||||
|
||||
const makeTheme = (label: string, source?: string) =>
|
||||
convertVscodeColorTheme(
|
||||
{
|
||||
name: label,
|
||||
type: 'dark',
|
||||
colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
|
||||
},
|
||||
source ? { source } : undefined
|
||||
).theme
|
||||
const makeTheme = (label: string) =>
|
||||
convertVscodeColorTheme({
|
||||
name: label,
|
||||
type: 'dark',
|
||||
colors: { 'editor.background': '#101014', 'editor.foreground': '#fafafa', focusBorder: '#7aa2f7' }
|
||||
}).theme
|
||||
|
||||
describe('user theme registry', () => {
|
||||
beforeEach(() => {
|
||||
@@ -73,33 +61,3 @@ describe('user theme registry', () => {
|
||||
expect(() => installUserTheme(broken)).toThrow(/colors/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('marketplace install tracking', () => {
|
||||
beforeEach(() => {
|
||||
window.localStorage.clear()
|
||||
$userThemes.set({})
|
||||
})
|
||||
|
||||
it('recovers the extension id only from Marketplace-sourced themes', () => {
|
||||
expect(marketplaceIdOf(makeTheme('Dracula', 'dracula-theme.theme-dracula'))).toBe('dracula-theme.theme-dracula')
|
||||
// A pasted (non-Marketplace) import has no extension id to report.
|
||||
expect(marketplaceIdOf(makeTheme('Pasted'))).toBeNull()
|
||||
})
|
||||
|
||||
it('maps installed Marketplace extension ids to their theme, reactively', () => {
|
||||
expect($marketplaceInstalls.get().size).toBe(0)
|
||||
|
||||
const theme = installUserTheme(makeTheme('Dracula', 'dracula-theme.theme-dracula'))
|
||||
const map = $marketplaceInstalls.get()
|
||||
|
||||
expect(map.get('dracula-theme.theme-dracula')).toEqual(theme)
|
||||
|
||||
removeUserTheme(theme.name)
|
||||
expect($marketplaceInstalls.get().has('dracula-theme.theme-dracula')).toBe(false)
|
||||
})
|
||||
|
||||
it('omits pasted imports (no extension id) from the map', () => {
|
||||
installUserTheme(makeTheme('Pasted'))
|
||||
expect($marketplaceInstalls.get().size).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -10,18 +10,13 @@
|
||||
* React mounts) can resolve a user theme synchronously, same as built-ins.
|
||||
*/
|
||||
|
||||
import { atom, computed } from 'nanostores'
|
||||
import { atom } from 'nanostores'
|
||||
|
||||
import { BUILTIN_THEMES } from './presets'
|
||||
import type { DesktopTheme, DesktopThemeColors } from './types'
|
||||
|
||||
const USER_THEMES_KEY = 'hermes-desktop-user-themes-v1'
|
||||
|
||||
// Marketplace imports stamp their description "VS Code · <publisher.extension>"
|
||||
// (see `convertVscodeColorTheme`). This is the one place that convention is read
|
||||
// back out, so every install surface can tell what's already installed.
|
||||
const MARKETPLACE_DESC_PREFIX = 'VS Code · '
|
||||
|
||||
// The minimal set of color keys a stored theme must carry to be usable. We keep
|
||||
// this loose — `applyTheme` tolerates missing optionals via fallbacks — but a
|
||||
// theme with no background/foreground/primary is junk and gets dropped.
|
||||
@@ -116,32 +111,6 @@ export function removeUserTheme(name: string): void {
|
||||
|
||||
export const isUserTheme = (name: string): boolean => Boolean($userThemes.get()[name])
|
||||
|
||||
/** The Marketplace extension id an installed theme came from, or null. */
|
||||
export function marketplaceIdOf(theme: DesktopTheme): string | null {
|
||||
return theme.description.startsWith(MARKETPLACE_DESC_PREFIX)
|
||||
? theme.description.slice(MARKETPLACE_DESC_PREFIX.length)
|
||||
: null
|
||||
}
|
||||
|
||||
/**
|
||||
* Reactive `extensionId → installed theme` map, so the install UIs can mark
|
||||
* Marketplace rows you already have (and re-activate them without re-downloading)
|
||||
* from one memoized source instead of re-deriving the set on every render.
|
||||
*/
|
||||
export const $marketplaceInstalls = computed($userThemes, themes => {
|
||||
const map = new Map<string, DesktopTheme>()
|
||||
|
||||
for (const theme of Object.values(themes)) {
|
||||
const id = marketplaceIdOf(theme)
|
||||
|
||||
if (id) {
|
||||
map.set(id, theme)
|
||||
}
|
||||
}
|
||||
|
||||
return map
|
||||
})
|
||||
|
||||
/** Resolve a theme by name across the merged registry (built-in + user). */
|
||||
export function resolveTheme(name: string): DesktopTheme | undefined {
|
||||
return BUILTIN_THEMES[name] ?? $userThemes.get()[name]
|
||||
|
||||
@@ -646,25 +646,6 @@ agent:
|
||||
# force it on or off; the HERMES_VERIFY_ON_STOP env var (1/0) takes precedence.
|
||||
# verify_on_stop: auto
|
||||
|
||||
# Standing operator instructions for the coding posture (when Hermes is in a
|
||||
# code workspace). Appended to the coding brief as an extra system block, so
|
||||
# you can pin project-wide workflow rules without editing the shipped brief.
|
||||
# Accepts a string or a list of strings. Takes effect next session.
|
||||
# coding_instructions:
|
||||
# - "For UI work, don't run tsc/lint until I approve the look."
|
||||
# - "Clean the diff before you commit and push."
|
||||
|
||||
# When verify-on-stop finds edited code without fresh verification evidence,
|
||||
# append guidance for creative UI work (avoid broad tsc/lint/test before visual
|
||||
# approval) and clean-diff expectations. Set false to keep that nudge terse.
|
||||
# verify_guidance: true
|
||||
|
||||
# A `pre_verify` hook (plugin or shell, see Event Hooks docs) can keep the
|
||||
# agent going one more turn to verify/clean before finishing. This caps how
|
||||
# many times one turn may be nudged to continue, so a hook can't trap the loop.
|
||||
# Default 3.
|
||||
# max_verify_nudges: 3
|
||||
|
||||
# Enable verbose logging
|
||||
verbose: false
|
||||
|
||||
@@ -1295,33 +1276,3 @@ updates:
|
||||
# # default — works on Fly.io out of the box).
|
||||
# #
|
||||
# # public_url: "https://example.com/hermes"
|
||||
#
|
||||
# -----------------------------------------------------------------------------
|
||||
# Self-hosted OIDC dashboard auth (generic OpenID Connect — Authentik,
|
||||
# Keycloak, Zitadel, Authelia, Auth0, Okta, Google, …). Use this INSTEAD of the
|
||||
# nous block above when gating the dashboard with your own identity provider.
|
||||
# Each setting can be overridden by an environment variable:
|
||||
#
|
||||
# dashboard.oauth.self_hosted.issuer <- HERMES_DASHBOARD_OIDC_ISSUER
|
||||
# dashboard.oauth.self_hosted.client_id <- HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
# dashboard.oauth.self_hosted.scopes <- HERMES_DASHBOARD_OIDC_SCOPES
|
||||
# dashboard.oauth.self_hosted.client_secret <- HERMES_DASHBOARD_OIDC_CLIENT_SECRET
|
||||
#
|
||||
# dashboard:
|
||||
# oauth:
|
||||
# provider: self-hosted
|
||||
# self_hosted:
|
||||
# issuer: "https://auth.example.com/application/o/hermes/" # required
|
||||
# client_id: "hermes-dashboard" # required
|
||||
# scopes: "openid profile email" # optional
|
||||
#
|
||||
# # OPTIONAL — set ONLY if your IDP registered the client as
|
||||
# # *confidential* (Authentik / Keycloak often default to this). When
|
||||
# # set, Hermes authenticates the client at the token endpoint
|
||||
# # (client_secret_basic or client_secret_post, auto-selected from the
|
||||
# # IDP's discovery doc) IN ADDITION to PKCE. Leave unset for a public
|
||||
# # (PKCE-only) client — the common case.
|
||||
# #
|
||||
# # This is a CREDENTIAL: prefer setting HERMES_DASHBOARD_OIDC_CLIENT_SECRET
|
||||
# # in ~/.hermes/.env over putting it here in config.yaml.
|
||||
# # client_secret: ""
|
||||
|
||||
@@ -751,14 +751,6 @@ try:
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Initialize friendly tool labels from config (default on)
|
||||
try:
|
||||
from agent.display import set_friendly_tool_labels
|
||||
_ftl = CLI_CONFIG.get("display", {}).get("friendly_tool_labels", True)
|
||||
set_friendly_tool_labels(bool(_ftl))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Neuter AsyncHttpxClientWrapper.__del__ before any AsyncOpenAI clients are
|
||||
# created. The SDK's __del__ schedules aclose() on asyncio.get_running_loop()
|
||||
# which, during CLI idle time, finds prompt_toolkit's event loop and tries to
|
||||
|
||||
@@ -345,15 +345,6 @@ class PlatformConfig:
|
||||
# noise; keep True for back-channels where the operator wants them.
|
||||
gateway_restart_notification: bool = True
|
||||
|
||||
# Whether the gateway shows a "typing…" / "is thinking…" status indicator
|
||||
# while the agent processes a message on this platform. Default True
|
||||
# preserves prior behavior. Set False on platforms where the indicator is
|
||||
# unwanted (e.g. Slack's assistant.threads.setStatus "is thinking…", which
|
||||
# disables the compose box, or any platform where users find the bubble
|
||||
# noisy). Drives the per-message _keep_typing refresh loop in
|
||||
# gateway/platforms/base.py.
|
||||
typing_indicator: bool = True
|
||||
|
||||
# Platform-specific settings
|
||||
extra: Dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
@@ -363,7 +354,6 @@ class PlatformConfig:
|
||||
"extra": self.extra,
|
||||
"reply_to_mode": self.reply_to_mode,
|
||||
"gateway_restart_notification": self.gateway_restart_notification,
|
||||
"typing_indicator": self.typing_indicator,
|
||||
}
|
||||
if self.token:
|
||||
result["token"] = self.token
|
||||
@@ -387,13 +377,6 @@ class PlatformConfig:
|
||||
if _grn is None:
|
||||
_grn = data.get("extra", {}).get("gateway_restart_notification")
|
||||
|
||||
# typing_indicator mirrors gateway_restart_notification: it may arrive
|
||||
# top-level or bridged into extra by the shared-key loop in
|
||||
# load_gateway_config(), so check both.
|
||||
_typing = data.get("typing_indicator")
|
||||
if _typing is None:
|
||||
_typing = data.get("extra", {}).get("typing_indicator")
|
||||
|
||||
return cls(
|
||||
enabled=_coerce_bool(data.get("enabled"), False),
|
||||
token=data.get("token"),
|
||||
@@ -401,7 +384,6 @@ class PlatformConfig:
|
||||
home_channel=home_channel,
|
||||
reply_to_mode=data.get("reply_to_mode", "first"),
|
||||
gateway_restart_notification=_coerce_bool(_grn, True),
|
||||
typing_indicator=_coerce_bool(_typing, True),
|
||||
extra=data.get("extra", {}),
|
||||
)
|
||||
|
||||
@@ -1050,8 +1032,6 @@ def load_gateway_config() -> GatewayConfig:
|
||||
bridged["channel_prompts"] = channel_prompts
|
||||
if "gateway_restart_notification" in platform_cfg:
|
||||
bridged["gateway_restart_notification"] = platform_cfg["gateway_restart_notification"]
|
||||
if "typing_indicator" in platform_cfg:
|
||||
bridged["typing_indicator"] = platform_cfg["typing_indicator"]
|
||||
enabled_was_explicit = _cfg_toplevel and "enabled" in platform_cfg
|
||||
if not bridged and not enabled_was_explicit:
|
||||
continue
|
||||
|
||||
+13
-18
@@ -4562,26 +4562,21 @@ class BasePlatformAdapter(ABC):
|
||||
interrupt_event = self._active_sessions.get(session_key) or asyncio.Event()
|
||||
self._active_sessions[session_key] = interrupt_event
|
||||
|
||||
# Start continuous typing indicator (refreshes every 2 seconds).
|
||||
# Gated per-platform: when typing_indicator=False the refresh loop is
|
||||
# never spawned, so no "typing…" / "is thinking…" status is shown.
|
||||
# typing_task stays None; _stop_typing_refresh already no-ops on None.
|
||||
# Start continuous typing indicator (refreshes every 2 seconds)
|
||||
_thread_metadata = _thread_metadata_for_source(event.source, _reply_anchor_for_event(event))
|
||||
typing_task: Optional[asyncio.Task] = None
|
||||
if getattr(self.config, "typing_indicator", True):
|
||||
_keep_typing_kwargs: Dict[str, Any] = {"metadata": _thread_metadata}
|
||||
try:
|
||||
_keep_typing_sig = inspect.signature(self._keep_typing)
|
||||
except (TypeError, ValueError):
|
||||
_keep_typing_sig = None
|
||||
if _keep_typing_sig is None or "stop_event" in _keep_typing_sig.parameters:
|
||||
_keep_typing_kwargs["stop_event"] = interrupt_event
|
||||
typing_task = asyncio.create_task(
|
||||
self._keep_typing(
|
||||
event.source.chat_id,
|
||||
**_keep_typing_kwargs,
|
||||
)
|
||||
_keep_typing_kwargs = {"metadata": _thread_metadata}
|
||||
try:
|
||||
_keep_typing_sig = inspect.signature(self._keep_typing)
|
||||
except (TypeError, ValueError):
|
||||
_keep_typing_sig = None
|
||||
if _keep_typing_sig is None or "stop_event" in _keep_typing_sig.parameters:
|
||||
_keep_typing_kwargs["stop_event"] = interrupt_event
|
||||
typing_task = asyncio.create_task(
|
||||
self._keep_typing(
|
||||
event.source.chat_id,
|
||||
**_keep_typing_kwargs,
|
||||
)
|
||||
)
|
||||
|
||||
async def _stop_typing_task() -> None:
|
||||
await self._stop_typing_refresh(
|
||||
|
||||
+2
-27
@@ -15385,14 +15385,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Apply friendly tool labels config (default on) — per-platform aware
|
||||
try:
|
||||
from agent.display import set_friendly_tool_labels
|
||||
_ftl = resolve_display_setting(user_config, platform_key, "friendly_tool_labels", True)
|
||||
set_friendly_tool_labels(bool(_ftl))
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Tool progress mode — resolved per-platform with env var fallback
|
||||
_resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress")
|
||||
_env_tp = os.getenv("HERMES_TOOL_PROGRESS_MODE")
|
||||
@@ -15686,29 +15678,12 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
msg = _code_block_short
|
||||
last_was_terminal_block[0] = True
|
||||
elif preview:
|
||||
from agent.display import (
|
||||
get_tool_preview_max_len,
|
||||
get_tool_verb,
|
||||
tool_verb_connector,
|
||||
verb_drops_preview,
|
||||
)
|
||||
from agent.display import get_tool_preview_max_len
|
||||
_pl = get_tool_preview_max_len()
|
||||
_cap = _pl if _pl > 0 else 40
|
||||
if len(preview) > _cap:
|
||||
preview = preview[:_cap - 3] + "..."
|
||||
# Friendly labels: render a human-phrased line for built-in
|
||||
# tools ("🔍 Searching the web for ...") by prefixing the verb
|
||||
# onto the preview the callback already computed (so the
|
||||
# command/url/query is preserved). Custom/plugin/MCP tools
|
||||
# have no verb and fall back to the raw "tool_name: ..." form.
|
||||
_verb = get_tool_verb(tool_name)
|
||||
if _verb:
|
||||
if verb_drops_preview(tool_name):
|
||||
msg = f"{emoji} {_verb}"
|
||||
else:
|
||||
msg = f"{emoji} {_verb}{tool_verb_connector(tool_name)}{preview}"
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
msg = f"{emoji} {tool_name}: \"{preview}\""
|
||||
last_was_terminal_block[0] = False
|
||||
else:
|
||||
msg = f"{emoji} {tool_name}..."
|
||||
|
||||
@@ -3455,47 +3455,6 @@ class GatewaySlashCommandsMixin:
|
||||
lines.append("Complete your top-up in the browser — credits will appear in /credits shortly.")
|
||||
return "\n".join(lines)
|
||||
|
||||
def _context_breakdown_lines(self, agent, source) -> list[str]:
|
||||
"""Render the per-category context breakdown for /usage.
|
||||
|
||||
Estimated (chars/4) — same engine the desktop popover uses. Returns an
|
||||
empty list and never raises on failure so /usage stays robust.
|
||||
"""
|
||||
try:
|
||||
from agent.context_breakdown import compute_session_context_breakdown
|
||||
|
||||
history: list[dict] = []
|
||||
try:
|
||||
entry = self.session_store.get_or_create_session(source)
|
||||
history = self.session_store.load_transcript(entry.session_id) or []
|
||||
except Exception:
|
||||
history = []
|
||||
|
||||
payload = compute_session_context_breakdown(agent, history)
|
||||
categories = payload.get("categories") or []
|
||||
if not categories:
|
||||
return []
|
||||
|
||||
total = payload.get("estimated_total") or 0
|
||||
out = [t("gateway.usage.breakdown_header")]
|
||||
for cat in categories:
|
||||
tokens = int(cat.get("tokens") or 0)
|
||||
if tokens <= 0:
|
||||
continue
|
||||
cat_id = str(cat.get("id") or "")
|
||||
label = t(f"gateway.usage.breakdown_cat_{cat_id}")
|
||||
# Missing key → t() echoes the key back; fall back to the
|
||||
# English label the engine already provides.
|
||||
if label.endswith(f"breakdown_cat_{cat_id}"):
|
||||
label = str(cat.get("label") or cat_id)
|
||||
pct = round(tokens / total * 100) if total else 0
|
||||
out.append(
|
||||
t("gateway.usage.breakdown_line", label=label, count=f"{tokens:,}", pct=pct)
|
||||
)
|
||||
return out if len(out) > 1 else []
|
||||
except Exception:
|
||||
return []
|
||||
|
||||
async def _handle_usage_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /usage command -- show token usage for the current session.
|
||||
|
||||
@@ -3595,15 +3554,6 @@ class GatewaySlashCommandsMixin:
|
||||
if ctx.compression_count:
|
||||
lines.append(t("gateway.usage.label_compressions", count=ctx.compression_count))
|
||||
|
||||
# Per-category context breakdown (estimated — chars/4 heuristic).
|
||||
# Same engine the desktop popover uses (PR #54907). The system
|
||||
# prompt / tools / skills / memory slices read off the live agent;
|
||||
# the conversation slice is estimated from the session transcript.
|
||||
breakdown_lines = self._context_breakdown_lines(agent, source)
|
||||
if breakdown_lines:
|
||||
lines.append("")
|
||||
lines.extend(breakdown_lines)
|
||||
|
||||
if account_lines:
|
||||
lines.append("")
|
||||
lines.extend(account_lines)
|
||||
|
||||
@@ -999,21 +999,6 @@ DEFAULT_CONFIG = {
|
||||
# "on" — force the prompt posture everywhere.
|
||||
# "off" — disable entirely.
|
||||
"coding_context": "auto",
|
||||
# Standing operator instructions for the coding posture. A string (or
|
||||
# list of strings) appended to the coding brief as an extra stable
|
||||
# system block — pin project-wide workflow rules here instead of editing
|
||||
# the shipped brief, e.g. "For UI work, don't run tsc/lint until I
|
||||
# approve. Clean the diff before you commit and push." Cache-safe:
|
||||
# takes effect next session. Empty by default.
|
||||
"coding_instructions": "",
|
||||
# When verify-on-stop finds edited code without fresh verification
|
||||
# evidence, append guidance for creative UI work (avoid broad
|
||||
# tsc/lint/test before visual approval) and clean-diff expectations.
|
||||
# Set false to keep the evidence nudge terse.
|
||||
"verify_guidance": True,
|
||||
# Upper bound on consecutive `pre_verify` "continue" nudges in a single
|
||||
# turn, so a user/plugin hook can never trap the loop.
|
||||
"max_verify_nudges": 3,
|
||||
# Verification closure: after the agent edits files in a code workspace,
|
||||
# do not accept a final answer until fresh verification evidence exists
|
||||
# or the agent explains why it cannot run checks. The loop is bounded
|
||||
@@ -1740,11 +1725,6 @@ DEFAULT_CONFIG = {
|
||||
"tool_progress_command": False, # Enable /verbose command in messaging gateway
|
||||
"tool_progress_overrides": {}, # DEPRECATED — use display.platforms instead
|
||||
"tool_preview_length": 0, # Max chars for tool call previews (0 = no limit, show full paths/commands)
|
||||
# Human-phrased tool status labels for built-in tools: "Searching the
|
||||
# web for ...", "Reading <file>", "Browsing <url>" instead of the raw
|
||||
# tool name. Applies to CLI spinner + gateway/desktop tool-progress.
|
||||
# Custom/plugin/MCP tools always fall back to the raw preview.
|
||||
"friendly_tool_labels": True,
|
||||
# How gateway tool-progress is grouped on platforms that support message
|
||||
# editing: "accumulate" (default) edits one bubble in place; "separate"
|
||||
# sends one message per tool (the pre-v0.9 behavior, noisier). Only
|
||||
|
||||
@@ -38,36 +38,6 @@ log = logging.getLogger(__name__)
|
||||
# durable start/stop intent across pod/container recreation.
|
||||
_AUTOSTART_STATES = frozenset({"running"})
|
||||
|
||||
# Transient runtime sub-states of a RUNNING gateway. A gateway only ever
|
||||
# reaches these while it is up and serving, so they are NOT an operator stop
|
||||
# and NOT a failed boot:
|
||||
# - `draining` — written by the drain watcher / scale-to-zero go-dormant
|
||||
# path when an in-flight quiesce begins (gateway/run.py).
|
||||
# - `degraded` — written when the gateway comes up with some platforms
|
||||
# queued for retry, then "falls through to the normal
|
||||
# running state" (gateway/run.py #5196): the process is up,
|
||||
# serving cron + whatever platforms connected, and the
|
||||
# reconnect watcher takes the rest from there.
|
||||
#
|
||||
# When a gateway is hard-killed *while in one of these states* (a container/VM
|
||||
# recreate SIGTERMs it before `_stop_impl` reaches its terminal-state persist),
|
||||
# the last value left in gateway_state.json is the transient sub-state. With no
|
||||
# explicit `desired_state` to fall back to, treating that literal value as the
|
||||
# autostart intent would leave the gateway DOWN on every subsequent boot — the
|
||||
# gateway never comes back, the dashboard is up but messaging stays dark
|
||||
# (observed on a relay-opted-in staging instance stranded at `draining`,
|
||||
# 2026-06; `degraded` is the same wedge class). Map these transient sub-states
|
||||
# to `running` so a stranded marker reads as the run-intent it actually
|
||||
# represents. This mirrors gateway/run.py's #42675 handling, which persists
|
||||
# `running` (not the mid-shutdown `draining`) when an unexpected signal tears
|
||||
# the gateway down — extended here to the case where the gateway died before it
|
||||
# could persist anything at all.
|
||||
#
|
||||
# `starting` / `startup_failed` are deliberately NOT included: those mean the
|
||||
# gateway died mid-boot or failed to come up, so auto-restarting them would
|
||||
# reintroduce the crash-loop the down-marker guard exists to prevent.
|
||||
_TRANSIENT_RUNNING_STATES = frozenset({"draining", "degraded"})
|
||||
|
||||
# Stale runtime files we sweep before recreating service slots. These
|
||||
# all hold container-namespaced state (PIDs, process tables) that's
|
||||
# garbage post-restart — a numerically-equal PID in the new container
|
||||
@@ -354,15 +324,6 @@ def _read_desired_state(profile_dir: Path) -> str | None:
|
||||
that as a compatibility fallback so existing running/stopped profiles
|
||||
preserve their behavior until the next explicit start/stop.
|
||||
|
||||
When falling back to ``gateway_state`` (no explicit ``desired_state``),
|
||||
a transient running sub-state (``draining``) is normalised to ``running``
|
||||
— see ``_TRANSIENT_RUNNING_STATES``. A gateway hard-killed mid-drain
|
||||
leaves ``draining`` as its last persisted value; without this it would be
|
||||
treated as a non-autostart state and the gateway would stay DOWN forever.
|
||||
An explicit ``desired_state`` is always honoured verbatim (it is the
|
||||
operator's durable intent), so this normalisation only affects the
|
||||
legacy/transient fallback path.
|
||||
|
||||
Missing or unparseable files count as "no desired state" so we don't
|
||||
bork the whole reconciliation on a corrupt file.
|
||||
"""
|
||||
@@ -374,10 +335,7 @@ def _read_desired_state(profile_dir: Path) -> str | None:
|
||||
desired_state = data.get("desired_state")
|
||||
if desired_state is not None:
|
||||
return desired_state
|
||||
gateway_state = data.get("gateway_state")
|
||||
if gateway_state in _TRANSIENT_RUNNING_STATES:
|
||||
return "running"
|
||||
return gateway_state
|
||||
return data.get("gateway_state")
|
||||
except (OSError, json.JSONDecodeError):
|
||||
log.warning(
|
||||
"could not read %s; treating as no prior state", state_file,
|
||||
|
||||
@@ -139,15 +139,6 @@ _DEFAULT_PAYLOADS = {
|
||||
"model": "gpt-4",
|
||||
"platform": "cli",
|
||||
},
|
||||
"pre_verify": {
|
||||
"session_id": "test-session",
|
||||
"platform": "cli",
|
||||
"model": "gpt-4",
|
||||
"coding": True,
|
||||
"attempt": 0,
|
||||
"final_response": "All done — the change is applied.",
|
||||
"changed_paths": ["src/app.tsx"],
|
||||
},
|
||||
"on_session_start": {"session_id": "test-session"},
|
||||
"on_session_end": {"session_id": "test-session"},
|
||||
"on_session_finalize": {"session_id": "test-session"},
|
||||
|
||||
@@ -136,17 +136,6 @@ VALID_HOOKS: Set[str] = {
|
||||
"transform_llm_output",
|
||||
"pre_llm_call",
|
||||
"post_llm_call",
|
||||
# Verification-loop gate. Fired once per turn when the agent has edited code
|
||||
# and is about to verify/finish (after the verify-on-stop guard). A callback
|
||||
# may keep the agent going — run a check, defer it, tidy the diff — instead
|
||||
# of stopping by returning:
|
||||
# {"action": "continue", "message": "<follow-up instruction>"}
|
||||
# The Claude-Code Stop shape {"decision": "block", "reason": "..."} (block
|
||||
# the stop == keep going) is accepted too. Anything else lets the turn
|
||||
# finish. Hermes' shipped guidance lives in the evidence-based
|
||||
# verification-stop nudge; this hook is for user/plugin policy and is
|
||||
# bounded by agent.max_verify_nudges.
|
||||
"pre_verify",
|
||||
"pre_api_request",
|
||||
"post_api_request",
|
||||
"api_request_error",
|
||||
@@ -2040,57 +2029,6 @@ def get_pre_tool_call_block_message(
|
||||
return None
|
||||
|
||||
|
||||
def get_pre_verify_continue_message(
|
||||
*,
|
||||
session_id: str = "",
|
||||
platform: str = "",
|
||||
model: str = "",
|
||||
coding: bool = False,
|
||||
attempt: int = 0,
|
||||
final_response: str = "",
|
||||
changed_paths: Optional[List[str]] = None,
|
||||
) -> Optional[str]:
|
||||
"""Check user ``pre_verify`` hooks for a directive to keep the agent going.
|
||||
|
||||
Fired once per turn when the agent edited code and is about to verify/finish.
|
||||
A hook keeps the turn going (run a check, defer it, tidy the diff) by
|
||||
returning::
|
||||
|
||||
{"action": "continue", "message": "<follow-up for the model>"}
|
||||
|
||||
The Claude-Code Stop shape ``{"decision": "block", "reason": "..."}`` (block
|
||||
the stop == keep going) is accepted too. The first directive carrying a
|
||||
non-empty message wins; any other return lets the turn finish. Mirrors
|
||||
:func:`get_pre_tool_call_block_message` — the call site stays a one-liner.
|
||||
|
||||
``coding`` / ``attempt`` let a hook scope itself (``if not coding`` …) and
|
||||
self-throttle (``if attempt`` …), the same way a ``pre_tool_call`` hook
|
||||
scopes on ``tool_name``.
|
||||
"""
|
||||
hook_results = invoke_hook(
|
||||
"pre_verify",
|
||||
session_id=session_id,
|
||||
platform=platform,
|
||||
model=model,
|
||||
coding=coding,
|
||||
attempt=attempt,
|
||||
final_response=final_response,
|
||||
changed_paths=list(changed_paths or []),
|
||||
)
|
||||
|
||||
for result in hook_results:
|
||||
if not isinstance(result, dict):
|
||||
continue
|
||||
action = str(result.get("action") or result.get("decision") or "").strip().lower()
|
||||
if action not in ("continue", "block"):
|
||||
continue
|
||||
message = result.get("message") or result.get("reason")
|
||||
if isinstance(message, str) and message.strip():
|
||||
return message.strip()
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _ensure_plugins_discovered(force: bool = False) -> PluginManager:
|
||||
"""Return the global manager after ensuring plugin discovery has run.
|
||||
|
||||
|
||||
@@ -66,7 +66,7 @@ CONFIGURABLE_TOOLSETS = [
|
||||
("vision", "👁️ Vision / Image Analysis", "vision_analyze"),
|
||||
("video", "🎬 Video Analysis", "video_analyze (requires video-capable model)"),
|
||||
("image_gen", "🎨 Image Generation", "image_generate"),
|
||||
("video_gen", "🎬 Video Generation", "video_generate (text/image/reference)"),
|
||||
("video_gen", "🎬 Video Generation", "video_generate (text-to-video + image-to-video)"),
|
||||
("x_search", "🐦 X (Twitter) Search", "x_search (requires xAI OAuth or XAI_API_KEY)"),
|
||||
("tts", "🔊 Text-to-Speech", "text_to_speech"),
|
||||
("skills", "📚 Skills", "list, view, manage"),
|
||||
@@ -2785,49 +2785,6 @@ def _configure_imagegen_model_for_plugin(plugin_name: str, config: dict) -> None
|
||||
_print_success(f" Model set to: {chosen}")
|
||||
|
||||
|
||||
def _configure_xai_imagine_storage(section_name: str, config: dict) -> None:
|
||||
"""Prompt for xAI Imagine stored public URL behavior."""
|
||||
section = config.setdefault(section_name, {})
|
||||
if not isinstance(section, dict):
|
||||
section = {}
|
||||
config[section_name] = section
|
||||
xai_cfg = section.setdefault("xai", {})
|
||||
if not isinstance(xai_cfg, dict):
|
||||
xai_cfg = {}
|
||||
section["xai"] = xai_cfg
|
||||
storage_cfg = xai_cfg.setdefault("storage", {})
|
||||
if not isinstance(storage_cfg, dict):
|
||||
storage_cfg = {}
|
||||
xai_cfg["storage"] = storage_cfg
|
||||
|
||||
_print_warning(
|
||||
" xAI Imagine can store generated media and create reusable public URLs. "
|
||||
"xAI may bill for stored files and public URL hosting."
|
||||
)
|
||||
idx = _prompt_choice(
|
||||
" Stored public URLs:",
|
||||
[
|
||||
"Enable public URLs without automatic expiry (recommended)",
|
||||
"Disable stored public URLs",
|
||||
"Enable public URLs for 2 days",
|
||||
],
|
||||
default=0,
|
||||
)
|
||||
if idx == 1:
|
||||
storage_cfg["enabled"] = False
|
||||
_print_success(" xAI stored public URLs disabled")
|
||||
elif idx == 2:
|
||||
storage_cfg["enabled"] = True
|
||||
storage_cfg["public_url"] = True
|
||||
storage_cfg["expires_after"] = 2 * 24 * 60 * 60
|
||||
_print_success(" xAI stored public URLs enabled for 2 days")
|
||||
else:
|
||||
storage_cfg["enabled"] = True
|
||||
storage_cfg["public_url"] = True
|
||||
storage_cfg["expires_after"] = None
|
||||
_print_success(" xAI stored public URLs enabled without automatic expiry")
|
||||
|
||||
|
||||
def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None:
|
||||
"""Persist a plugin-backed image generation provider selection."""
|
||||
img_cfg = config.setdefault("image_gen", {})
|
||||
@@ -2838,8 +2795,6 @@ def _select_plugin_image_gen_provider(plugin_name: str, config: dict) -> None:
|
||||
img_cfg["use_gateway"] = False
|
||||
_print_success(f" image_gen.provider set to: {plugin_name}")
|
||||
_configure_imagegen_model_for_plugin(plugin_name, config)
|
||||
if plugin_name == "xai":
|
||||
_configure_xai_imagine_storage("image_gen", config)
|
||||
|
||||
|
||||
# ─── Video Generation Model Pickers ───────────────────────────────────────────
|
||||
@@ -2940,8 +2895,6 @@ def _select_plugin_video_gen_provider(plugin_name: str, config: dict, *, use_gat
|
||||
vid_cfg["use_gateway"] = use_gateway
|
||||
_print_success(f" video_gen.provider set to: {plugin_name}")
|
||||
_configure_videogen_model_for_plugin(plugin_name, config)
|
||||
if plugin_name == "xai":
|
||||
_configure_xai_imagine_storage("video_gen", config)
|
||||
|
||||
|
||||
def _write_provider_config(provider: dict, config: dict, *, managed_feature) -> None:
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 900 KiB |
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Koste: ingesluit"
|
||||
label_context: "Konteks: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Saamperserings: {count}"
|
||||
breakdown_header: "🧩 **Konteksverdeling** _(geskat)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Stelselopdrag"
|
||||
breakdown_cat_tool_definitions: "Nutsmiddeldefinisies"
|
||||
breakdown_cat_rules: "Reëls"
|
||||
breakdown_cat_skills: "Vaardighede"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Subagent-definisies"
|
||||
breakdown_cat_memory: "Geheue"
|
||||
breakdown_cat_conversation: "Gesprek"
|
||||
header_session_info: "📊 **Sessie-inligting**"
|
||||
label_messages: "Boodskappe: {count}"
|
||||
label_estimated_context: "Geskatte konteks: ~{count} tokens"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Kosten: inbegriffen"
|
||||
label_context: "Kontext: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Kompressionen: {count}"
|
||||
breakdown_header: "🧩 **Kontextaufschlüsselung** _(geschätzt)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "System-Prompt"
|
||||
breakdown_cat_tool_definitions: "Tool-Definitionen"
|
||||
breakdown_cat_rules: "Regeln"
|
||||
breakdown_cat_skills: "Fähigkeiten"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Subagent-Definitionen"
|
||||
breakdown_cat_memory: "Speicher"
|
||||
breakdown_cat_conversation: "Konversation"
|
||||
header_session_info: "📊 **Sitzungsinfo**"
|
||||
label_messages: "Nachrichten: {count}"
|
||||
label_estimated_context: "Geschätzter Kontext: ~{count} Tokens"
|
||||
|
||||
@@ -344,16 +344,6 @@ gateway:
|
||||
label_cost_included: "Cost: included"
|
||||
label_context: "Context: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Compressions: {count}"
|
||||
breakdown_header: "🧩 **Context breakdown** _(estimated)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "System prompt"
|
||||
breakdown_cat_tool_definitions: "Tool definitions"
|
||||
breakdown_cat_rules: "Rules"
|
||||
breakdown_cat_skills: "Skills"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Subagent definitions"
|
||||
breakdown_cat_memory: "Memory"
|
||||
breakdown_cat_conversation: "Conversation"
|
||||
header_session_info: "📊 **Session Info**"
|
||||
label_messages: "Messages: {count}"
|
||||
label_estimated_context: "Estimated context: ~{count} tokens"
|
||||
|
||||
@@ -329,16 +329,6 @@ gateway:
|
||||
label_cost_included: "Costo: incluido"
|
||||
label_context: "Contexto: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Compresiones: {count}"
|
||||
breakdown_header: "🧩 **Desglose del contexto** _(estimado)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Prompt del sistema"
|
||||
breakdown_cat_tool_definitions: "Definiciones de herramientas"
|
||||
breakdown_cat_rules: "Reglas"
|
||||
breakdown_cat_skills: "Habilidades"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Definiciones de subagentes"
|
||||
breakdown_cat_memory: "Memoria"
|
||||
breakdown_cat_conversation: "Conversación"
|
||||
header_session_info: "📊 **Información de la sesión**"
|
||||
label_messages: "Mensajes: {count}"
|
||||
label_estimated_context: "Contexto estimado: ~{count} tokens"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Coût : inclus"
|
||||
label_context: "Contexte : {used} / {total} ({pct}%)"
|
||||
label_compressions: "Compressions : {count}"
|
||||
breakdown_header: "🧩 **Répartition du contexte** _(estimée)_"
|
||||
breakdown_line: "• {label} : ~{count} ({pct} %)"
|
||||
breakdown_cat_system_prompt: "Invite système"
|
||||
breakdown_cat_tool_definitions: "Définitions des outils"
|
||||
breakdown_cat_rules: "Règles"
|
||||
breakdown_cat_skills: "Compétences"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Définitions des sous-agents"
|
||||
breakdown_cat_memory: "Mémoire"
|
||||
breakdown_cat_conversation: "Conversation"
|
||||
header_session_info: "📊 **Infos de session**"
|
||||
label_messages: "Messages : {count}"
|
||||
label_estimated_context: "Contexte estimé : ~{count} jetons"
|
||||
|
||||
@@ -336,16 +336,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Costas: san áireamh"
|
||||
label_context: "Comhthéacs: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Dlúthuithe: {count}"
|
||||
breakdown_header: "🧩 **Miondealú comhthéacs** _(measta)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Leid an chórais"
|
||||
breakdown_cat_tool_definitions: "Sainmhínithe uirlisí"
|
||||
breakdown_cat_rules: "Rialacha"
|
||||
breakdown_cat_skills: "Scileanna"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Sainmhínithe fo-ghníomhairí"
|
||||
breakdown_cat_memory: "Cuimhne"
|
||||
breakdown_cat_conversation: "Comhrá"
|
||||
header_session_info: "📊 **Eolas Seisiúin**"
|
||||
label_messages: "Teachtaireachtaí: {count}"
|
||||
label_estimated_context: "Comhthéacs measta: ~{count} comhartha"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Költség: belefoglalva"
|
||||
label_context: "Kontextus: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Tömörítések: {count}"
|
||||
breakdown_header: "🧩 **Kontextus-bontás** _(becsült)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Rendszerüzenet"
|
||||
breakdown_cat_tool_definitions: "Eszközdefiníciók"
|
||||
breakdown_cat_rules: "Szabályok"
|
||||
breakdown_cat_skills: "Készségek"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Alügynök-definíciók"
|
||||
breakdown_cat_memory: "Memória"
|
||||
breakdown_cat_conversation: "Beszélgetés"
|
||||
header_session_info: "📊 **Munkamenet-információ**"
|
||||
label_messages: "Üzenetek: {count}"
|
||||
label_estimated_context: "Becsült kontextus: ~{count} token"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Costo: incluso"
|
||||
label_context: "Contesto: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Compressioni: {count}"
|
||||
breakdown_header: "🧩 **Suddivisione del contesto** _(stimata)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Prompt di sistema"
|
||||
breakdown_cat_tool_definitions: "Definizioni degli strumenti"
|
||||
breakdown_cat_rules: "Regole"
|
||||
breakdown_cat_skills: "Competenze"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Definizioni dei subagenti"
|
||||
breakdown_cat_memory: "Memoria"
|
||||
breakdown_cat_conversation: "Conversazione"
|
||||
header_session_info: "📊 **Info sessione**"
|
||||
label_messages: "Messaggi: {count}"
|
||||
label_estimated_context: "Contesto stimato: ~{count} token"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "コスト: 含まれています"
|
||||
label_context: "コンテキスト: {used} / {total} ({pct}%)"
|
||||
label_compressions: "圧縮回数: {count}"
|
||||
breakdown_header: "🧩 **コンテキスト内訳** _(推定)_"
|
||||
breakdown_line: "• {label}: 約{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "システムプロンプト"
|
||||
breakdown_cat_tool_definitions: "ツール定義"
|
||||
breakdown_cat_rules: "ルール"
|
||||
breakdown_cat_skills: "スキル"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "サブエージェント定義"
|
||||
breakdown_cat_memory: "メモリ"
|
||||
breakdown_cat_conversation: "会話"
|
||||
header_session_info: "📊 **セッション情報**"
|
||||
label_messages: "メッセージ数: {count}"
|
||||
label_estimated_context: "推定コンテキスト: ~{count} トークン"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "비용: 포함됨"
|
||||
label_context: "컨텍스트: {used} / {total} ({pct}%)"
|
||||
label_compressions: "압축: {count}"
|
||||
breakdown_header: "🧩 **컨텍스트 분석** _(추정치)_"
|
||||
breakdown_line: "• {label}: 약 {count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "시스템 프롬프트"
|
||||
breakdown_cat_tool_definitions: "도구 정의"
|
||||
breakdown_cat_rules: "규칙"
|
||||
breakdown_cat_skills: "스킬"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "서브에이전트 정의"
|
||||
breakdown_cat_memory: "메모리"
|
||||
breakdown_cat_conversation: "대화"
|
||||
header_session_info: "📊 **세션 정보**"
|
||||
label_messages: "메시지: {count}"
|
||||
label_estimated_context: "예상 컨텍스트: 약 {count} 토큰"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Custo: incluído"
|
||||
label_context: "Contexto: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Compressões: {count}"
|
||||
breakdown_header: "🧩 **Detalhamento do contexto** _(estimado)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Prompt do sistema"
|
||||
breakdown_cat_tool_definitions: "Definições de ferramentas"
|
||||
breakdown_cat_rules: "Regras"
|
||||
breakdown_cat_skills: "Habilidades"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Definições de subagentes"
|
||||
breakdown_cat_memory: "Memória"
|
||||
breakdown_cat_conversation: "Conversa"
|
||||
header_session_info: "📊 **Informações da sessão**"
|
||||
label_messages: "Mensagens: {count}"
|
||||
label_estimated_context: "Contexto estimado: ~{count} tokens"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Стоимость: включено"
|
||||
label_context: "Контекст: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Сжатий: {count}"
|
||||
breakdown_header: "🧩 **Разбивка контекста** _(оценка)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Системный промпт"
|
||||
breakdown_cat_tool_definitions: "Определения инструментов"
|
||||
breakdown_cat_rules: "Правила"
|
||||
breakdown_cat_skills: "Навыки"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Определения субагентов"
|
||||
breakdown_cat_memory: "Память"
|
||||
breakdown_cat_conversation: "Разговор"
|
||||
header_session_info: "📊 **Информация о сеансе**"
|
||||
label_messages: "Сообщений: {count}"
|
||||
label_estimated_context: "Ориентировочный контекст: ~{count} токенов"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Maliyet: dahil"
|
||||
label_context: "Bağlam: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Sıkıştırmalar: {count}"
|
||||
breakdown_header: "🧩 **Bağlam dökümü** _(tahmini)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Sistem istemi"
|
||||
breakdown_cat_tool_definitions: "Araç tanımları"
|
||||
breakdown_cat_rules: "Kurallar"
|
||||
breakdown_cat_skills: "Beceriler"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Alt aracı tanımları"
|
||||
breakdown_cat_memory: "Bellek"
|
||||
breakdown_cat_conversation: "Konuşma"
|
||||
header_session_info: "📊 **Oturum Bilgisi**"
|
||||
label_messages: "Mesajlar: {count}"
|
||||
label_estimated_context: "Tahmini bağlam: ~{count} token"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "Вартість: включено"
|
||||
label_context: "Контекст: {used} / {total} ({pct}%)"
|
||||
label_compressions: "Стиснень: {count}"
|
||||
breakdown_header: "🧩 **Розбивка контексту** _(оцінка)_"
|
||||
breakdown_line: "• {label}: ~{count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "Системний промпт"
|
||||
breakdown_cat_tool_definitions: "Визначення інструментів"
|
||||
breakdown_cat_rules: "Правила"
|
||||
breakdown_cat_skills: "Навички"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "Визначення субагентів"
|
||||
breakdown_cat_memory: "Пам'ять"
|
||||
breakdown_cat_conversation: "Розмова"
|
||||
header_session_info: "📊 **Інформація про сеанс**"
|
||||
label_messages: "Повідомлень: {count}"
|
||||
label_estimated_context: "Орієнтовний контекст: ~{count} токенів"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "費用:已包含"
|
||||
label_context: "上下文:{used} / {total}({pct}%)"
|
||||
label_compressions: "壓縮次數:{count}"
|
||||
breakdown_header: "🧩 **上下文明細** _(估算)_"
|
||||
breakdown_line: "• {label}:約 {count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "系統提示詞"
|
||||
breakdown_cat_tool_definitions: "工具定義"
|
||||
breakdown_cat_rules: "規則"
|
||||
breakdown_cat_skills: "技能"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "子代理定義"
|
||||
breakdown_cat_memory: "記憶"
|
||||
breakdown_cat_conversation: "對話"
|
||||
header_session_info: "📊 **工作階段資訊**"
|
||||
label_messages: "訊息數:{count}"
|
||||
label_estimated_context: "預估上下文:~{count} 個 token"
|
||||
|
||||
@@ -332,16 +332,6 @@ Future messages in this room will use that transcript until `/reset` or another
|
||||
label_cost_included: "费用:已包含"
|
||||
label_context: "上下文:{used} / {total}({pct}%)"
|
||||
label_compressions: "压缩次数:{count}"
|
||||
breakdown_header: "🧩 **上下文明细** _(估算)_"
|
||||
breakdown_line: "• {label}:约 {count} ({pct}%)"
|
||||
breakdown_cat_system_prompt: "系统提示词"
|
||||
breakdown_cat_tool_definitions: "工具定义"
|
||||
breakdown_cat_rules: "规则"
|
||||
breakdown_cat_skills: "技能"
|
||||
breakdown_cat_mcp: "MCP"
|
||||
breakdown_cat_subagent_definitions: "子代理定义"
|
||||
breakdown_cat_memory: "记忆"
|
||||
breakdown_cat_conversation: "对话"
|
||||
header_session_info: "📊 **会话信息**"
|
||||
label_messages: "消息数:{count}"
|
||||
label_estimated_context: "估计上下文:~{count} 个令牌"
|
||||
|
||||
@@ -32,17 +32,11 @@ only choice that is universally correct across self-hosted IDPs. (The ``nous``
|
||||
provider verifies its *access* token because Nous Portal mints a custom JWT
|
||||
access token with the dashboard claims baked in — a non-OIDC shortcut.)
|
||||
|
||||
Both **public** (PKCE-only) and **confidential** (PKCE + ``client_secret``)
|
||||
clients are supported. A self-hoster who registers a public client configures
|
||||
no secret and the token-endpoint calls authenticate with PKCE alone (the
|
||||
default). A self-hoster whose IDP defaults the client to *confidential*
|
||||
(Authentik and Keycloak commonly do) sets ``client_secret`` and the provider
|
||||
additionally authenticates the client at the token endpoint, choosing
|
||||
``client_secret_basic`` (HTTP Basic header) or ``client_secret_post`` (secret
|
||||
in the form body) from the IDP's advertised
|
||||
``token_endpoint_auth_methods_supported``. PKCE is sent in **both** modes —
|
||||
the secret is client authentication layered on top, never a replacement for
|
||||
PKCE (OAuth 2.1 / RFC 9700 keep PKCE mandatory regardless).
|
||||
Public PKCE clients only. Confidential clients (with a ``client_secret``) are
|
||||
not yet supported — see the ``# TODO(confidential-client)`` seam in
|
||||
``complete_login`` / ``refresh_session``. Self-hosters configuring a CLI/SPA
|
||||
client almost always register a public + PKCE client, which is the smaller,
|
||||
simpler surface.
|
||||
|
||||
Configuration surfaces (env wins over config.yaml when set non-empty, so a
|
||||
provisioned-but-not-populated secret can't shadow a valid config.yaml entry —
|
||||
@@ -56,16 +50,11 @@ same precedence convention as the ``nous`` plugin)::
|
||||
issuer: https://auth.example.com/application/o/hermes/ # required
|
||||
client_id: hermes-dashboard # required
|
||||
scopes: "openid profile email" # optional
|
||||
# client_secret: set ONLY for a confidential client. It is a
|
||||
# credential — prefer the env var / ~/.hermes/.env over config.yaml.
|
||||
|
||||
# Environment overrides (Docker/Fly secret injection)
|
||||
HERMES_DASHBOARD_OIDC_ISSUER
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_ID
|
||||
HERMES_DASHBOARD_OIDC_SCOPES # optional; defaults to "openid profile email"
|
||||
HERMES_DASHBOARD_OIDC_CLIENT_SECRET # optional; set for a confidential client
|
||||
# (the .env file is the canonical home —
|
||||
# it's a secret, not a behavioural setting)
|
||||
|
||||
Skip reasons: when the plugin loads but can't register (missing issuer /
|
||||
client_id), it writes a human-readable reason to the module-level
|
||||
@@ -183,7 +172,6 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
issuer: str,
|
||||
client_id: str,
|
||||
scopes: str = _DEFAULT_SCOPES,
|
||||
client_secret: str = "",
|
||||
) -> None:
|
||||
if not issuer:
|
||||
raise ValueError("issuer is required")
|
||||
@@ -198,10 +186,6 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
_require_https_or_loopback(self._issuer, field="issuer")
|
||||
self._client_id = client_id
|
||||
self._scopes = scopes.strip() or _DEFAULT_SCOPES
|
||||
# An empty/whitespace secret means "public client" — strip so a
|
||||
# provisioned-but-blank secret can't flip us into a broken confidential
|
||||
# mode that sends an empty client_secret. Non-empty ⇒ confidential.
|
||||
self._client_secret = (client_secret or "").strip()
|
||||
|
||||
# Discovery + JWKS are lazily resolved on first use so plugin
|
||||
# registration never makes a network call (the IDP may be down at
|
||||
@@ -261,16 +245,11 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
"client_id": self._client_id,
|
||||
"code_verifier": code_verifier,
|
||||
}
|
||||
# Confidential clients additionally authenticate the client here (basic
|
||||
# header or post body, per the IDP's advertised methods); public
|
||||
# clients get ({}, {}) and authenticate with PKCE alone.
|
||||
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
||||
data.update(extra_data)
|
||||
# TODO(confidential-client): when client_secret support lands, add it
|
||||
# here (and switch to HTTP Basic auth if the IDP's
|
||||
# token_endpoint_auth_methods_supported prefers client_secret_basic).
|
||||
return self._exchange(
|
||||
disco["token_endpoint"],
|
||||
data,
|
||||
bad_request_exc=InvalidCodeError,
|
||||
extra_headers=extra_headers,
|
||||
disco["token_endpoint"], data, bad_request_exc=InvalidCodeError
|
||||
)
|
||||
|
||||
def refresh_session(self, *, refresh_token: str) -> Session:
|
||||
@@ -286,17 +265,12 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
# identity claims (some IDPs narrow scope on refresh otherwise).
|
||||
"scope": self._scopes,
|
||||
}
|
||||
# Same client-authentication treatment as complete_login: confidential
|
||||
# clients must authenticate on the refresh grant too, or the IDP
|
||||
# rejects the rotation with invalid_client.
|
||||
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
||||
data.update(extra_data)
|
||||
# TODO(confidential-client): add client_secret here when supported.
|
||||
return self._exchange(
|
||||
disco["token_endpoint"],
|
||||
data,
|
||||
bad_request_exc=RefreshExpiredError,
|
||||
previous_refresh_token=refresh_token,
|
||||
extra_headers=extra_headers,
|
||||
)
|
||||
|
||||
def verify_session(self, *, access_token: str) -> Optional[Session]:
|
||||
@@ -330,23 +304,15 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
endpoint = str(disco.get("revocation_endpoint") or "").strip()
|
||||
if not endpoint:
|
||||
return None
|
||||
data = {
|
||||
"token": refresh_token,
|
||||
"token_type_hint": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
}
|
||||
headers = {"Accept": "application/json"}
|
||||
# A confidential client must authenticate on revocation too (RFC 7009
|
||||
# §2.1), or the IDP rejects it with invalid_client. Reuse the same
|
||||
# method selection as the token endpoint; public clients add nothing.
|
||||
extra_data, extra_headers = self._token_endpoint_auth(disco)
|
||||
data.update(extra_data)
|
||||
headers.update(extra_headers)
|
||||
try:
|
||||
httpx.post(
|
||||
endpoint,
|
||||
data=data,
|
||||
headers=headers,
|
||||
data={
|
||||
"token": refresh_token,
|
||||
"token_type_hint": "refresh_token",
|
||||
"client_id": self._client_id,
|
||||
},
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 — best-effort
|
||||
@@ -355,50 +321,6 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
|
||||
# ---- internals: token exchange ----------------------------------------
|
||||
|
||||
def _token_endpoint_auth(
|
||||
self, disco: Dict[str, Any]
|
||||
) -> tuple[Dict[str, str], Dict[str, str]]:
|
||||
"""Return ``(extra_data, extra_headers)`` for token-endpoint client auth.
|
||||
|
||||
Public client (no ``client_secret`` configured): returns ``({}, {})`` —
|
||||
the exchange authenticates with PKCE alone, exactly as before this
|
||||
method existed.
|
||||
|
||||
Confidential client (``client_secret`` set): authenticates the client
|
||||
per RFC 6749 §2.3.1, choosing the method from the IDP's advertised
|
||||
``token_endpoint_auth_methods_supported``:
|
||||
|
||||
* ``client_secret_post`` advertised (and ``client_secret_basic`` not)
|
||||
→ secret in the form body.
|
||||
* otherwise → HTTP Basic ``Authorization`` header (the OIDC default;
|
||||
also the fallback when the IDP advertises nothing).
|
||||
|
||||
PKCE's ``code_verifier`` is sent regardless by the callers — the secret
|
||||
is layered on top, never a replacement.
|
||||
"""
|
||||
if not self._client_secret:
|
||||
return {}, {}
|
||||
|
||||
methods = disco.get("token_endpoint_auth_methods_supported") or []
|
||||
prefer_post = (
|
||||
"client_secret_post" in methods
|
||||
and "client_secret_basic" not in methods
|
||||
)
|
||||
if prefer_post:
|
||||
# Secret travels in the application/x-www-form-urlencoded body.
|
||||
return {"client_secret": self._client_secret}, {}
|
||||
|
||||
# HTTP Basic: base64(urlencode(client_id) ":" urlencode(secret)).
|
||||
# Both halves must be form-url-encoded *before* base64 per RFC 6749
|
||||
# §2.3.1, or a secret containing ':' / reserved chars corrupts the
|
||||
# header.
|
||||
userpass = (
|
||||
f"{urllib.parse.quote(self._client_id, safe='')}:"
|
||||
f"{urllib.parse.quote(self._client_secret, safe='')}"
|
||||
)
|
||||
encoded = base64.b64encode(userpass.encode("utf-8")).decode("ascii")
|
||||
return {}, {"Authorization": f"Basic {encoded}"}
|
||||
|
||||
def _exchange(
|
||||
self,
|
||||
token_endpoint: str,
|
||||
@@ -406,7 +328,6 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
*,
|
||||
bad_request_exc: type[Exception],
|
||||
previous_refresh_token: str = "",
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
) -> Session:
|
||||
"""POST the token endpoint and turn the response into a Session.
|
||||
|
||||
@@ -414,20 +335,12 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
(refresh grant). ``bad_request_exc`` is raised on a 400 —
|
||||
``InvalidCodeError`` for the auth-code path, ``RefreshExpiredError``
|
||||
for the refresh path — preserving the middleware's distinct handling.
|
||||
|
||||
``extra_headers`` carries the confidential-client ``Authorization``
|
||||
header when one applies (see ``_token_endpoint_auth``); it is empty for
|
||||
a public client, so the request is byte-identical to the pre-
|
||||
confidential-client behaviour in that case.
|
||||
"""
|
||||
headers = {"Accept": "application/json"}
|
||||
if extra_headers:
|
||||
headers.update(extra_headers)
|
||||
try:
|
||||
response = httpx.post(
|
||||
token_endpoint,
|
||||
data=data,
|
||||
headers=headers,
|
||||
headers={"Accept": "application/json"},
|
||||
timeout=_TOKEN_ENDPOINT_TIMEOUT_SEC,
|
||||
)
|
||||
except httpx.RequestError as exc:
|
||||
@@ -570,24 +483,12 @@ class SelfHostedOIDCProvider(DashboardAuthProvider):
|
||||
payload.get("revocation_endpoint", "") or ""
|
||||
).strip()
|
||||
|
||||
# Client-authentication methods the IDP advertises for the token
|
||||
# endpoint. Used to pick client_secret_basic vs client_secret_post for
|
||||
# a confidential client (see ``_token_endpoint_auth``). Absent/garbage
|
||||
# → empty list → we fall back to the OIDC default (basic).
|
||||
auth_methods_raw = payload.get("token_endpoint_auth_methods_supported")
|
||||
token_endpoint_auth_methods = (
|
||||
[str(m) for m in auth_methods_raw]
|
||||
if isinstance(auth_methods_raw, list)
|
||||
else []
|
||||
)
|
||||
|
||||
return {
|
||||
"issuer": advertised_issuer or self._issuer,
|
||||
"authorization_endpoint": authorization_endpoint,
|
||||
"token_endpoint": token_endpoint,
|
||||
"jwks_uri": jwks_uri,
|
||||
"revocation_endpoint": revocation_endpoint,
|
||||
"token_endpoint_auth_methods_supported": token_endpoint_auth_methods,
|
||||
}
|
||||
|
||||
# ---- internals: JWT verification --------------------------------------
|
||||
@@ -812,12 +713,6 @@ def register(ctx) -> None:
|
||||
_resolve_setting("HERMES_DASHBOARD_OIDC_SCOPES", oidc_cfg.get("scopes"))
|
||||
or _DEFAULT_SCOPES
|
||||
)
|
||||
# Optional — set only for a confidential client. A credential, so the
|
||||
# canonical home is the env var / ~/.hermes/.env; config.yaml is supported
|
||||
# for precedence symmetry. Empty ⇒ public client (unchanged behaviour).
|
||||
client_secret = _resolve_setting(
|
||||
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET", oidc_cfg.get("client_secret")
|
||||
)
|
||||
|
||||
if not issuer or not client_id:
|
||||
LAST_SKIP_REASON = (
|
||||
@@ -834,10 +729,7 @@ def register(ctx) -> None:
|
||||
|
||||
try:
|
||||
provider = SelfHostedOIDCProvider(
|
||||
issuer=issuer,
|
||||
client_id=client_id,
|
||||
scopes=scopes,
|
||||
client_secret=client_secret,
|
||||
issuer=issuer, client_id=client_id, scopes=scopes
|
||||
)
|
||||
except (ValueError, ProviderError) as exc:
|
||||
LAST_SKIP_REASON = (
|
||||
@@ -849,10 +741,8 @@ def register(ctx) -> None:
|
||||
ctx.register_dashboard_auth_provider(provider)
|
||||
logger.info(
|
||||
"dashboard-auth-self-hosted: registered provider "
|
||||
"(issuer=%s, client_id=%s, scopes=%r, confidential=%s)",
|
||||
"(issuer=%s, client_id=%s, scopes=%r)",
|
||||
issuer,
|
||||
client_id,
|
||||
scopes,
|
||||
# Log only whether a secret is present, never the secret itself.
|
||||
bool(client_secret),
|
||||
)
|
||||
|
||||
@@ -19,7 +19,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import requests
|
||||
@@ -34,14 +33,7 @@ from agent.image_gen_provider import (
|
||||
save_url_image,
|
||||
success_response,
|
||||
)
|
||||
from tools.xai_http import (
|
||||
build_xai_storage_options,
|
||||
hermes_xai_user_agent,
|
||||
maybe_mark_xai_storage_notice_seen,
|
||||
read_xai_imagine_storage_config,
|
||||
resolve_xai_http_credentials,
|
||||
xai_storage_notice_text,
|
||||
)
|
||||
from tools.xai_http import hermes_xai_user_agent, resolve_xai_http_credentials
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -126,8 +118,10 @@ def _resolve_resolution() -> str:
|
||||
def _xai_image_field(source: str) -> Dict[str, str]:
|
||||
"""Build the xAI ``image`` field for an edit request.
|
||||
|
||||
xAI's ``/v1/images/edits`` accepts a public HTTPS URL or a base64 data URI.
|
||||
Local file paths are read and encoded into a ``data:`` URI.
|
||||
xAI's ``/v1/images/edits`` accepts ``{"url": <ref>, "type": "image_url"}``
|
||||
where ``<ref>`` is a public URL or a base64 data URI. Public URLs and
|
||||
existing data URIs pass through unchanged; local file paths are read and
|
||||
encoded into a ``data:`` URI.
|
||||
"""
|
||||
source = source.strip()
|
||||
lower = source.lower()
|
||||
@@ -137,7 +131,7 @@ def _xai_image_field(source: str) -> Dict[str, str]:
|
||||
import base64
|
||||
import os as _os
|
||||
|
||||
with open(_os.path.expanduser(source), "rb") as fh: # windows-footgun: ok
|
||||
with open(source, "rb") as fh:
|
||||
raw = fh.read()
|
||||
ext = (_os.path.splitext(source)[1].lstrip(".") or "png").lower()
|
||||
if ext == "jpg":
|
||||
@@ -182,29 +176,19 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
# hook (``hermes_cli/tools_config.py``); identical to the TTS / video
|
||||
# gen entries so users see the same OAuth-or-API-key choice for every
|
||||
# xAI service.
|
||||
storage_notice = xai_storage_notice_text("image_gen")
|
||||
tag = (
|
||||
"grok-imagine-image - text-to-image & image editing; uses xAI "
|
||||
"Grok OAuth or XAI_API_KEY"
|
||||
)
|
||||
if storage_notice:
|
||||
tag += f". {storage_notice}"
|
||||
return {
|
||||
"name": "xAI Grok Imagine (image)",
|
||||
"badge": "paid",
|
||||
"tag": tag,
|
||||
"tag": "grok-imagine-image — text-to-image & image editing; uses xAI Grok OAuth or XAI_API_KEY",
|
||||
"env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
}
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
# xAI's /v1/images/edits supports image editing via grok-imagine-image
|
||||
# -quality, including up to 3 total source images.
|
||||
return {
|
||||
"modalities": ["text", "image"],
|
||||
"max_reference_images": 2,
|
||||
"max_source_images": 3,
|
||||
}
|
||||
# -quality. Single primary source image (multi-image editing exists as
|
||||
# a separate capability but we keep the primary edit surface here).
|
||||
return {"modalities": ["text", "image"], "max_reference_images": 1}
|
||||
|
||||
def generate(
|
||||
self,
|
||||
@@ -240,39 +224,16 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
resolution = _resolve_resolution()
|
||||
xai_res = resolution if resolution in _XAI_RESOLUTIONS else DEFAULT_RESOLUTION
|
||||
|
||||
source_images: List[str] = []
|
||||
# Pick the primary source image: explicit image_url wins, else the
|
||||
# first reference image.
|
||||
source_image = None
|
||||
if isinstance(image_url, str) and image_url.strip():
|
||||
source_images.append(image_url.strip())
|
||||
refs = normalize_reference_images(reference_image_urls)
|
||||
if refs:
|
||||
source_images.extend(refs)
|
||||
if len(source_images) > 3:
|
||||
return error_response(
|
||||
error="xAI image editing supports at most 3 source images",
|
||||
error_type="too_many_references",
|
||||
provider=provider_name,
|
||||
model="grok-imagine-image-quality",
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
for index, source in enumerate(source_images):
|
||||
field = "image_url" if index == 0 and image_url and image_url.strip() == source else "reference_image_urls"
|
||||
lower = source.lower()
|
||||
if not lower.startswith(("http://", "https://", "data:")):
|
||||
path = Path(source).expanduser()
|
||||
if not path.is_file():
|
||||
return error_response(
|
||||
error=(
|
||||
f"{field} must be a public HTTPS URL or data URI "
|
||||
"(e.g. the `image`/`public_url` from a prior Imagine result)"
|
||||
),
|
||||
error_type="invalid_image_url",
|
||||
provider=provider_name,
|
||||
model="grok-imagine-image-quality",
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
is_edit = bool(source_images)
|
||||
source_image = image_url.strip()
|
||||
else:
|
||||
refs = normalize_reference_images(reference_image_urls)
|
||||
if refs:
|
||||
source_image = refs[0]
|
||||
is_edit = bool(source_image)
|
||||
modality = "image" if is_edit else "text"
|
||||
|
||||
headers = {
|
||||
@@ -282,13 +243,6 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
}
|
||||
|
||||
base_url = str(creds.get("base_url") or "https://api.x.ai/v1").strip().rstrip("/")
|
||||
storage_options = build_xai_storage_options(
|
||||
"image_gen",
|
||||
filename_prefix="hermes-xai-image",
|
||||
extension="png",
|
||||
)
|
||||
storage_notice = maybe_mark_xai_storage_notice_seen("image_gen")
|
||||
storage_cfg = read_xai_imagine_storage_config("image_gen")
|
||||
|
||||
if is_edit:
|
||||
# Editing requires the quality model per xAI docs. The source
|
||||
@@ -296,7 +250,7 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
# are converted to a data URI here.
|
||||
edit_model = "grok-imagine-image-quality"
|
||||
try:
|
||||
image_fields = [_xai_image_field(source) for source in source_images]
|
||||
image_field = _xai_image_field(source_image)
|
||||
except Exception as exc:
|
||||
return error_response(
|
||||
error=f"Could not load source image for editing: {exc}",
|
||||
@@ -309,11 +263,8 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
payload: Dict[str, Any] = {
|
||||
"model": edit_model,
|
||||
"prompt": prompt,
|
||||
"image": image_field,
|
||||
}
|
||||
if len(image_fields) == 1:
|
||||
payload["image"] = image_fields[0]
|
||||
else:
|
||||
payload["images"] = image_fields
|
||||
endpoint_url = f"{base_url}/images/edits"
|
||||
model_id = edit_model
|
||||
else:
|
||||
@@ -324,8 +275,6 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
"resolution": xai_res,
|
||||
}
|
||||
endpoint_url = f"{base_url}/images/generations"
|
||||
if storage_options is not None:
|
||||
payload["storage_options"] = storage_options
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
@@ -382,8 +331,7 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
# Parse response - xAI returns data[0].b64_json, data[0].url, and
|
||||
# optionally data[0].file_output when storage_options were requested.
|
||||
# Parse response — xAI returns data[0].b64_json or data[0].url
|
||||
data = result.get("data", [])
|
||||
if not data:
|
||||
return error_response(
|
||||
@@ -398,13 +346,8 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
first = data[0]
|
||||
b64 = first.get("b64_json")
|
||||
url = first.get("url")
|
||||
file_output = first.get("file_output") if isinstance(first, dict) else None
|
||||
file_output = file_output if isinstance(file_output, dict) else {}
|
||||
public_url = file_output.get("public_url") if isinstance(file_output.get("public_url"), str) else None
|
||||
|
||||
if public_url:
|
||||
image_ref = public_url
|
||||
elif b64:
|
||||
if b64:
|
||||
try:
|
||||
saved_path = save_b64_image(b64, prefix=f"xai_{model_id}")
|
||||
except Exception as exc:
|
||||
@@ -446,27 +389,9 @@ class XAIImageGenProvider(ImageGenProvider):
|
||||
aspect_ratio=aspect,
|
||||
)
|
||||
|
||||
extra: Dict[str, Any] = {
|
||||
"storage_enabled": bool(storage_cfg["enabled"]),
|
||||
}
|
||||
extra: Dict[str, Any] = {}
|
||||
if not is_edit:
|
||||
extra["resolution"] = xai_res
|
||||
if storage_notice:
|
||||
extra["storage_notice"] = storage_notice
|
||||
if public_url:
|
||||
extra["public_url"] = public_url
|
||||
if file_output:
|
||||
for key in (
|
||||
"filename",
|
||||
"expires_at",
|
||||
"public_url_expires_at",
|
||||
"public_url_error",
|
||||
"storage_error",
|
||||
):
|
||||
if key in file_output:
|
||||
extra[key] = file_output[key]
|
||||
if result.get("usage"):
|
||||
extra["usage"] = result["usage"]
|
||||
|
||||
return success_response(
|
||||
image=image_ref,
|
||||
|
||||
@@ -874,70 +874,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
except Exception: # pragma: no cover - diagnostics must never break connect
|
||||
pass
|
||||
|
||||
def _warn_if_not_bot_token(self, auth_response, team_name: str) -> None:
|
||||
"""Warn when the configured token authenticates as a human, not a bot.
|
||||
|
||||
``auth.test`` returns the ``user_id`` of *whatever principal owns the
|
||||
token*. For a real bot token (``xoxb-…``) that is the app's bot user
|
||||
and the response carries a ``bot_id``. For a **user** token
|
||||
(``xoxp-…`` / a legacy/personal OAuth token) it is the *installing
|
||||
human's* member ID and there is **no** ``bot_id``.
|
||||
|
||||
When that happens, ``self._bot_user_id`` becomes a human's member ID,
|
||||
and every "is this the bot?" check downstream misfires: that one
|
||||
person's ``<@…>`` mentions wake the bot (``is_mentioned`` in
|
||||
``_handle_slack_message``) and get stripped as if they were the bot's
|
||||
own mention — so the agent is genuinely told it was @mentioned and
|
||||
replies to messages merely *addressed to that human*. There is no
|
||||
runtime API error to catch; the only detectable moment is here at
|
||||
connect time, by noticing ``bot_id`` is absent from ``auth.test``.
|
||||
|
||||
Warning-only: a user token can still send/receive, and we don't want
|
||||
to hard-fail a working-but-misconfigured install on connect. We log
|
||||
exactly what is wrong and how to fix it, once per workspace per
|
||||
process.
|
||||
"""
|
||||
try:
|
||||
warned = getattr(self, "_user_token_warned", None)
|
||||
if warned is None:
|
||||
warned = set()
|
||||
self._user_token_warned = warned
|
||||
team_key = team_name or ""
|
||||
if team_key in warned:
|
||||
return
|
||||
# ``auth.test`` includes ``bot_id`` only for bot tokens. Its
|
||||
# absence (with a resolved user_id) means a user/legacy token.
|
||||
bot_id = ""
|
||||
user_id = ""
|
||||
try:
|
||||
bot_id = auth_response.get("bot_id", "") or ""
|
||||
user_id = auth_response.get("user_id", "") or ""
|
||||
except Exception:
|
||||
# Some response shapes are attribute-only; fall back to .data.
|
||||
data = getattr(auth_response, "data", None) or {}
|
||||
bot_id = data.get("bot_id", "") or ""
|
||||
user_id = data.get("user_id", "") or ""
|
||||
if not user_id:
|
||||
return # Nothing resolved — don't guess.
|
||||
if not bot_id:
|
||||
warned.add(team_key)
|
||||
logger.warning(
|
||||
"[Slack] The configured Slack token for workspace %s "
|
||||
"authenticated as a USER (member %s), not a bot — the "
|
||||
"auth.test response has no 'bot_id'. This is almost "
|
||||
"certainly a user token (xoxp-...) instead of a Bot User "
|
||||
"OAuth Token (xoxb-...). The bot's identity is now bound "
|
||||
"to that member's ID, so mentions OF THAT PERSON will be "
|
||||
"misrouted as mentions of the bot (the bot replies to "
|
||||
"messages merely addressed to them). Use the 'Bot User "
|
||||
"OAuth Token' (xoxb-...) from your Slack app's 'OAuth & "
|
||||
"Permissions' page in SLACK_BOT_TOKEN.",
|
||||
team_key or "this workspace",
|
||||
user_id,
|
||||
)
|
||||
except Exception: # pragma: no cover - diagnostics must never break connect
|
||||
pass
|
||||
|
||||
async def connect(self, *, is_reconnect: bool = False) -> bool:
|
||||
"""Connect to Slack via Socket Mode."""
|
||||
if not SLACK_AVAILABLE:
|
||||
@@ -1067,7 +1003,6 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
)
|
||||
|
||||
self._warn_if_missing_group_dm_scopes(auth_response, team_name)
|
||||
self._warn_if_not_bot_token(auth_response, team_name)
|
||||
|
||||
# Register message event handler
|
||||
@self._app.event("message")
|
||||
|
||||
+196
-597
@@ -1,7 +1,10 @@
|
||||
"""xAI Grok-Imagine video generation backend.
|
||||
|
||||
Surface: text-to-video, image-to-video, and reference-to-video through the
|
||||
unified video provider. xAI edit/extend are exposed through separate tools.
|
||||
Surface: text-to-video and image-to-video (animate an input image)
|
||||
through xAI's ``/videos/generations`` endpoint. Edit and extend are not
|
||||
exposed in this unified surface — xAI is the only backend that supports
|
||||
them and the inconsistency would force per-backend prose in the agent's
|
||||
tool description.
|
||||
|
||||
Originally salvaged from PR #10600 by @Jaaneek; reshaped into the
|
||||
:class:`VideoGenProvider` plugin interface and trimmed to the
|
||||
@@ -11,9 +14,8 @@ Authentication: xAI Grok OAuth tokens (preferred — billed against the
|
||||
user's SuperGrok or X Premium+ subscription) or ``XAI_API_KEY``. Both routes are
|
||||
resolved through ``tools.xai_http.resolve_xai_http_credentials`` so a
|
||||
single login covers chat + TTS + image gen + video gen + transcription.
|
||||
When xAI storage is enabled, the primary ``video`` / ``public_url`` fields are the
|
||||
stored files-cdn HTTPS link. Pass that public MP4 URL as ``video_url`` for
|
||||
edit/extend; it is sent to xAI as ``video.url``.
|
||||
Output is an HTTPS URL from xAI's CDN; the gateway downloads and
|
||||
delivers it.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -44,14 +46,13 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_XAI_BASE_URL = "https://api.x.ai/v1"
|
||||
DEFAULT_TEXT_TO_VIDEO_MODEL = "grok-imagine-video"
|
||||
DEFAULT_IMAGE_TO_VIDEO_MODEL = "grok-imagine-video-1.5"
|
||||
DEFAULT_IMAGE_TO_VIDEO_MODEL = "grok-imagine-video-1.5-preview"
|
||||
DEFAULT_MODEL = DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
DEFAULT_DURATION = 8
|
||||
DEFAULT_ASPECT_RATIO = "16:9"
|
||||
DEFAULT_RESOLUTION = "720p"
|
||||
DEFAULT_TIMEOUT_SECONDS = 240
|
||||
DEFAULT_POLL_INTERVAL_SECONDS = 5
|
||||
DEFAULT_EXTEND_DURATION = 6
|
||||
|
||||
VALID_ASPECT_RATIOS = {"1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3"}
|
||||
VALID_RESOLUTIONS = {"480p", "720p"}
|
||||
@@ -66,20 +67,16 @@ _MODELS: Dict[str, Dict[str, Any]] = {
|
||||
"price": "see https://docs.x.ai/developers/models/grok-imagine-video",
|
||||
"modalities": ["text", "image"],
|
||||
},
|
||||
"grok-imagine-video-1.5": {
|
||||
"display": "Grok Imagine Video 1.5",
|
||||
"grok-imagine-video-1.5-preview": {
|
||||
"display": "Grok Imagine Video 1.5 Preview",
|
||||
"speed": "~60-240s",
|
||||
"strengths": "Latest xAI image-to-video model.",
|
||||
"price": "see https://docs.x.ai/developers/pricing",
|
||||
"price": "see https://docs.x.ai/developers/models/grok-imagine-video-1.5-preview",
|
||||
"modalities": ["image"],
|
||||
"aliases": ["grok-imagine-video-1.5-2026-05-30"],
|
||||
},
|
||||
}
|
||||
|
||||
_IMAGE_TO_VIDEO_COMPAT_MODEL_IDS = {
|
||||
"grok-imagine-video-1.5-preview",
|
||||
"grok-imagine-video-1.5-2026-05-30",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTTP helpers
|
||||
@@ -148,114 +145,21 @@ def _image_ref_to_xai_url(value: str) -> str:
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
def _image_ref_to_xai_input(value: str) -> Optional[Dict[str, str]]:
|
||||
ref = _image_ref_to_xai_url(value)
|
||||
if not ref:
|
||||
return None
|
||||
lower = ref.lower()
|
||||
if lower.startswith(("http://", "https://", "data:image/")):
|
||||
return {"url": ref}
|
||||
return None
|
||||
|
||||
|
||||
def _xai_video_output_urls(
|
||||
video: Dict[str, Any],
|
||||
) -> Tuple[str, Optional[str], Optional[str]]:
|
||||
"""Return ``(public_video_url, temporary_url, stored_public_url)``.
|
||||
|
||||
``public_video_url`` is the stored files-cdn HTTPS MP4 (``public_url``) when
|
||||
storage is enabled; otherwise xAI's temporary ``video.url``. Pass this value
|
||||
as ``video_url`` for edit/extend chaining.
|
||||
"""
|
||||
file_output = video.get("file_output") if isinstance(video.get("file_output"), dict) else {}
|
||||
file_output = file_output or {}
|
||||
stored_public = file_output.get("public_url")
|
||||
stored_public = stored_public.strip() if isinstance(stored_public, str) else None
|
||||
temporary = video.get("url")
|
||||
temporary = temporary.strip() if isinstance(temporary, str) else None
|
||||
public_video_url = stored_public or temporary or ""
|
||||
temporary_out = (
|
||||
temporary
|
||||
if temporary and stored_public and temporary != stored_public
|
||||
else None
|
||||
)
|
||||
return public_video_url, temporary_out, stored_public
|
||||
|
||||
|
||||
def _video_ref_to_xai_url(value: str) -> str:
|
||||
"""Return a URL/data URI accepted by xAI for video inputs."""
|
||||
ref = (value or "").strip()
|
||||
if not ref:
|
||||
return ""
|
||||
lower = ref.lower()
|
||||
if lower.startswith(("http://", "https://", "data:video/")):
|
||||
return ref
|
||||
|
||||
path = Path(ref).expanduser()
|
||||
if not path.is_file():
|
||||
return ref
|
||||
|
||||
mime = mimetypes.guess_type(path.name)[0] or "video/mp4"
|
||||
if not mime.startswith("video/"):
|
||||
return ref
|
||||
|
||||
encoded = base64.b64encode(path.read_bytes()).decode("ascii")
|
||||
return f"data:{mime};base64,{encoded}"
|
||||
|
||||
|
||||
async def _video_input_from_public_url(
|
||||
value: str,
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
) -> Optional[Dict[str, str]]:
|
||||
"""Build xAI ``video`` input using a public HTTPS URL (``url`` field only)."""
|
||||
ref = (value or "").strip()
|
||||
if not ref:
|
||||
return None
|
||||
|
||||
path = Path(ref).expanduser()
|
||||
if path.is_file():
|
||||
data_ref = _video_ref_to_xai_url(ref)
|
||||
return {"url": data_ref} if data_ref else None
|
||||
|
||||
lower = ref.lower()
|
||||
if not lower.startswith(("http://", "https://")):
|
||||
return None
|
||||
|
||||
return {"url": ref}
|
||||
|
||||
|
||||
def _normalize_reference_images(
|
||||
reference_image_urls: Optional[List[str]],
|
||||
) -> Tuple[Optional[List[Dict[str, str]]], Optional[str]]:
|
||||
refs: List[Dict[str, str]] = []
|
||||
def _normalize_reference_images(reference_image_urls: Optional[List[str]]):
|
||||
refs = []
|
||||
for url in reference_image_urls or []:
|
||||
cleaned = (url or "").strip()
|
||||
if not cleaned:
|
||||
continue
|
||||
normalized = _image_ref_to_xai_input(cleaned)
|
||||
if not normalized:
|
||||
return None, (
|
||||
"reference_image_urls must be public HTTPS URLs or data URIs "
|
||||
"(e.g. the `image`/`public_url` from a prior Imagine result)"
|
||||
)
|
||||
refs.append(normalized)
|
||||
return (refs if refs else None), None
|
||||
normalized = _image_ref_to_xai_url(url)
|
||||
if normalized:
|
||||
refs.append({"url": normalized})
|
||||
return refs or None
|
||||
|
||||
|
||||
def _clamp_duration(
|
||||
duration: Optional[int],
|
||||
*,
|
||||
has_reference_images: bool = False,
|
||||
max_seconds: int = 15,
|
||||
default: int = DEFAULT_DURATION,
|
||||
) -> int:
|
||||
value = duration if duration is not None else default
|
||||
def _clamp_duration(duration: Optional[int], has_reference_images: bool) -> int:
|
||||
value = duration if duration is not None else DEFAULT_DURATION
|
||||
if value < 1:
|
||||
value = 1
|
||||
if value > max_seconds:
|
||||
value = max_seconds
|
||||
if value > 15:
|
||||
value = 15
|
||||
if has_reference_images and value > 10:
|
||||
value = 10
|
||||
return value
|
||||
@@ -269,7 +173,7 @@ def _resolve_model_for_modality(
|
||||
) -> str:
|
||||
"""Select xAI's text/video model without treating config as a prompt override.
|
||||
|
||||
``grok-imagine-video-1.5`` currently rejects text-only video
|
||||
``grok-imagine-video-1.5-preview`` currently rejects text-only video
|
||||
generation, but it is the desired image-to-video backend. Explicit tool
|
||||
``model=`` still wins for users who intentionally request another model.
|
||||
"""
|
||||
@@ -278,7 +182,7 @@ def _resolve_model_for_modality(
|
||||
return requested
|
||||
if modality == "image":
|
||||
return DEFAULT_IMAGE_TO_VIDEO_MODEL
|
||||
if requested == DEFAULT_IMAGE_TO_VIDEO_MODEL or requested in _IMAGE_TO_VIDEO_COMPAT_MODEL_IDS:
|
||||
if requested == DEFAULT_IMAGE_TO_VIDEO_MODEL:
|
||||
return DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
return requested or DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
|
||||
@@ -289,11 +193,11 @@ async def _submit(
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
endpoint: str = "generations",
|
||||
) -> str:
|
||||
"""POST to one of xAI's async video endpoints and return request_id."""
|
||||
"""POST to /videos/generations — xAI's only public endpoint for our
|
||||
text-to-video and image-to-video surface."""
|
||||
response = await client.post(
|
||||
f"{base_url}/videos/{endpoint}",
|
||||
f"{base_url}/videos/generations",
|
||||
headers={**_xai_headers(api_key), "x-idempotency-key": str(uuid.uuid4())},
|
||||
json=payload,
|
||||
timeout=60,
|
||||
@@ -344,7 +248,7 @@ async def _poll(
|
||||
|
||||
|
||||
class XAIVideoGenProvider(VideoGenProvider):
|
||||
"""xAI Grok Imagine video backend."""
|
||||
"""xAI Grok Imagine video backend (text-to-video + image-to-video)."""
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
@@ -371,25 +275,10 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
# Grok OAuth (SuperGrok / Premium+) — TTS / image gen / video gen
|
||||
# all share the same credential resolver. The hook offers an
|
||||
# OAuth-vs-API-key choice when neither is configured.
|
||||
try:
|
||||
from tools.xai_http import xai_storage_notice_text
|
||||
|
||||
storage_notice = xai_storage_notice_text("video_gen")
|
||||
except Exception:
|
||||
storage_notice = ""
|
||||
tag = (
|
||||
"grok-imagine-video for text/reference; "
|
||||
"grok-imagine-video-1.5 for image-to-video; "
|
||||
"edit/extend: pass the stored public HTTPS MP4 (`video` / "
|
||||
"`public_url` from a prior Imagine result); uses xAI Grok OAuth "
|
||||
"or XAI_API_KEY"
|
||||
)
|
||||
if storage_notice:
|
||||
tag += f". {storage_notice}"
|
||||
return {
|
||||
"name": "xAI Grok Imagine",
|
||||
"badge": "paid",
|
||||
"tag": tag,
|
||||
"tag": "grok-imagine-video for text-to-video; grok-imagine-video-1.5-preview for image-to-video; uses xAI Grok OAuth or XAI_API_KEY",
|
||||
"env_vars": [],
|
||||
"post_setup": "xai_grok",
|
||||
}
|
||||
@@ -421,479 +310,189 @@ class XAIVideoGenProvider(VideoGenProvider):
|
||||
seed: Optional[int] = None,
|
||||
**kwargs: Any,
|
||||
) -> Dict[str, Any]:
|
||||
return run_xai_video_generation(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
explicit_model=bool(kwargs.get("_model_override_explicit")),
|
||||
image_url=image_url,
|
||||
reference_image_urls=reference_image_urls,
|
||||
duration=duration,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
)
|
||||
|
||||
|
||||
def has_xai_video_credentials() -> bool:
|
||||
api_key, _ = _resolve_xai_credentials()
|
||||
return bool(api_key)
|
||||
|
||||
|
||||
def run_xai_video_generation(
|
||||
*,
|
||||
prompt: str,
|
||||
model: Optional[str],
|
||||
explicit_model: bool,
|
||||
image_url: Optional[str],
|
||||
reference_image_urls: Optional[List[str]],
|
||||
duration: Optional[int],
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
) -> Dict[str, Any]:
|
||||
return _run_xai_video_coroutine(
|
||||
_generate_xai_video_async(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
explicit_model=explicit_model,
|
||||
image_url=image_url,
|
||||
reference_image_urls=reference_image_urls,
|
||||
duration=duration,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
),
|
||||
operation_label="generation",
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
|
||||
def run_xai_video_edit(
|
||||
*,
|
||||
prompt: str,
|
||||
video_url: str,
|
||||
model: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return _run_xai_video_coroutine(
|
||||
_edit_xai_video_async(prompt=prompt, video_url=video_url, model=model),
|
||||
operation_label="edit",
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
aspect_ratio=DEFAULT_ASPECT_RATIO,
|
||||
)
|
||||
|
||||
|
||||
def run_xai_video_extend(
|
||||
*,
|
||||
prompt: str,
|
||||
video_url: str,
|
||||
duration: Optional[int] = None,
|
||||
model: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
return _run_xai_video_coroutine(
|
||||
_extend_xai_video_async(
|
||||
prompt=prompt,
|
||||
video_url=video_url,
|
||||
duration=duration,
|
||||
model=model,
|
||||
),
|
||||
operation_label="extend",
|
||||
model=model,
|
||||
prompt=prompt,
|
||||
aspect_ratio=DEFAULT_ASPECT_RATIO,
|
||||
)
|
||||
|
||||
|
||||
def _run_xai_video_coroutine(
|
||||
coro,
|
||||
*,
|
||||
operation_label: str,
|
||||
model: Optional[str],
|
||||
prompt: str,
|
||||
aspect_ratio: str,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
return loop.run_until_complete(coro)
|
||||
finally:
|
||||
loop.close()
|
||||
except Exception as exc:
|
||||
logger.warning("xAI video %s unexpected failure: %s", operation_label, exc, exc_info=True)
|
||||
return error_response(
|
||||
error=f"xAI video {operation_label} failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider="xai",
|
||||
model=model or DEFAULT_MODEL,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
|
||||
async def _generate_xai_video_async(
|
||||
*,
|
||||
prompt: str,
|
||||
model: Optional[str],
|
||||
explicit_model: bool,
|
||||
image_url: Optional[str],
|
||||
reference_image_urls: Optional[List[str]],
|
||||
duration: Optional[int],
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
) -> Dict[str, Any]:
|
||||
api_key, base_url = _resolve_xai_credentials()
|
||||
if not api_key:
|
||||
return _auth_required_response(prompt)
|
||||
|
||||
prompt = (prompt or "").strip()
|
||||
image_input = None
|
||||
if (image_url or "").strip():
|
||||
image_input = _image_ref_to_xai_input(image_url)
|
||||
if not image_input:
|
||||
return error_response(
|
||||
error=(
|
||||
"image_url must be a public HTTPS URL or data URI "
|
||||
"(e.g. the `image`/`public_url` from a prior Imagine result)"
|
||||
),
|
||||
error_type="invalid_image_url",
|
||||
provider="xai",
|
||||
prompt=prompt,
|
||||
)
|
||||
normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip()
|
||||
normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower()
|
||||
refs, refs_error = _normalize_reference_images(reference_image_urls)
|
||||
if refs_error:
|
||||
return error_response(
|
||||
error=refs_error,
|
||||
error_type="invalid_reference_image_urls",
|
||||
provider="xai",
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error="prompt is required for xAI video generation",
|
||||
error_type="missing_prompt",
|
||||
provider="xai", prompt=prompt,
|
||||
)
|
||||
if refs and len(refs) > MAX_REFERENCE_IMAGES:
|
||||
return error_response(
|
||||
error=f"reference_image_urls supports at most {MAX_REFERENCE_IMAGES} images on xAI",
|
||||
error_type="too_many_references",
|
||||
provider="xai", prompt=prompt,
|
||||
)
|
||||
if image_input and refs:
|
||||
return error_response(
|
||||
error="image_url and reference_image_urls cannot be combined on xAI",
|
||||
error_type="conflicting_inputs",
|
||||
provider="xai", prompt=prompt,
|
||||
)
|
||||
|
||||
if normalized_aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||
normalized_aspect_ratio = DEFAULT_ASPECT_RATIO
|
||||
if normalized_resolution not in VALID_RESOLUTIONS:
|
||||
normalized_resolution = DEFAULT_RESOLUTION
|
||||
|
||||
modality_used = "reference" if refs else ("image" if image_input else "text")
|
||||
resolved_model = _resolve_model_for_modality(
|
||||
model,
|
||||
modality=modality_used,
|
||||
explicit_model=explicit_model,
|
||||
)
|
||||
if refs and resolved_model != DEFAULT_TEXT_TO_VIDEO_MODEL:
|
||||
if explicit_model:
|
||||
return error_response(
|
||||
error=(
|
||||
"xAI reference-to-video requires "
|
||||
f"{DEFAULT_TEXT_TO_VIDEO_MODEL}; got {resolved_model}"
|
||||
),
|
||||
error_type="unsupported_model",
|
||||
provider="xai",
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
resolved_model = DEFAULT_TEXT_TO_VIDEO_MODEL
|
||||
|
||||
clamped_duration = _clamp_duration(duration, has_reference_images=bool(refs))
|
||||
payload = {
|
||||
"model": resolved_model,
|
||||
"prompt": prompt,
|
||||
"duration": clamped_duration,
|
||||
"aspect_ratio": normalized_aspect_ratio,
|
||||
"resolution": normalized_resolution,
|
||||
}
|
||||
if image_input:
|
||||
payload["image"] = image_input
|
||||
if refs:
|
||||
payload["reference_images"] = refs
|
||||
|
||||
return await _submit_xai_video_payload(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint="generations",
|
||||
payload=payload,
|
||||
prompt=prompt,
|
||||
resolved_model=resolved_model,
|
||||
modality=modality_used,
|
||||
aspect_ratio=normalized_aspect_ratio,
|
||||
duration=clamped_duration,
|
||||
operation="generate",
|
||||
resolution=normalized_resolution,
|
||||
)
|
||||
|
||||
|
||||
async def _run_xai_video_mutation(
|
||||
*,
|
||||
prompt: str,
|
||||
video_url: str,
|
||||
model: Optional[str],
|
||||
endpoint: str,
|
||||
operation: str,
|
||||
duration: int,
|
||||
) -> Dict[str, Any]:
|
||||
"""Edit or extend using a public HTTPS ``video_url`` input (``url`` on the wire)."""
|
||||
api_key, base_url = _resolve_xai_credentials()
|
||||
if not api_key:
|
||||
return _auth_required_response(prompt)
|
||||
|
||||
prompt = (prompt or "").strip()
|
||||
video_input = await _video_input_from_public_url(
|
||||
video_url or "",
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
)
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error="prompt is required for xAI video edit/extend",
|
||||
error_type="missing_prompt",
|
||||
provider="xai",
|
||||
prompt=prompt,
|
||||
)
|
||||
if not video_input:
|
||||
return error_response(
|
||||
error=(
|
||||
"video_url must be a public HTTPS MP4 URL "
|
||||
"(the `video`/`public_url` from a prior Imagine result)"
|
||||
),
|
||||
error_type="missing_video",
|
||||
provider="xai",
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
resolved_model = _resolve_model_for_modality(
|
||||
model,
|
||||
modality="text",
|
||||
explicit_model=bool(model),
|
||||
)
|
||||
payload: Dict[str, Any] = {
|
||||
"model": resolved_model,
|
||||
"prompt": prompt,
|
||||
"video": video_input,
|
||||
}
|
||||
if endpoint == "extensions":
|
||||
payload["duration"] = duration
|
||||
|
||||
return await _submit_xai_video_payload(
|
||||
api_key=api_key,
|
||||
base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
payload=payload,
|
||||
prompt=prompt,
|
||||
resolved_model=resolved_model,
|
||||
modality=operation,
|
||||
aspect_ratio=DEFAULT_ASPECT_RATIO,
|
||||
duration=duration,
|
||||
operation=operation,
|
||||
)
|
||||
|
||||
|
||||
async def _edit_xai_video_async(
|
||||
*,
|
||||
prompt: str,
|
||||
video_url: str,
|
||||
model: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
return await _run_xai_video_mutation(
|
||||
prompt=prompt,
|
||||
video_url=video_url,
|
||||
model=model,
|
||||
endpoint="edits",
|
||||
operation="edit",
|
||||
duration=DEFAULT_DURATION,
|
||||
)
|
||||
|
||||
|
||||
async def _extend_xai_video_async(
|
||||
*,
|
||||
prompt: str,
|
||||
video_url: str,
|
||||
duration: Optional[int],
|
||||
model: Optional[str],
|
||||
) -> Dict[str, Any]:
|
||||
clamped_duration = _clamp_duration(
|
||||
duration,
|
||||
max_seconds=10,
|
||||
default=DEFAULT_EXTEND_DURATION,
|
||||
)
|
||||
return await _run_xai_video_mutation(
|
||||
prompt=prompt,
|
||||
video_url=video_url,
|
||||
model=model,
|
||||
endpoint="extensions",
|
||||
operation="extend",
|
||||
duration=clamped_duration,
|
||||
)
|
||||
|
||||
|
||||
def _auth_required_response(prompt: str) -> Dict[str, Any]:
|
||||
return error_response(
|
||||
error=(
|
||||
"No xAI credentials found. Sign in via `hermes auth add xai-oauth` "
|
||||
"(SuperGrok / Premium+) or set XAI_API_KEY from "
|
||||
"https://console.x.ai/."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="xai", prompt=prompt,
|
||||
)
|
||||
|
||||
|
||||
async def _submit_xai_video_payload(
|
||||
*,
|
||||
api_key: str,
|
||||
base_url: str,
|
||||
endpoint: str,
|
||||
payload: Dict[str, Any],
|
||||
prompt: str,
|
||||
resolved_model: str,
|
||||
modality: str,
|
||||
aspect_ratio: str,
|
||||
duration: int,
|
||||
operation: str,
|
||||
resolution: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
try:
|
||||
from tools.xai_http import (
|
||||
build_xai_storage_options,
|
||||
maybe_mark_xai_storage_notice_seen,
|
||||
read_xai_imagine_storage_config,
|
||||
)
|
||||
|
||||
storage_options = build_xai_storage_options(
|
||||
"video_gen",
|
||||
filename_prefix="hermes-xai-video",
|
||||
extension="mp4",
|
||||
)
|
||||
storage_notice = maybe_mark_xai_storage_notice_seen("video_gen")
|
||||
storage_cfg = read_xai_imagine_storage_config("video_gen")
|
||||
except Exception:
|
||||
storage_options = None
|
||||
storage_notice = None
|
||||
storage_cfg = {"enabled": False}
|
||||
if storage_options is not None:
|
||||
payload["storage_options"] = storage_options
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
request_id = await _submit(
|
||||
client, payload, api_key=api_key, base_url=base_url,
|
||||
endpoint=endpoint,
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = ""
|
||||
loop = asyncio.new_event_loop()
|
||||
try:
|
||||
detail = exc.response.text[:500]
|
||||
except Exception:
|
||||
pass
|
||||
return loop.run_until_complete(self._generate_async(
|
||||
prompt=prompt,
|
||||
model=model,
|
||||
explicit_model=bool(kwargs.get("_model_override_explicit")),
|
||||
image_url=image_url,
|
||||
reference_image_urls=reference_image_urls,
|
||||
duration=duration,
|
||||
aspect_ratio=aspect_ratio,
|
||||
resolution=resolution,
|
||||
))
|
||||
finally:
|
||||
loop.close()
|
||||
except Exception as exc:
|
||||
logger.warning("xAI video gen unexpected failure: %s", exc, exc_info=True)
|
||||
return error_response(
|
||||
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
|
||||
error=f"xAI video generation failed: {exc}",
|
||||
error_type="api_error",
|
||||
provider="xai",
|
||||
model=model or DEFAULT_MODEL,
|
||||
prompt=prompt,
|
||||
aspect_ratio=aspect_ratio,
|
||||
)
|
||||
|
||||
async def _generate_async(
|
||||
self,
|
||||
*,
|
||||
prompt: str,
|
||||
model: Optional[str],
|
||||
explicit_model: bool,
|
||||
image_url: Optional[str],
|
||||
reference_image_urls: Optional[List[str]],
|
||||
duration: Optional[int],
|
||||
aspect_ratio: str,
|
||||
resolution: str,
|
||||
) -> Dict[str, Any]:
|
||||
api_key, base_url = _resolve_xai_credentials()
|
||||
if not api_key:
|
||||
return error_response(
|
||||
error=(
|
||||
"No xAI credentials found. Sign in via `hermes auth add xai-oauth` "
|
||||
"(SuperGrok / Premium+) or set XAI_API_KEY from "
|
||||
"https://console.x.ai/."
|
||||
),
|
||||
error_type="auth_required",
|
||||
provider="xai", prompt=prompt,
|
||||
)
|
||||
|
||||
prompt = (prompt or "").strip()
|
||||
image_url_norm = _image_ref_to_xai_url(image_url or "") or None
|
||||
normalized_aspect_ratio = (aspect_ratio or DEFAULT_ASPECT_RATIO).strip()
|
||||
normalized_resolution = (resolution or DEFAULT_RESOLUTION).strip().lower()
|
||||
modality_used = "image" if image_url_norm else "text"
|
||||
resolved_model = _resolve_model_for_modality(
|
||||
model,
|
||||
modality=modality_used,
|
||||
explicit_model=explicit_model,
|
||||
)
|
||||
|
||||
if not prompt:
|
||||
return error_response(
|
||||
error=(
|
||||
"prompt is required for xAI video generation "
|
||||
"(text-to-video or image-to-video)"
|
||||
),
|
||||
error_type="missing_prompt",
|
||||
provider="xai", prompt=prompt,
|
||||
)
|
||||
|
||||
refs = _normalize_reference_images(reference_image_urls)
|
||||
if refs and len(refs) > MAX_REFERENCE_IMAGES:
|
||||
return error_response(
|
||||
error=f"reference_image_urls supports at most {MAX_REFERENCE_IMAGES} images on xAI",
|
||||
error_type="too_many_references",
|
||||
provider="xai", prompt=prompt,
|
||||
)
|
||||
if image_url_norm and refs:
|
||||
return error_response(
|
||||
error="image_url and reference_image_urls cannot be combined on xAI",
|
||||
error_type="conflicting_inputs",
|
||||
provider="xai", prompt=prompt,
|
||||
)
|
||||
|
||||
clamped_duration = _clamp_duration(duration, has_reference_images=bool(refs))
|
||||
|
||||
if normalized_aspect_ratio not in VALID_ASPECT_RATIOS:
|
||||
normalized_aspect_ratio = DEFAULT_ASPECT_RATIO
|
||||
if normalized_resolution not in VALID_RESOLUTIONS:
|
||||
normalized_resolution = DEFAULT_RESOLUTION
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": resolved_model,
|
||||
"prompt": prompt,
|
||||
"duration": clamped_duration,
|
||||
"aspect_ratio": normalized_aspect_ratio,
|
||||
"resolution": normalized_resolution,
|
||||
}
|
||||
if image_url_norm:
|
||||
payload["image"] = {"url": image_url_norm}
|
||||
if refs:
|
||||
payload["reference_images"] = refs
|
||||
|
||||
async with httpx.AsyncClient() as client:
|
||||
try:
|
||||
request_id = await _submit(
|
||||
client, payload, api_key=api_key, base_url=base_url
|
||||
)
|
||||
except httpx.HTTPStatusError as exc:
|
||||
detail = ""
|
||||
try:
|
||||
detail = exc.response.text[:500]
|
||||
except Exception:
|
||||
pass
|
||||
return error_response(
|
||||
error=f"xAI submit failed ({exc.response.status_code}): {detail or exc}",
|
||||
error_type="api_error",
|
||||
provider="xai",
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
poll_result = await _poll(
|
||||
client, request_id,
|
||||
api_key=api_key, base_url=base_url,
|
||||
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
||||
poll_interval=DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
)
|
||||
|
||||
status = poll_result["status"]
|
||||
body = poll_result["body"]
|
||||
|
||||
if status == "done":
|
||||
video = body.get("video") or {}
|
||||
url = video.get("url")
|
||||
if not url:
|
||||
return error_response(
|
||||
error="xAI video generation completed without a video URL",
|
||||
error_type="empty_response",
|
||||
provider="xai",
|
||||
model=body.get("model") or resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
extra: Dict[str, Any] = {
|
||||
"request_id": request_id,
|
||||
"resolution": normalized_resolution,
|
||||
}
|
||||
if body.get("usage"):
|
||||
extra["usage"] = body["usage"]
|
||||
return success_response(
|
||||
video=url,
|
||||
model=body.get("model") or resolved_model,
|
||||
prompt=prompt,
|
||||
modality=modality_used,
|
||||
aspect_ratio=normalized_aspect_ratio,
|
||||
duration=video.get("duration") or clamped_duration,
|
||||
provider="xai",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
if status == "timeout":
|
||||
return error_response(
|
||||
error=f"Timed out waiting for video generation after {DEFAULT_TIMEOUT_SECONDS}s",
|
||||
error_type="timeout",
|
||||
provider="xai",
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
poll_result = await _poll(
|
||||
client, request_id,
|
||||
api_key=api_key, base_url=base_url,
|
||||
timeout_seconds=DEFAULT_TIMEOUT_SECONDS,
|
||||
poll_interval=DEFAULT_POLL_INTERVAL_SECONDS,
|
||||
message = (
|
||||
(body.get("error", {}) or {}).get("message")
|
||||
or body.get("message")
|
||||
or f"xAI video generation ended with status '{status}'"
|
||||
)
|
||||
|
||||
status = poll_result["status"]
|
||||
body = poll_result["body"]
|
||||
|
||||
if status == "done":
|
||||
video = body.get("video") or {}
|
||||
if not isinstance(video, dict):
|
||||
video = {}
|
||||
file_output = video.get("file_output") if isinstance(video.get("file_output"), dict) else {}
|
||||
file_output = file_output or {}
|
||||
public_video_url, temporary_url, stored_public_url = _xai_video_output_urls(video)
|
||||
if not public_video_url:
|
||||
return error_response(
|
||||
error="xAI video request completed without a video URL",
|
||||
error_type="empty_response",
|
||||
provider="xai",
|
||||
model=body.get("model") or resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
extra: Dict[str, Any] = {
|
||||
"request_id": request_id,
|
||||
"operation": operation,
|
||||
"storage_enabled": bool(storage_cfg.get("enabled")),
|
||||
}
|
||||
if resolution:
|
||||
extra["resolution"] = resolution
|
||||
if storage_notice:
|
||||
extra["storage_notice"] = storage_notice
|
||||
if stored_public_url:
|
||||
extra["public_url"] = stored_public_url
|
||||
if temporary_url:
|
||||
extra["temporary_url"] = temporary_url
|
||||
if file_output:
|
||||
for key in (
|
||||
"filename",
|
||||
"expires_at",
|
||||
"public_url_expires_at",
|
||||
"public_url_error",
|
||||
"storage_error",
|
||||
):
|
||||
if key in file_output:
|
||||
extra[key] = file_output[key]
|
||||
if body.get("usage"):
|
||||
extra["usage"] = body["usage"]
|
||||
return success_response(
|
||||
video=public_video_url,
|
||||
model=body.get("model") or resolved_model,
|
||||
prompt=prompt,
|
||||
modality=modality,
|
||||
aspect_ratio=aspect_ratio,
|
||||
duration=video.get("duration") or duration,
|
||||
provider="xai",
|
||||
extra=extra,
|
||||
)
|
||||
|
||||
if status == "timeout":
|
||||
return error_response(
|
||||
error=f"Timed out waiting for xAI video request after {DEFAULT_TIMEOUT_SECONDS}s",
|
||||
error_type="timeout",
|
||||
error=message,
|
||||
error_type=f"xai_{status}",
|
||||
provider="xai",
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
message = (
|
||||
(body.get("error", {}) or {}).get("message")
|
||||
or body.get("message")
|
||||
or f"xAI video request ended with status '{status}'"
|
||||
)
|
||||
return error_response(
|
||||
error=message,
|
||||
error_type=f"xai_{status}",
|
||||
provider="xai",
|
||||
model=resolved_model,
|
||||
prompt=prompt,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Plugin entry point
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
name: xai
|
||||
version: 1.0.0
|
||||
description: "xAI Grok Imagine video generation backend. Supports text-to-video, image-to-video, reference-to-video, video editing, video extension, and stored public URLs via the xAI async videos API."
|
||||
description: "xAI Grok Imagine video generation backend. Supports text-to-video, image-to-video, and reference-image-guided generation via the xAI async videos API."
|
||||
author: NousResearch
|
||||
kind: backend
|
||||
requires_env:
|
||||
|
||||
@@ -1,782 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Collect CI job/step timings from the GitHub API and generate an HTML diff report.
|
||||
|
||||
In CI, the script reads GITHUB_TOKEN, GITHUB_REPOSITORY, GITHUB_RUN_ID, and
|
||||
GITHUB_SHA from the environment to collect timings via the REST API.
|
||||
|
||||
If a baseline JSON file (ci-timings-baseline.json by default) exists, the
|
||||
report includes a diff with per-job and per-step deltas, plus a gantt chart
|
||||
overlaying current vs baseline bars.
|
||||
|
||||
Usage:
|
||||
# Collect from API (CI mode):
|
||||
python scripts/ci/timings_report.py
|
||||
|
||||
# Regenerate HTML from saved JSON (testing):
|
||||
python scripts/ci/timings_report.py --from-json ci-timings.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import urllib.error
|
||||
import urllib.parse
|
||||
import urllib.request
|
||||
from datetime import datetime
|
||||
from html import escape
|
||||
|
||||
API_BASE = "https://api.github.com"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GitHub API helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def api_get(path: str, token: str, params: dict | None = None,
|
||||
list_key: str | None = None) -> list | dict:
|
||||
"""Authenticated GitHub API GET with automatic pagination.
|
||||
|
||||
For list endpoints, pass list_key to extract items from the paginated
|
||||
wrapper response (e.g. list_key='jobs' for {'total_count': N, 'jobs': [...]}).
|
||||
When list_key is omitted, a non-list response is returned as-is (single object).
|
||||
"""
|
||||
url = f"{API_BASE}{path}"
|
||||
if params:
|
||||
url += "?" + urllib.parse.urlencode(params)
|
||||
|
||||
results: list = []
|
||||
while url:
|
||||
req = urllib.request.Request(url, headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
"X-GitHub-Api-Version": "2022-11-28",
|
||||
"User-Agent": "ci-timings-report",
|
||||
})
|
||||
with urllib.request.urlopen(req) as resp:
|
||||
data = json.loads(resp.read())
|
||||
link_header = resp.headers.get("Link", "")
|
||||
|
||||
if list_key:
|
||||
results.extend(data.get(list_key, []))
|
||||
elif isinstance(data, list):
|
||||
results.extend(data)
|
||||
else:
|
||||
return data
|
||||
|
||||
next_url = None
|
||||
for part in link_header.split(","):
|
||||
part = part.strip()
|
||||
if 'rel="next"' in part:
|
||||
next_url = part[part.find("<") + 1:part.find(">")]
|
||||
break
|
||||
url = next_url
|
||||
|
||||
return results
|
||||
|
||||
|
||||
def parse_ts(ts: str | None) -> datetime | None:
|
||||
if not ts:
|
||||
return None
|
||||
return datetime.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
|
||||
|
||||
def dur_s(started: str | None, completed: str | None) -> float | None:
|
||||
s = parse_ts(started)
|
||||
e = parse_ts(completed)
|
||||
if not s or not e:
|
||||
return None
|
||||
return (e - s).total_seconds()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Timings collection
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _normalize_job(raw: dict) -> dict:
|
||||
steps = []
|
||||
for step in (raw.get("steps") or []):
|
||||
steps.append({
|
||||
"name": step.get("name", ""),
|
||||
"number": step.get("number", 0),
|
||||
"status": step.get("status", ""),
|
||||
"conclusion": step.get("conclusion", ""),
|
||||
"started_at": step.get("started_at"),
|
||||
"completed_at": step.get("completed_at"),
|
||||
"duration_s": dur_s(step.get("started_at"), step.get("completed_at")),
|
||||
})
|
||||
return {
|
||||
"name": raw.get("name", "unknown"),
|
||||
"workflow_name": raw.get("_workflow_name", ""),
|
||||
"job_id": raw.get("id"),
|
||||
"status": raw.get("status", ""),
|
||||
"conclusion": raw.get("conclusion", ""),
|
||||
"started_at": raw.get("started_at"),
|
||||
"completed_at": raw.get("completed_at"),
|
||||
"duration_s": dur_s(raw.get("started_at"), raw.get("completed_at")),
|
||||
"html_url": raw.get("html_url", ""),
|
||||
"steps": steps,
|
||||
}
|
||||
|
||||
|
||||
def collect_timings(token: str, repo: str, run_id: str, head_sha: str) -> dict:
|
||||
"""Collect job/step timings from the GitHub API.
|
||||
|
||||
1. Get orchestrator run's direct jobs (detect, all-checks-pass, etc.).
|
||||
Skip workflow-call placeholder jobs (step name starts with "Run ./.github/").
|
||||
2. Find sub-workflow runs via head_sha + event=workflow_call.
|
||||
3. Get each sub-workflow run's jobs with full step timing.
|
||||
"""
|
||||
owner, repo_name = repo.split("/")
|
||||
|
||||
# Orchestrator run info
|
||||
run_info = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}", token)
|
||||
created_at = run_info.get("created_at", "")
|
||||
|
||||
# Orchestrator direct jobs
|
||||
orch_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs",
|
||||
token, list_key="jobs")
|
||||
|
||||
direct = []
|
||||
for job in orch_jobs:
|
||||
steps = job.get("steps") or []
|
||||
if any(s.get("name", "").startswith("Run ./.github/") for s in steps):
|
||||
continue # workflow-call placeholder
|
||||
if job.get("status") in ("in_progress", "queued"):
|
||||
continue # skip self / unfinished
|
||||
direct.append(job)
|
||||
|
||||
# Sub-workflow runs
|
||||
sub_runs = api_get(f"/repos/{owner}/{repo_name}/actions/runs", token, params={
|
||||
"head_sha": head_sha,
|
||||
"event": "workflow_call",
|
||||
"per_page": 100,
|
||||
}, list_key="workflow_runs")
|
||||
sub_runs = [r for r in sub_runs if r.get("created_at", "") >= created_at]
|
||||
|
||||
sub_jobs_raw = []
|
||||
for sr in sub_runs:
|
||||
sr_id = sr["id"]
|
||||
sr_name = sr.get("name", "")
|
||||
sr_jobs = api_get(f"/repos/{owner}/{repo_name}/actions/runs/{sr_id}/jobs",
|
||||
token, list_key="jobs")
|
||||
for j in sr_jobs:
|
||||
j["_workflow_name"] = sr_name
|
||||
j["_workflow_run_id"] = sr_id
|
||||
sub_jobs_raw.append(j)
|
||||
|
||||
# Normalize + sort
|
||||
all_jobs = [_normalize_job(j) for j in direct + sub_jobs_raw]
|
||||
all_jobs = [j for j in all_jobs if j["status"] not in ("in_progress", "queued")]
|
||||
all_jobs.sort(key=lambda j: j.get("started_at") or "")
|
||||
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"head_sha": head_sha,
|
||||
"created_at": created_at,
|
||||
"jobs": all_jobs,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Formatting helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def fmt_dur(seconds: float | None) -> str:
|
||||
if seconds is None:
|
||||
return "—"
|
||||
if seconds < 60:
|
||||
return f"{seconds:.1f}s"
|
||||
m = int(seconds // 60)
|
||||
s = seconds % 60
|
||||
if s == 0:
|
||||
return f"{m}m"
|
||||
return f"{m}m{s:.0f}s"
|
||||
|
||||
|
||||
def fmt_delta(current: float | None, baseline: float | None) -> tuple[str, str]:
|
||||
"""Return (text, css_class) for a delta."""
|
||||
if current is None or baseline is None:
|
||||
return ("—", "neutral")
|
||||
delta = current - baseline
|
||||
if baseline == 0:
|
||||
pct_str = "new" if delta > 0 else "0%"
|
||||
else:
|
||||
pct = (delta / baseline) * 100
|
||||
pct_str = f"{pct:+.1f}%"
|
||||
if abs(delta) < 1.0:
|
||||
cls = "neutral"
|
||||
elif delta > 0:
|
||||
cls = "slower"
|
||||
else:
|
||||
cls = "faster"
|
||||
sign = "+" if delta >= 0 else ""
|
||||
return (f"{sign}{delta:.1f}s ({pct_str})", cls)
|
||||
|
||||
|
||||
def nice_ticks(max_seconds: float, num_ticks: int = 8) -> list[int]:
|
||||
if max_seconds <= 0:
|
||||
return [0]
|
||||
raw = max_seconds / num_ticks
|
||||
for nice in [5, 10, 15, 30, 60, 120, 180, 300, 600, 900, 1800, 3600, 7200]:
|
||||
if nice >= raw:
|
||||
step = nice
|
||||
break
|
||||
else:
|
||||
step = max(int(raw), 3600)
|
||||
return list(range(0, int(max_seconds) + step + 1, step))
|
||||
|
||||
|
||||
def fmt_tick(seconds: int) -> str:
|
||||
if seconds < 60:
|
||||
return f"{seconds}s"
|
||||
m, s = divmod(seconds, 60)
|
||||
if s == 0:
|
||||
return f"{m}m"
|
||||
return f"{m}m{s}s"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Stats computation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def compute_stats(timings: dict, baseline: dict | None = None) -> dict:
|
||||
jobs = timings.get("jobs", [])
|
||||
bl_jobs = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
||||
|
||||
# Wall time
|
||||
starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
|
||||
ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
|
||||
wall = (max(ends) - min(starts)).total_seconds() if starts and ends else 0
|
||||
compute = sum(j.get("duration_s") or 0 for j in jobs)
|
||||
|
||||
# Baseline wall/compute
|
||||
bl_wall = None
|
||||
bl_compute = None
|
||||
if baseline:
|
||||
bl_starts = [s for s in (parse_ts(j.get("started_at")) for j in baseline.get("jobs", [])) if s is not None]
|
||||
bl_ends = [e for e in (parse_ts(j.get("completed_at")) for j in baseline.get("jobs", [])) if e is not None]
|
||||
if bl_starts and bl_ends:
|
||||
bl_wall = (max(bl_ends) - min(bl_starts)).total_seconds()
|
||||
bl_compute = sum(j.get("duration_s") or 0 for j in baseline.get("jobs", []))
|
||||
|
||||
# Per-job deltas
|
||||
faster = 0
|
||||
slower = 0
|
||||
unchanged = 0
|
||||
no_baseline = 0
|
||||
for j in jobs:
|
||||
bl = bl_jobs.get(j["name"])
|
||||
if not bl:
|
||||
no_baseline += 1
|
||||
continue
|
||||
cur_d = j.get("duration_s") or 0
|
||||
bl_d = bl.get("duration_s") or 0
|
||||
if abs(cur_d - bl_d) < 1.0:
|
||||
unchanged += 1
|
||||
elif cur_d > bl_d:
|
||||
slower += 1
|
||||
else:
|
||||
faster += 1
|
||||
|
||||
return {
|
||||
"wall": wall,
|
||||
"compute": compute,
|
||||
"bl_wall": bl_wall,
|
||||
"bl_compute": bl_compute,
|
||||
"faster": faster,
|
||||
"slower": slower,
|
||||
"unchanged": unchanged,
|
||||
"no_baseline": no_baseline,
|
||||
"total_jobs": len(jobs),
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HTML generation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
CSS = """
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
|
||||
background: #0d1117; color: #e6edf3; line-height: 1.5; padding: 24px;
|
||||
}
|
||||
h1 { font-size: 24px; border-bottom: 1px solid #30363d; padding-bottom: 12px; margin-bottom: 8px; }
|
||||
.meta { color: #8b949e; font-size: 13px; margin-bottom: 24px; }
|
||||
h2 { font-size: 18px; margin: 32px 0 12px; }
|
||||
|
||||
/* Stats cards */
|
||||
.stats { display: flex; gap: 12px; flex-wrap: wrap; margin-bottom: 24px; }
|
||||
.stat-card {
|
||||
background: #161b22; border: 1px solid #30363d; border-radius: 8px;
|
||||
padding: 14px 18px; min-width: 140px;
|
||||
}
|
||||
.stat-label { font-size: 12px; color: #8b949e; text-transform: uppercase; letter-spacing: 0.5px; }
|
||||
.stat-value { font-size: 22px; font-weight: 600; margin: 4px 0; }
|
||||
.stat-delta { font-size: 13px; }
|
||||
.faster { color: #3fb950; }
|
||||
.slower { color: #f85149; }
|
||||
.neutral { color: #8b949e; }
|
||||
|
||||
/* Gantt */
|
||||
.gantt-wrap { overflow-x: auto; }
|
||||
.gantt { min-width: 700px; }
|
||||
.gantt-row { display: flex; align-items: center; height: 28px; }
|
||||
.gantt-label {
|
||||
width: 220px; padding-right: 12px; text-align: right;
|
||||
font-size: 12px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
||||
}
|
||||
.gantt-track { flex: 1; position: relative; height: 100%; border-left: 1px solid #21262d; }
|
||||
.gantt-bar {
|
||||
position: absolute; height: 18px; border-radius: 3px;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 10px; color: transparent; overflow: hidden;
|
||||
transition: color 0.15s;
|
||||
}
|
||||
.gantt-bar:hover { color: #fff; z-index: 10; }
|
||||
.gantt-bar.current { background: #1f6feb; top: 5px; z-index: 2; }
|
||||
.gantt-bar.baseline {
|
||||
background: transparent; border: 1px dashed #8b949e; top: 2px; height: 24px; z-index: 1;
|
||||
}
|
||||
.gantt-axis { display: flex; height: 20px; position: relative; border-top: 1px solid #30363d; margin-top: 4px; }
|
||||
.gantt-tick { position: absolute; font-size: 10px; color: #8b949e; transform: translateX(-50%); top: 4px; }
|
||||
.gantt-tick::before { content: ''; position: absolute; top: -4px; left: 50%; width: 1px; height: 4px; background: #30363d; }
|
||||
.legend { display: flex; gap: 16px; margin-top: 8px; font-size: 12px; color: #8b949e; }
|
||||
.legend-swatch { display: inline-block; width: 16px; height: 10px; border-radius: 2px; margin-right: 4px; vertical-align: middle; }
|
||||
|
||||
/* Tables */
|
||||
table { border-collapse: collapse; width: 100%; font-size: 13px; margin-bottom: 16px; }
|
||||
th, td { border: 1px solid #30363d; padding: 6px 10px; text-align: left; }
|
||||
th { background: #161b22; font-weight: 600; position: sticky; top: 0; }
|
||||
tr:hover td { background: #161b22; }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.job-name { font-weight: 500; }
|
||||
|
||||
/* Step details */
|
||||
details { margin-bottom: 8px; background: #161b22; border: 1px solid #30363d; border-radius: 6px; }
|
||||
summary { padding: 8px 12px; cursor: pointer; font-weight: 500; font-size: 14px; user-select: none; }
|
||||
summary:hover { background: #21262d; }
|
||||
details[open] summary { border-bottom: 1px solid #30363d; }
|
||||
details table { border: none; margin: 0; }
|
||||
details td, details th { font-size: 12px; }
|
||||
|
||||
/* Worst regressions */
|
||||
.regressions { margin-bottom: 24px; }
|
||||
.regressions table { font-size: 13px; }
|
||||
.tag {
|
||||
display: inline-block; padding: 1px 6px; border-radius: 3px; font-size: 11px; font-weight: 500;
|
||||
}
|
||||
.tag.slow { background: rgba(248,81,73,0.15); color: #f85149; }
|
||||
.tag.fast { background: rgba(63,185,80,0.15); color: #3fb950; }
|
||||
"""
|
||||
|
||||
|
||||
def _gantt_bars(timings: dict, baseline: dict | None) -> str:
|
||||
"""Render the gantt chart HTML.
|
||||
|
||||
Both current and baseline timelines are normalized to start at t=0
|
||||
(relative to each run's earliest job start). The axis scale spans
|
||||
0..max_end across both runs so bars are directly comparable.
|
||||
"""
|
||||
jobs = [j for j in timings.get("jobs", []) if j.get("started_at") and j.get("completed_at")]
|
||||
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
||||
|
||||
# Current run: relative offsets from earliest start
|
||||
cur_starts = [s for s in (parse_ts(j.get("started_at")) for j in jobs) if s is not None]
|
||||
cur_ends = [e for e in (parse_ts(j.get("completed_at")) for j in jobs) if e is not None]
|
||||
if not cur_starts or not cur_ends:
|
||||
return '<p style="color:#8b949e">No timing data available.</p>'
|
||||
cur_t0 = min(cur_starts)
|
||||
cur_max = (max(cur_ends) - cur_t0).total_seconds()
|
||||
|
||||
# Baseline run: relative offsets from its earliest start
|
||||
bl_t0 = None
|
||||
bl_max = 0.0
|
||||
bl_jobs_timed = []
|
||||
for bl_j in bl_map.values():
|
||||
s = parse_ts(bl_j.get("started_at"))
|
||||
e = parse_ts(bl_j.get("completed_at"))
|
||||
if s is not None and e is not None:
|
||||
bl_jobs_timed.append((bl_j, s, e))
|
||||
if bl_t0 is None or s < bl_t0:
|
||||
bl_t0 = s
|
||||
rel_end = (e - s).total_seconds() + (s - (bl_t0 or s)).total_seconds()
|
||||
if bl_t0 is not None:
|
||||
bl_max = max((e - bl_t0).total_seconds() for _, _, e in bl_jobs_timed) if bl_jobs_timed else 0
|
||||
|
||||
total_s = max(cur_max, bl_max)
|
||||
if total_s <= 0:
|
||||
total_s = 1
|
||||
|
||||
rows = []
|
||||
for j in jobs:
|
||||
s = parse_ts(j.get("started_at"))
|
||||
e = parse_ts(j.get("completed_at"))
|
||||
if s is None or e is None:
|
||||
continue
|
||||
left = (s - cur_t0).total_seconds() / total_s * 100
|
||||
width = max((e - s).total_seconds() / total_s * 100, 0.5) # min 0.5% for visibility
|
||||
dur = j.get("duration_s") or 0
|
||||
|
||||
bl = bl_map.get(j["name"])
|
||||
bl_bar = ""
|
||||
if bl and bl_t0 is not None:
|
||||
bl_s = parse_ts(bl.get("started_at"))
|
||||
bl_e = parse_ts(bl.get("completed_at"))
|
||||
if bl_s is not None and bl_e is not None:
|
||||
bl_left = (bl_s - bl_t0).total_seconds() / total_s * 100
|
||||
bl_width = max((bl_e - bl_s).total_seconds() / total_s * 100, 0.5)
|
||||
bl_dur = bl.get("duration_s") or 0
|
||||
bl_bar = (
|
||||
f'<div class="gantt-bar baseline" '
|
||||
f'style="left:{bl_left:.2f}%;width:{bl_width:.2f}%" '
|
||||
f'title="baseline: {fmt_dur(bl_dur)}"></div>'
|
||||
)
|
||||
|
||||
name_display = escape(j["name"])
|
||||
if j.get("workflow_name"):
|
||||
name_display = f'{escape(j["workflow_name"])} / {escape(j["name"])}'
|
||||
|
||||
delta_info = ""
|
||||
if bl and bl.get("duration_s") is not None:
|
||||
d_text, d_cls = fmt_delta(dur, bl.get("duration_s"))
|
||||
delta_info = f' — {d_text}'
|
||||
|
||||
rows.append(
|
||||
f'<div class="gantt-row">'
|
||||
f'<div class="gantt-label" title="{escape(j["name"])}">{name_display}</div>'
|
||||
f'<div class="gantt-track">'
|
||||
f'{bl_bar}'
|
||||
f'<div class="gantt-bar current" '
|
||||
f'style="left:{left:.2f}%;width:{width:.2f}%" '
|
||||
f'title="{escape(j["name"])}: {fmt_dur(dur)}{delta_info}"></div>'
|
||||
f'</div></div>'
|
||||
)
|
||||
|
||||
# Axis
|
||||
ticks = nice_ticks(total_s)
|
||||
tick_html = "".join(
|
||||
f'<span class="gantt-tick" style="left:{(t / total_s * 100):.1f}%">{fmt_tick(t)}</span>'
|
||||
for t in ticks
|
||||
)
|
||||
axis = f'<div class="gantt-axis"><div class="gantt-track">{tick_html}</div></div>'
|
||||
|
||||
legend = (
|
||||
'<div class="legend">'
|
||||
'<span><span class="legend-swatch" style="background:#1f6feb"></span>Current</span>'
|
||||
)
|
||||
if baseline:
|
||||
legend += '<span><span class="legend-swatch" style="border:1px dashed #8b949e"></span>Baseline (main)</span>'
|
||||
legend += '</div>'
|
||||
|
||||
return f'<div class="gantt-wrap"><div class="gantt">{"".join(rows)}{axis}</div></div>{legend}'
|
||||
|
||||
|
||||
def _stats_cards(stats: dict) -> str:
|
||||
wall_text = fmt_dur(stats["wall"])
|
||||
wall_delta = ""
|
||||
if stats["bl_wall"] is not None:
|
||||
d, cls = fmt_delta(stats["wall"], stats["bl_wall"])
|
||||
wall_delta = f'<span class="stat-delta {cls}">{d}</span>'
|
||||
|
||||
compute_text = fmt_dur(stats["compute"])
|
||||
compute_delta = ""
|
||||
if stats["bl_compute"] is not None:
|
||||
d, cls = fmt_delta(stats["compute"], stats["bl_compute"])
|
||||
compute_delta = f'<span class="stat-delta {cls}">{d}</span>'
|
||||
|
||||
cards = [
|
||||
f'<div class="stat-card"><span class="stat-label">Wall Time</span>'
|
||||
f'<div class="stat-value">{wall_text}</div>{wall_delta}</div>',
|
||||
f'<div class="stat-card"><span class="stat-label">Total Compute</span>'
|
||||
f'<div class="stat-value">{compute_text}</div>{compute_delta}</div>',
|
||||
f'<div class="stat-card"><span class="stat-label">Jobs Faster</span>'
|
||||
f'<div class="stat-value faster">{stats["faster"]}</div></div>',
|
||||
f'<div class="stat-card"><span class="stat-label">Jobs Slower</span>'
|
||||
f'<div class="stat-value slower">{stats["slower"]}</div></div>',
|
||||
f'<div class="stat-card"><span class="stat-label">Unchanged</span>'
|
||||
f'<div class="stat-value neutral">{stats["unchanged"]}</div></div>',
|
||||
f'<div class="stat-card"><span class="stat-label">No Baseline</span>'
|
||||
f'<div class="stat-value neutral">{stats["no_baseline"]}</div></div>',
|
||||
]
|
||||
return f'<div class="stats">{"".join(cards)}</div>'
|
||||
|
||||
|
||||
def _job_table(timings: dict, baseline: dict | None) -> str:
|
||||
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
||||
rows = []
|
||||
for j in timings.get("jobs", []):
|
||||
dur = j.get("duration_s")
|
||||
bl = bl_map.get(j["name"])
|
||||
bl_dur = bl.get("duration_s") if bl else None
|
||||
delta_text, delta_cls = fmt_delta(dur, bl_dur)
|
||||
|
||||
name = escape(j["name"])
|
||||
if j.get("workflow_name"):
|
||||
name = f'{escape(j["workflow_name"])} / {escape(j["name"])}'
|
||||
|
||||
concl = j.get("conclusion", "")
|
||||
concl_icon = {"success": "✓", "failure": "✗", "skipped": "⊘"}.get(concl, "?")
|
||||
concl_cls = {"success": "faster", "failure": "slower", "skipped": "neutral"}.get(concl, "neutral")
|
||||
|
||||
rows.append(
|
||||
f'<tr>'
|
||||
f'<td class="job-name">{name}</td>'
|
||||
f'<td class="num">{fmt_dur(dur)}</td>'
|
||||
f'<td class="num">{fmt_dur(bl_dur)}</td>'
|
||||
f'<td class="num {delta_cls}">{delta_text}</td>'
|
||||
f'<td class="{concl_cls}" style="text-align:center">{concl_icon}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<table><thead><tr>'
|
||||
'<th>Job</th><th class="num">Current</th><th class="num">Baseline</th>'
|
||||
'<th class="num">Delta</th><th>Status</th>'
|
||||
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
|
||||
)
|
||||
|
||||
|
||||
def _step_details(timings: dict, baseline: dict | None) -> str:
|
||||
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
||||
blocks = []
|
||||
for j in timings.get("jobs", []):
|
||||
if not j.get("steps"):
|
||||
continue
|
||||
bl = bl_map.get(j["name"], {})
|
||||
bl_steps = {s["name"]: s for s in bl.get("steps", [])}
|
||||
|
||||
dur = j.get("duration_s") or 0
|
||||
bl_dur = bl.get("duration_s") if bl else None
|
||||
delta_text, delta_cls = fmt_delta(dur, bl_dur)
|
||||
|
||||
summary_text = f'{escape(j["name"])} — {fmt_dur(dur)}'
|
||||
if bl_dur is not None:
|
||||
summary_text += f' <span class="{delta_cls}">({delta_text})</span>'
|
||||
|
||||
step_rows = []
|
||||
for s in j["steps"]:
|
||||
s_dur = s.get("duration_s")
|
||||
bl_s = bl_steps.get(s["name"])
|
||||
bl_s_dur = bl_s.get("duration_s") if bl_s else None
|
||||
s_delta, s_cls = fmt_delta(s_dur, bl_s_dur)
|
||||
|
||||
step_rows.append(
|
||||
f'<tr>'
|
||||
f'<td>{escape(s["name"])}</td>'
|
||||
f'<td class="num">{fmt_dur(s_dur)}</td>'
|
||||
f'<td class="num">{fmt_dur(bl_s_dur)}</td>'
|
||||
f'<td class="num {s_cls}">{s_delta}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
|
||||
blocks.append(
|
||||
f'<details><summary>{summary_text}</summary>'
|
||||
f'<table><thead><tr>'
|
||||
'<th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
|
||||
'<th class="num">Delta</th>'
|
||||
f'</tr></thead><tbody>{"".join(step_rows)}</tbody></table>'
|
||||
f'</details>'
|
||||
)
|
||||
|
||||
return "".join(blocks) if blocks else '<p style="color:#8b949e">No step data available.</p>'
|
||||
|
||||
|
||||
def _regressions(timings: dict, baseline: dict | None) -> str:
|
||||
"""Show top 10 biggest absolute regressions/improvements across all steps."""
|
||||
if not baseline:
|
||||
return ""
|
||||
bl_map = {j["name"]: j for j in baseline.get("jobs", [])}
|
||||
|
||||
deltas = [] # (abs_delta, job_name, step_name, current, baseline, is_slower)
|
||||
for j in timings.get("jobs", []):
|
||||
bl = bl_map.get(j["name"])
|
||||
if not bl:
|
||||
continue
|
||||
bl_steps = {s["name"]: s for s in bl.get("steps", [])}
|
||||
for s in j.get("steps", []):
|
||||
bl_s = bl_steps.get(s["name"])
|
||||
if not bl_s:
|
||||
continue
|
||||
cur = s.get("duration_s") or 0
|
||||
bl_d = bl_s.get("duration_s") or 0
|
||||
diff = cur - bl_d
|
||||
if abs(diff) < 1.0:
|
||||
continue
|
||||
deltas.append((abs(diff), diff, j["name"], s["name"], cur, bl_d))
|
||||
|
||||
deltas.sort(key=lambda x: x[0], reverse=True)
|
||||
top = deltas[:10]
|
||||
if not top:
|
||||
return ""
|
||||
|
||||
rows = []
|
||||
for _, diff, job, step, cur, bl_d in top:
|
||||
cls = "slower" if diff > 0 else "faster"
|
||||
tag = f'<span class="tag {"slow" if diff > 0 else "fast"}">{"+" if diff > 0 else ""}{diff:.1f}s</span>'
|
||||
rows.append(
|
||||
f'<tr>'
|
||||
f'<td class="job-name">{escape(job)}</td>'
|
||||
f'<td>{escape(step)}</td>'
|
||||
f'<td class="num">{fmt_dur(cur)}</td>'
|
||||
f'<td class="num">{fmt_dur(bl_d)}</td>'
|
||||
f'<td>{tag}</td>'
|
||||
f'</tr>'
|
||||
)
|
||||
|
||||
return (
|
||||
'<div class="regressions">'
|
||||
'<table><thead><tr>'
|
||||
'<th>Job</th><th>Step</th><th class="num">Current</th><th class="num">Baseline</th>'
|
||||
'<th>Delta</th>'
|
||||
'</tr></thead><tbody>' + "".join(rows) + '</tbody></table>'
|
||||
'</div>'
|
||||
)
|
||||
|
||||
|
||||
def generate_html(timings: dict, baseline: dict | None = None) -> str:
|
||||
stats = compute_stats(timings, baseline)
|
||||
|
||||
sha_short = (timings.get("head_sha") or "")[:7]
|
||||
run_id = timings.get("run_id", "?")
|
||||
created = timings.get("created_at", "")
|
||||
|
||||
bl_info = ""
|
||||
if baseline:
|
||||
bl_sha = (baseline.get("head_sha") or "")[:7]
|
||||
bl_info = f' | Baseline: <code>{bl_sha}</code> (main)'
|
||||
|
||||
html = (
|
||||
f'<!DOCTYPE html>\n<html lang="en">\n<head>\n'
|
||||
f'<meta charset="utf-8">\n'
|
||||
f'<meta name="viewport" content="width=device-width, initial-scale=1">\n'
|
||||
f'<title>CI Timing Report — {sha_short}</title>\n'
|
||||
f'<style>{CSS}</style>\n'
|
||||
f'</head>\n<body>\n'
|
||||
f'<h1>CI Timing Report</h1>\n'
|
||||
f'<div class="meta">Run <code>{escape(run_id)}</code> | SHA <code>{sha_short}</code>'
|
||||
f' | Generated {escape(created)}{bl_info}</div>\n'
|
||||
)
|
||||
|
||||
html += '<h2>Global Stats</h2>\n'
|
||||
html += _stats_cards(stats)
|
||||
|
||||
if baseline:
|
||||
html += '<h2>Top Regressions & Improvements</h2>\n'
|
||||
html += _regressions(timings, baseline)
|
||||
|
||||
html += '<h2>Gantt Chart</h2>\n'
|
||||
html += _gantt_bars(timings, baseline)
|
||||
|
||||
html += '<h2>Per-Job Comparison</h2>\n'
|
||||
html += _job_table(timings, baseline)
|
||||
|
||||
html += '<h2>Step Details</h2>\n'
|
||||
html += _step_details(timings, baseline)
|
||||
|
||||
html += '</body>\n</html>\n'
|
||||
return html
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Markdown summary for $GITHUB_STEP_SUMMARY
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def generate_summary(timings: dict, baseline: dict | None = None) -> str:
|
||||
stats = compute_stats(timings, baseline)
|
||||
bl_map = {j["name"]: j for j in (baseline or {}).get("jobs", [])}
|
||||
|
||||
lines = ["## CI Timing Summary\n"]
|
||||
|
||||
# Global stats table
|
||||
lines.append("| Metric | Current | Baseline | Delta |")
|
||||
lines.append("|--------|---------|----------|-------|")
|
||||
|
||||
wall_d = ""
|
||||
if stats["bl_wall"] is not None:
|
||||
d, _ = fmt_delta(stats["wall"], stats["bl_wall"])
|
||||
wall_d = d
|
||||
lines.append(f"| Wall time | {fmt_dur(stats['wall'])} | {fmt_dur(stats['bl_wall'])} | {wall_d} |")
|
||||
|
||||
compute_d = ""
|
||||
if stats["bl_compute"] is not None:
|
||||
d, _ = fmt_delta(stats["compute"], stats["bl_compute"])
|
||||
compute_d = d
|
||||
lines.append(f"| Total compute | {fmt_dur(stats['compute'])} | {fmt_dur(stats['bl_compute'])} | {compute_d} |")
|
||||
|
||||
lines.append(f"| Jobs faster | {stats['faster']} | — | — |")
|
||||
lines.append(f"| Jobs slower | {stats['slower']} | — | — |")
|
||||
lines.append(f"| Jobs unchanged | {stats['unchanged']} | — | — |")
|
||||
lines.append(f"| Jobs without baseline | {stats['no_baseline']} | — | — |")
|
||||
lines.append("")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def expect_env(var: str) -> str:
|
||||
val = os.environ.get(var)
|
||||
if not val:
|
||||
raise ValueError(f"missing environment variable {var}")
|
||||
return val
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Collect CI timings and generate HTML report")
|
||||
parser.add_argument("--from-json", help="Read timings from JSON instead of API")
|
||||
parser.add_argument("--baseline", default="ci-timings-baseline.json",
|
||||
help="Baseline JSON path (default: ci-timings-baseline.json)")
|
||||
parser.add_argument("--output", default="ci-timings-report.html",
|
||||
help="HTML output path (default: ci-timings-report.html)")
|
||||
parser.add_argument("--json-out", default="ci-timings.json",
|
||||
help="JSON output path (default: ci-timings.json)")
|
||||
parser.add_argument("--summary-out", default="ci-timings-summary.md",
|
||||
help="Markdown summary output path (default: ci-timings-summary.md)")
|
||||
args = parser.parse_args()
|
||||
|
||||
# Collect or load timings
|
||||
if args.from_json:
|
||||
with open(args.from_json, encoding="utf-8") as f:
|
||||
timings = json.load(f)
|
||||
else:
|
||||
token = expect_env("GITHUB_TOKEN")
|
||||
repo = expect_env("GITHUB_REPOSITORY")
|
||||
run_id = expect_env("GITHUB_RUN_ID")
|
||||
head_sha = expect_env("GITHUB_SHA")
|
||||
|
||||
timings = collect_timings(token, repo, run_id, head_sha)
|
||||
|
||||
# Save JSON
|
||||
with open(args.json_out, "w", encoding="utf-8") as f:
|
||||
json.dump(timings, f, indent=2)
|
||||
print(f"Saved timings to {args.json_out} ({len(timings.get('jobs', []))} jobs)")
|
||||
|
||||
# Load baseline
|
||||
baseline = None
|
||||
if os.path.exists(args.baseline):
|
||||
with open(args.baseline, encoding="utf-8") as f:
|
||||
baseline = json.load(f)
|
||||
print(f"Loaded baseline from {args.baseline}")
|
||||
else:
|
||||
print(f"No baseline file at {args.baseline} — generating current-only report")
|
||||
|
||||
# Generate HTML
|
||||
html = generate_html(timings, baseline)
|
||||
with open(args.output, "w", encoding="utf-8") as f:
|
||||
f.write(html)
|
||||
print(f"Generated HTML report: {args.output}")
|
||||
|
||||
# Write summary
|
||||
summary = generate_summary(timings, baseline)
|
||||
with open(args.summary_out, "a", encoding="utf-8") as f:
|
||||
f.write(summary)
|
||||
print(f"Wrote summary to {args.summary_out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +1,7 @@
|
||||
---
|
||||
name: claude-design
|
||||
description: Design one-off HTML artifacts (landing, deck, prototype).
|
||||
version: 1.1.0
|
||||
version: 1.0.0
|
||||
author: BadTechBandit
|
||||
license: MIT
|
||||
platforms: [linux, macos, windows]
|
||||
@@ -165,31 +165,6 @@ Skip questions when:
|
||||
|
||||
When proceeding with assumptions, label only the important ones.
|
||||
|
||||
## Surface-First: Commit to a Composition Before Touching Tokens
|
||||
|
||||
The single highest-leverage anti-slop rule. Most AI design slop is **compositional, not cosmetic** — the model reaches for a centered hero + three equal-weight feature cards for *every* surface, then decorates. Recoloring or restyling that layout never fixes it, because the layout was wrong before a single color was chosen.
|
||||
|
||||
Before you write any colors, type scale, or components, **commit out loud to exactly one surface archetype.** This conditions generation on a high-level plan first, which collapses the entropy of what gets produced — the same reason a chain-of-thought step improves reasoning.
|
||||
|
||||
The seven surfaces:
|
||||
|
||||
1. **Monitor** — the user is watching state change (dashboards, status pages, observability). Density, glanceable hierarchy, no marketing framing.
|
||||
2. **Operate** — the user is taking action on things (consoles, admin panels, queues, inboxes). Action affordances and selection state dominate.
|
||||
3. **Compare** — the user is weighing options against each other (pricing, plans, spec tables, search results). Aligned columns, parity of structure, one differentiator emphasized.
|
||||
4. **Configure** — the user is setting things up (settings, forms, wizards, onboarding). Progressive disclosure, clear save/validation states, low decoration.
|
||||
5. **Decide / Learn** — the user is being convinced or taught (landing pages, docs, marketing). One idea lands per section; this is the ONLY surface where a hero is usually correct.
|
||||
6. **Explore** — the user is browsing an open space (galleries, maps, search-and-filter, catalogs). Filters, result grids, and zoom/peek are the composition.
|
||||
7. **Command / Inspect** — the user is driving by keyboard or drilling into one object (command bars, inspectors, detail panes, property editors). Speed and focus over breadth.
|
||||
|
||||
Rules:
|
||||
|
||||
- State the surface in one line before designing (e.g. "This is a **Monitor** surface, so density and glanceability beat a hero").
|
||||
- A dashboard is a Monitor surface, not a Decide surface — do not give it a centered hero and three feature cards.
|
||||
- If a screen genuinely spans two surfaces, name the **primary** one and treat the other as secondary; do not average them into mush.
|
||||
- The hero-plus-three-cards composition is correct for **Decide/Learn only**. Reaching for it anywhere else is the #1 tell.
|
||||
|
||||
This one constraint eliminates more generic-looking UI than any aesthetic rule below.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. **Understand the brief**
|
||||
@@ -202,11 +177,7 @@ This one constraint eliminates more generic-looking UI than any aesthetic rule b
|
||||
- Read supplied docs, screenshots, repo files, or design assets.
|
||||
- Identify the visual vocabulary before writing code.
|
||||
|
||||
3. **Commit to a surface** (see "Surface-First")
|
||||
- Name the one surface archetype before any visual tokens.
|
||||
- This conditions the composition; everything below inherits from it.
|
||||
|
||||
4. **Define the design system for this artifact**
|
||||
3. **Define the design system for this artifact**
|
||||
- colors
|
||||
- type
|
||||
- spacing
|
||||
@@ -216,26 +187,25 @@ This one constraint eliminates more generic-looking UI than any aesthetic rule b
|
||||
- component treatment
|
||||
- interaction rules
|
||||
|
||||
5. **Choose the right format**
|
||||
4. **Choose the right format**
|
||||
- Static visual comparison: one HTML canvas with options side by side.
|
||||
- Interaction/flow: clickable prototype.
|
||||
- Presentation: fixed-size HTML deck with slide navigation.
|
||||
- Component exploration: component lab with variants.
|
||||
- Motion: timeline or state-based animation.
|
||||
|
||||
6. **Build the artifact**
|
||||
5. **Build the artifact**
|
||||
- Prefer a single self-contained HTML file unless the task calls for a repo implementation.
|
||||
- Preserve prior versions for major revisions.
|
||||
- Avoid unnecessary dependencies.
|
||||
|
||||
7. **Verify**
|
||||
6. **Verify**
|
||||
- Confirm files exist.
|
||||
- Run any available syntax/static checks.
|
||||
- If browser tools are available, open the file and check console errors.
|
||||
- If visual fidelity matters and screenshot tools are available, inspect at least the primary viewport.
|
||||
- Run the slop self-audit (see "Slop Diagnostic") and repair only what it flags.
|
||||
|
||||
8. **Report briefly**
|
||||
7. **Report briefly**
|
||||
- exact file path
|
||||
- what was created
|
||||
- caveats
|
||||
@@ -433,35 +403,6 @@ Avoid common AI design sludge:
|
||||
|
||||
Minimal is not automatically good. Dense is not automatically cluttered. Choose intentionally.
|
||||
|
||||
## Slop Diagnostic: Score Before You Fix
|
||||
|
||||
AI design slop has a tiny, predictable failure distribution — designers asked to label AI UIs collapse the "this is AI" signal down to about ten tells. Before polishing or repairing an artifact, run this as an explicit self-audit and write a short report. **Diagnose first, treat second** — auditing and fixing in one breath fails, because the model's prior outweighs the instruction and it repeats the mistake (recolors when it needed re-layout, polishes type on a composition problem).
|
||||
|
||||
The ten tells (presence of each = one point of slop; lower is better):
|
||||
|
||||
1. **Tech gradient** — blue/violet/indigo glossy gradient on everything.
|
||||
2. **Generic tech hue** — the default accent is indigo/violet (not chosen for the brand, just the model's favorite).
|
||||
3. **Feature-tile grid** — icon + heading + sentence × 3, all equal weight, nothing prioritized.
|
||||
4. **Accent rail** — a colored left strip on cards: decoration pretending to be organization.
|
||||
5. **Unearned blur** — glassmorphism with no real depth/elevation system behind it.
|
||||
6. **Monument stat** — oversized numbers filling space that should carry product story.
|
||||
7. **Icon topper** — a rounded-square icon centered above every heading (Tailwind-template filler).
|
||||
8. **Center stack** — everything centered because no real composition was committed to.
|
||||
9. **Default type** — Inter (or system-ui) used by default rather than chosen.
|
||||
10. **Wrong surface** — the composition doesn't match the surface (e.g. a hero on a Monitor surface). This is the root cause behind most of the others.
|
||||
|
||||
How to run it:
|
||||
|
||||
- Score the artifact out of 10 (10 = maximum slop). State the score and list which tells fired, in one short report.
|
||||
- Treat the report as **context, not a to-do list** — it tells you *where* to spend repair effort, it does not dictate edits.
|
||||
- Then repair, matched to the diagnosis:
|
||||
- tells 3, 8, 10 → **re-layout / re-compose** (revisit the surface choice — do not recolor).
|
||||
- tells 1, 2, 9 → **recolor / re-typeset** (palette and type are genuinely the problem here).
|
||||
- tells 4, 5, 6, 7 → **remove the decoration**; replace it with real hierarchy (scale, weight, spacing).
|
||||
- Re-score after repairing. Do not declare done while compositional tells (3, 8, 10) are still firing — those are causes, the rest are usually symptoms.
|
||||
|
||||
The point of separating diagnosis from treatment: let the audit complain first, then fix only what it complained about, in the register the complaint calls for.
|
||||
|
||||
## Typography
|
||||
|
||||
Use the existing type system if one exists.
|
||||
|
||||
@@ -353,39 +353,6 @@ class TestRuntimeMode:
|
||||
assert any("coding agent" in b for b in blocks)
|
||||
assert any("Workspace" in b for b in blocks)
|
||||
|
||||
def test_coding_instructions_append_their_own_block(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
cfg = {
|
||||
"agent": {
|
||||
"coding_context": "on",
|
||||
"coding_instructions": "Clean the diff before commit.",
|
||||
}
|
||||
}
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config=cfg)
|
||||
blocks = mode.system_blocks()
|
||||
# The brief stays block 0 (byte-stable, cache-keyed independently); the
|
||||
# operator instructions ride a separate trailing block.
|
||||
assert blocks[0] == cc.CODING_AGENT_GUIDANCE
|
||||
assert any("Clean the diff before commit." in b for b in blocks[1:])
|
||||
|
||||
def test_coding_instructions_accept_a_list(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
cfg = {
|
||||
"agent": {
|
||||
"coding_context": "on",
|
||||
"coding_instructions": ["No tsc/lint on UI.", "Clean the diff."],
|
||||
}
|
||||
}
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config=cfg)
|
||||
instr_block = mode.system_blocks()[-1]
|
||||
assert "No tsc/lint on UI." in instr_block
|
||||
assert "Clean the diff." in instr_block
|
||||
|
||||
def test_no_instructions_block_when_unset(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
mode = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "on"}})
|
||||
assert not any("Operator instructions" in b for b in mode.system_blocks())
|
||||
|
||||
def test_toolset_selection_gated_on_focus(self, tmp_path):
|
||||
_git_init(tmp_path)
|
||||
focus = cc.resolve_runtime_mode(platform="cli", cwd=tmp_path, config={"agent": {"coding_context": "focus"}})
|
||||
|
||||
@@ -401,82 +401,3 @@ class TestEditDiffPreview:
|
||||
assert any("a/file2.py" in line for line in rendered)
|
||||
assert not any("a/file7.py" in line for line in rendered)
|
||||
assert "additional file" in rendered[-1]
|
||||
|
||||
|
||||
class TestBuildToolLabel:
|
||||
"""Friendly human-phrased tool labels for built-in tools."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _enable_friendly(self):
|
||||
from agent.display import set_friendly_tool_labels
|
||||
set_friendly_tool_labels(True)
|
||||
yield
|
||||
set_friendly_tool_labels(True)
|
||||
|
||||
def test_web_search_uses_for_connector(self):
|
||||
from agent.display import build_tool_label
|
||||
label = build_tool_label("web_search", {"query": "weather in NYC"})
|
||||
assert label == 'Searching the web for weather in NYC'
|
||||
|
||||
def test_web_extract_reads_url(self):
|
||||
from agent.display import build_tool_label
|
||||
label = build_tool_label("web_extract", {"urls": ["https://example.com/page"]})
|
||||
assert label is not None
|
||||
assert label.startswith("Reading ")
|
||||
assert "example.com/page" in label
|
||||
|
||||
def test_browser_navigate_browses_url(self):
|
||||
from agent.display import build_tool_label
|
||||
label = build_tool_label("browser_navigate", {"url": "https://news.site"})
|
||||
assert label == "Browsing https://news.site"
|
||||
|
||||
def test_read_file_uses_basename(self):
|
||||
from agent.display import build_tool_label
|
||||
label = build_tool_label("read_file", {"path": "/home/u/project/main.py"})
|
||||
assert label is not None
|
||||
assert label.startswith("Reading ")
|
||||
assert "main.py" in label
|
||||
|
||||
def test_search_files_uses_for_connector(self):
|
||||
from agent.display import build_tool_label
|
||||
label = build_tool_label("search_files", {"pattern": "TODO"})
|
||||
assert label == "Searching files for TODO"
|
||||
|
||||
def test_verb_only_for_no_preview_tools(self):
|
||||
from agent.display import build_tool_label
|
||||
# session_search is verb-only — no redundant query echo
|
||||
label = build_tool_label("session_search", {"query": "auth refactor"})
|
||||
assert label == "Searching past sessions"
|
||||
|
||||
def test_verb_only_when_no_preview_available(self):
|
||||
from agent.display import build_tool_label
|
||||
# image_generate with empty args still yields the verb (no preview)
|
||||
label = build_tool_label("image_generate", {})
|
||||
assert label == "Generating image"
|
||||
|
||||
def test_unknown_tool_falls_back_to_preview(self):
|
||||
from agent.display import build_tool_label, build_tool_preview
|
||||
args = {"some_arg": "value"}
|
||||
# A custom/plugin/MCP tool with no verb entry → raw preview behavior
|
||||
label = build_tool_label("custom_mcp_tool", args)
|
||||
assert label == build_tool_preview("custom_mcp_tool", args)
|
||||
|
||||
def test_disabled_falls_back_to_preview(self):
|
||||
from agent.display import (
|
||||
build_tool_label,
|
||||
build_tool_preview,
|
||||
set_friendly_tool_labels,
|
||||
)
|
||||
set_friendly_tool_labels(False)
|
||||
args = {"query": "weather in NYC"}
|
||||
label = build_tool_label("web_search", args)
|
||||
# With the feature off, must match the raw preview exactly
|
||||
assert label == build_tool_preview("web_search", args)
|
||||
assert "Searching the web" not in (label or "")
|
||||
|
||||
def test_every_known_verb_renders_without_error(self):
|
||||
from agent.display import build_tool_label, _TOOL_VERBS
|
||||
# Each built-in verb must produce a non-empty label given minimal args.
|
||||
for tool_name in _TOOL_VERBS:
|
||||
label = build_tool_label(tool_name, {"query": "x", "path": "x", "url": "x"})
|
||||
assert label, f"{tool_name} produced empty label"
|
||||
|
||||
@@ -97,24 +97,6 @@ class TestParseResponse:
|
||||
)
|
||||
assert r is None
|
||||
|
||||
def test_pre_verify_continue_canonical(self):
|
||||
r = shell_hooks._parse_response(
|
||||
"pre_verify", '{"action": "continue", "message": "run checks"}',
|
||||
)
|
||||
assert r == {"action": "continue", "message": "run checks"}
|
||||
|
||||
def test_pre_verify_block_is_continue_claude_style(self):
|
||||
# Claude-Code Stop hooks: block the stop == keep going; reason → message.
|
||||
r = shell_hooks._parse_response(
|
||||
"pre_verify", '{"decision": "block", "reason": "run the formatter"}',
|
||||
)
|
||||
assert r == {"action": "continue", "message": "run the formatter"}
|
||||
|
||||
def test_pre_verify_without_message_is_noop(self):
|
||||
# A continue with nothing to tell the model lets the turn finish.
|
||||
assert shell_hooks._parse_response("pre_verify", '{"action": "continue"}') is None
|
||||
assert shell_hooks._parse_response("pre_verify", '{"decision": "allow"}') is None
|
||||
|
||||
def test_block_action_without_message_uses_default(self):
|
||||
"""Block is honored even when message/reason is absent."""
|
||||
r = shell_hooks._parse_response("pre_tool_call", '{"action": "block"}')
|
||||
|
||||
@@ -215,7 +215,6 @@ def test_nudge_after_unverified_edit_with_known_command(tmp_path, monkeypatch):
|
||||
assert "fresh passing verification evidence" in nudge
|
||||
assert "`pnpm run test`" in nudge
|
||||
assert changed in nudge
|
||||
assert "creative UI/visual work" in nudge
|
||||
|
||||
|
||||
def test_nudge_includes_failed_output_summary(tmp_path, monkeypatch):
|
||||
@@ -250,23 +249,6 @@ def test_no_suite_nudge_requests_temp_script(tmp_path, monkeypatch):
|
||||
assert tempfile.gettempdir() in nudge
|
||||
assert "ad-hoc verification" in nudge
|
||||
assert "suite green" in nudge
|
||||
assert "creative UI/visual work" in nudge
|
||||
|
||||
|
||||
def test_verify_guidance_can_be_disabled(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path / ".hermes"))
|
||||
_node_project(tmp_path)
|
||||
changed = str(tmp_path / "src" / "app.ts")
|
||||
|
||||
from agent import verify_hooks
|
||||
|
||||
monkeypatch.setattr(verify_hooks, "coding_verify_guidance", lambda: None)
|
||||
|
||||
nudge = build_verify_on_stop_nudge(session_id="s1", changed_paths=[changed])
|
||||
|
||||
assert nudge is not None
|
||||
assert "fresh passing verification evidence" in nudge
|
||||
assert "creative UI/visual work" not in nudge
|
||||
|
||||
|
||||
def test_ad_hoc_pass_satisfies_no_suite_stop_loop(tmp_path, monkeypatch):
|
||||
|
||||
@@ -1,53 +0,0 @@
|
||||
"""Unit tests for the verification-loop policy (agent/verify_hooks.py).
|
||||
|
||||
The `pre_verify` user-hook aggregation lives in `hermes_cli.plugins`
|
||||
(`get_pre_verify_continue_message`) and is tested in
|
||||
`tests/hermes_cli/test_plugins.py`, alongside `get_pre_tool_call_block_message`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from agent import verify_hooks
|
||||
|
||||
|
||||
class TestMaxVerifyNudges:
|
||||
def test_default_when_unset(self):
|
||||
assert (
|
||||
verify_hooks.max_verify_nudges({})
|
||||
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
|
||||
)
|
||||
assert (
|
||||
verify_hooks.max_verify_nudges({"agent": {}})
|
||||
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
|
||||
)
|
||||
|
||||
def test_reads_and_coerces(self):
|
||||
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": 5}}) == 5
|
||||
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": "2"}}) == 2
|
||||
assert verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": -1}}) == 0
|
||||
|
||||
def test_bad_value_falls_back(self):
|
||||
assert (
|
||||
verify_hooks.max_verify_nudges({"agent": {"max_verify_nudges": "x"}})
|
||||
== verify_hooks.DEFAULT_MAX_VERIFY_NUDGES
|
||||
)
|
||||
|
||||
|
||||
class TestCodingVerifyGuidance:
|
||||
def test_enabled_by_default(self):
|
||||
assert (
|
||||
verify_hooks.coding_verify_guidance({})
|
||||
== verify_hooks.CODING_VERIFY_GUIDANCE
|
||||
)
|
||||
assert (
|
||||
verify_hooks.coding_verify_guidance({"agent": {}})
|
||||
== verify_hooks.CODING_VERIFY_GUIDANCE
|
||||
)
|
||||
|
||||
def test_reads_truthy_config(self):
|
||||
cfg = {"agent": {"verify_guidance": "yes"}}
|
||||
assert verify_hooks.coding_verify_guidance(cfg) == verify_hooks.CODING_VERIFY_GUIDANCE
|
||||
|
||||
def test_opt_out_via_config(self):
|
||||
off = {"agent": {"verify_guidance": False}}
|
||||
assert verify_hooks.coding_verify_guidance(off) is None
|
||||
@@ -71,25 +71,6 @@ class TestPlatformConfigRoundtrip:
|
||||
restored = PlatformConfig.from_dict({"gateway_restart_notification": "false"})
|
||||
assert restored.gateway_restart_notification is False
|
||||
|
||||
def test_typing_indicator_defaults_true(self):
|
||||
assert PlatformConfig().typing_indicator is True
|
||||
assert PlatformConfig.from_dict({}).typing_indicator is True
|
||||
|
||||
def test_typing_indicator_roundtrip_false(self):
|
||||
pc = PlatformConfig(enabled=True, typing_indicator=False)
|
||||
restored = PlatformConfig.from_dict(pc.to_dict())
|
||||
assert restored.typing_indicator is False
|
||||
|
||||
def test_typing_indicator_coerces_quoted_false(self):
|
||||
restored = PlatformConfig.from_dict({"typing_indicator": "false"})
|
||||
assert restored.typing_indicator is False
|
||||
|
||||
def test_typing_indicator_resolved_from_extra(self):
|
||||
# The shared-key loop in load_gateway_config bridges the flag into
|
||||
# extra; from_dict must honor it there too (mirrors _grn fallback).
|
||||
restored = PlatformConfig.from_dict({"extra": {"typing_indicator": False}})
|
||||
assert restored.typing_indicator is False
|
||||
|
||||
|
||||
class TestGetConnectedPlatforms:
|
||||
def test_returns_enabled_with_token(self):
|
||||
|
||||
@@ -308,7 +308,7 @@ async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_pa
|
||||
assert adapter.sent == [
|
||||
{
|
||||
"chat_id": "-1001",
|
||||
"content": '💻 Running pwd',
|
||||
"content": '💻 terminal: "pwd"',
|
||||
"reply_to": None,
|
||||
"metadata": {"thread_id": "17585"},
|
||||
}
|
||||
@@ -496,27 +496,6 @@ async def test_run_agent_feishu_progress_replies_inside_existing_thread(monkeypa
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _extract_progress_preview(content: str) -> str | None:
|
||||
"""Extract the argument-preview portion from a tool-progress message.
|
||||
|
||||
Handles both render styles:
|
||||
- Legacy / custom tools: ``🔧 tool_name: "<preview>"`` (quoted)
|
||||
- Friendly built-in verb: ``💻 Running <preview>`` (verb prefix, no quotes)
|
||||
"""
|
||||
import re
|
||||
|
||||
# Legacy quoted form takes precedence when present.
|
||||
match = re.search(r'"(.+)"', content)
|
||||
if match:
|
||||
return match.group(1)
|
||||
# Friendly form: "<emoji> <verb> <preview>". The terminal verb is "Running".
|
||||
marker = " Running "
|
||||
idx = content.find(marker)
|
||||
if idx != -1:
|
||||
return content[idx + len(marker):].strip()
|
||||
return None
|
||||
|
||||
|
||||
def _run_long_preview_helper(monkeypatch, tmp_path, preview_length=0):
|
||||
"""Shared setup for long-preview truncation tests.
|
||||
|
||||
@@ -573,10 +552,13 @@ def test_all_mode_default_truncation_40_chars(monkeypatch, tmp_path):
|
||||
assert result["final_response"] == "done"
|
||||
assert adapter.sent
|
||||
content = adapter.sent[0]["content"]
|
||||
# The long command should be truncated — the preview portion <= 40 chars.
|
||||
# The long command should be truncated — total preview <= 40 chars
|
||||
assert "..." in content
|
||||
preview_text = _extract_progress_preview(content)
|
||||
assert preview_text is not None, f"No preview found in: {content}"
|
||||
# Extract the preview part between quotes
|
||||
import re
|
||||
match = re.search(r'"(.+)"', content)
|
||||
assert match, f"No quoted preview found in: {content}"
|
||||
preview_text = match.group(1)
|
||||
assert len(preview_text) <= 40, f"Preview too long ({len(preview_text)}): {preview_text}"
|
||||
|
||||
|
||||
@@ -586,9 +568,11 @@ def test_all_mode_respects_custom_preview_length(monkeypatch, tmp_path):
|
||||
assert result["final_response"] == "done"
|
||||
assert adapter.sent
|
||||
content = adapter.sent[0]["content"]
|
||||
# With 120-char cap, the command (165 chars) should still be truncated but longer.
|
||||
preview_text = _extract_progress_preview(content)
|
||||
assert preview_text is not None, f"No preview found in: {content}"
|
||||
# With 120-char cap, the command (165 chars) should still be truncated but longer
|
||||
import re
|
||||
match = re.search(r'"(.+)"', content)
|
||||
assert match, f"No quoted preview found in: {content}"
|
||||
preview_text = match.group(1)
|
||||
# Should be longer than the 40-char default
|
||||
assert len(preview_text) > 40, f"Preview suspiciously short ({len(preview_text)}): {preview_text}"
|
||||
# But still capped at 120
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
"""
|
||||
Tests for the connect-time user-token (vs bot-token) nudge.
|
||||
|
||||
``auth.test`` returns the ``user_id`` of whatever principal owns the configured
|
||||
token. A real bot token (``xoxb-…``) resolves to the app's bot user and the
|
||||
response carries a ``bot_id``; a user/legacy token (``xoxp-…``) resolves to the
|
||||
installing *human's* member ID with **no** ``bot_id``. In the latter case the
|
||||
adapter binds its identity to a human's member ID, so that person's ``<@…>``
|
||||
mentions are misrouted as mentions of the bot. ``_warn_if_not_bot_token``
|
||||
detects the missing ``bot_id`` at connect time — the only point where this is
|
||||
observable, since a user token still sends/receives without any runtime error —
|
||||
and logs an actionable, warning-only nudge.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Mock slack-bolt if not installed (same pattern as test_slack_mention.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _ensure_slack_mock():
|
||||
if "slack_bolt" in sys.modules and hasattr(sys.modules["slack_bolt"], "__file__"):
|
||||
return
|
||||
|
||||
slack_bolt = MagicMock()
|
||||
slack_bolt.async_app.AsyncApp = MagicMock
|
||||
slack_bolt.adapter.socket_mode.async_handler.AsyncSocketModeHandler = MagicMock
|
||||
|
||||
slack_sdk = MagicMock()
|
||||
slack_sdk.web.async_client.AsyncWebClient = MagicMock
|
||||
|
||||
for name, mod in [
|
||||
("slack_bolt", slack_bolt),
|
||||
("slack_bolt.async_app", slack_bolt.async_app),
|
||||
("slack_bolt.adapter", slack_bolt.adapter),
|
||||
("slack_bolt.adapter.socket_mode", slack_bolt.adapter.socket_mode),
|
||||
("slack_bolt.adapter.socket_mode.async_handler",
|
||||
slack_bolt.adapter.socket_mode.async_handler),
|
||||
("slack_sdk", slack_sdk),
|
||||
("slack_sdk.web", slack_sdk.web),
|
||||
("slack_sdk.web.async_client", slack_sdk.web.async_client),
|
||||
]:
|
||||
sys.modules.setdefault(name, mod)
|
||||
|
||||
|
||||
_ensure_slack_mock()
|
||||
|
||||
import plugins.platforms.slack.adapter as _slack_mod # noqa: E402
|
||||
_slack_mod.SLACK_AVAILABLE = True
|
||||
|
||||
from plugins.platforms.slack.adapter import SlackAdapter # noqa: E402
|
||||
|
||||
|
||||
class _DictAuthResponse(dict):
|
||||
"""Mimics slack_sdk's AsyncSlackResponse — dict-like with .get(), like the
|
||||
real object the adapter already calls ``.get()`` on in ``connect``."""
|
||||
|
||||
|
||||
class _AttrAuthResponse:
|
||||
"""A response shape that is NOT dict-like; values live on ``.data``."""
|
||||
|
||||
def __init__(self, data):
|
||||
self.data = data
|
||||
|
||||
|
||||
def _make_adapter():
|
||||
# object.__new__ skips __init__ (heavy setup) — established slack-test pattern.
|
||||
return object.__new__(SlackAdapter)
|
||||
|
||||
|
||||
def test_warns_when_bot_id_absent(caplog):
|
||||
# User token: auth.test resolves a human member but carries no bot_id.
|
||||
adapter = _make_adapter()
|
||||
resp = _DictAuthResponse(team_id="T1", user_id="U_HUMAN", user="trevor")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
adapter._warn_if_not_bot_token(resp, "Acme")
|
||||
matched = [r for r in caplog.records
|
||||
if "authenticated as a USER" in r.message and "U_HUMAN" in r.message]
|
||||
assert matched
|
||||
|
||||
|
||||
def test_no_warning_when_bot_id_present(caplog):
|
||||
# Real bot token: auth.test carries a bot_id.
|
||||
adapter = _make_adapter()
|
||||
resp = _DictAuthResponse(team_id="T1", user_id="U_BOT", bot_id="B123", user="hermes")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
adapter._warn_if_not_bot_token(resp, "Acme")
|
||||
assert not any("authenticated as a USER" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_no_warning_when_user_id_unresolved(caplog):
|
||||
# Nothing resolved (e.g. odd/empty response) — don't guess, stay silent.
|
||||
adapter = _make_adapter()
|
||||
resp = _DictAuthResponse(team_id="T1")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
adapter._warn_if_not_bot_token(resp, "Acme")
|
||||
assert not any("authenticated as a USER" in r.message for r in caplog.records)
|
||||
|
||||
|
||||
def test_warns_only_once_per_workspace(caplog):
|
||||
adapter = _make_adapter()
|
||||
resp = _DictAuthResponse(user_id="U_HUMAN")
|
||||
with caplog.at_level(logging.WARNING):
|
||||
adapter._warn_if_not_bot_token(resp, "Acme")
|
||||
adapter._warn_if_not_bot_token(resp, "Acme")
|
||||
warnings = [r for r in caplog.records if "authenticated as a USER" in r.message]
|
||||
assert len(warnings) == 1
|
||||
|
||||
|
||||
def test_handles_attribute_only_response_shape(caplog):
|
||||
# Response without dict .get(): values must be read off .data.
|
||||
adapter = _make_adapter()
|
||||
resp = _AttrAuthResponse({"user_id": "U_HUMAN", "user": "trevor"})
|
||||
with caplog.at_level(logging.WARNING):
|
||||
adapter._warn_if_not_bot_token(resp, "Acme")
|
||||
assert any("authenticated as a USER" in r.message and "U_HUMAN" in r.message
|
||||
for r in caplog.records)
|
||||
@@ -1,99 +0,0 @@
|
||||
"""Per-platform typing-indicator toggle (PlatformConfig.typing_indicator).
|
||||
|
||||
The "typing…" / "is thinking…" status bubble is driven by the generic
|
||||
``_keep_typing`` refresh loop that ``_process_message_background`` spawns for
|
||||
every inbound message on every platform. ``typing_indicator`` (default True)
|
||||
gates that spawn: when False, the loop is never started, so ``send_typing``
|
||||
is never called and no status indicator is shown — while message delivery is
|
||||
otherwise unchanged.
|
||||
|
||||
These are behavioral tests against the real dispatch path, not snapshots.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import Platform, PlatformConfig
|
||||
from gateway.platforms.base import (
|
||||
BasePlatformAdapter,
|
||||
MessageEvent,
|
||||
MessageType,
|
||||
)
|
||||
from gateway.session import SessionSource, build_session_key
|
||||
|
||||
|
||||
class _StubAdapter(BasePlatformAdapter):
|
||||
async def connect(self, *, is_reconnect: bool = False):
|
||||
pass
|
||||
|
||||
async def disconnect(self):
|
||||
pass
|
||||
|
||||
async def send(self, chat_id, text, **kwargs):
|
||||
return None
|
||||
|
||||
async def get_chat_info(self, chat_id):
|
||||
return {}
|
||||
|
||||
|
||||
def _make_adapter(typing_indicator: bool) -> _StubAdapter:
|
||||
adapter = _StubAdapter(
|
||||
PlatformConfig(enabled=True, token="t", typing_indicator=typing_indicator),
|
||||
Platform.SLACK,
|
||||
)
|
||||
# Record send_typing calls without performing any platform I/O.
|
||||
adapter.send_typing = AsyncMock(return_value=None)
|
||||
adapter._send_with_retry = AsyncMock(return_value=None)
|
||||
# Handler returns immediately; the typing loop only fires if it was spawned.
|
||||
adapter._message_handler = AsyncMock(return_value="ok")
|
||||
return adapter
|
||||
|
||||
|
||||
def _make_event(chat_id="C123"):
|
||||
return MessageEvent(
|
||||
text="hi",
|
||||
message_type=MessageType.TEXT,
|
||||
source=SessionSource(platform=Platform.SLACK, chat_id=chat_id, chat_type="dm"),
|
||||
)
|
||||
|
||||
|
||||
def _sk(chat_id="C123"):
|
||||
return build_session_key(
|
||||
SessionSource(platform=Platform.SLACK, chat_id=chat_id, chat_type="dm")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_indicator_enabled_spawns_refresh_loop():
|
||||
"""Default (typing_indicator=True): the refresh loop calls send_typing."""
|
||||
adapter = _make_adapter(typing_indicator=True)
|
||||
|
||||
# Real handlers take time (tool calls); yield long enough for the spawned
|
||||
# refresh loop to fire at least one send_typing before delivery completes.
|
||||
async def _slow_handler(_event):
|
||||
await asyncio.sleep(0.05)
|
||||
return "ok"
|
||||
|
||||
adapter._message_handler = _slow_handler
|
||||
event = _make_event()
|
||||
adapter._active_sessions[_sk()] = asyncio.Event()
|
||||
|
||||
await adapter._process_message_background(event, _sk())
|
||||
|
||||
assert adapter.send_typing.await_count >= 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_typing_indicator_disabled_never_calls_send_typing():
|
||||
"""typing_indicator=False: the loop is never spawned, send_typing unused."""
|
||||
adapter = _make_adapter(typing_indicator=False)
|
||||
event = _make_event()
|
||||
adapter._active_sessions[_sk()] = asyncio.Event()
|
||||
|
||||
await adapter._process_message_background(event, _sk())
|
||||
|
||||
adapter.send_typing.assert_not_awaited()
|
||||
# Delivery still happened — disabling typing must not suppress the reply.
|
||||
adapter._send_with_retry.assert_awaited()
|
||||
@@ -243,65 +243,3 @@ class TestUsageAccountSection:
|
||||
assert account_call["kwargs"]["base_url"] == "https://chatgpt.com/backend-api/codex"
|
||||
assert "📊 **Session Info**" in result
|
||||
assert "📈 **Account limits**" in result
|
||||
|
||||
|
||||
class TestUsageContextBreakdown:
|
||||
"""The /usage output includes the per-category context breakdown."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breakdown_lines_rendered_for_live_agent(self):
|
||||
agent = _make_mock_agent()
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
session_entry = MagicMock()
|
||||
session_entry.session_id = "sess-bd"
|
||||
runner.session_store.get_or_create_session.return_value = session_entry
|
||||
runner.session_store.load_transcript.return_value = [
|
||||
{"role": "user", "content": "hi"},
|
||||
]
|
||||
event = MagicMock()
|
||||
|
||||
fake_payload = {
|
||||
"categories": [
|
||||
{"id": "system_prompt", "label": "System prompt", "tokens": 4000, "color": "x"},
|
||||
{"id": "tool_definitions", "label": "Tool definitions", "tokens": 6000, "color": "x"},
|
||||
{"id": "conversation", "label": "Conversation", "tokens": 0, "color": "x"},
|
||||
],
|
||||
"estimated_total": 10000,
|
||||
"context_max": 200000,
|
||||
"context_percent": 5,
|
||||
"context_used": 30000,
|
||||
"model": "anthropic/claude-sonnet-4.6",
|
||||
}
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.context_breakdown.compute_session_context_breakdown", return_value=fake_payload):
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
# Localized header + at least the two non-zero category labels appear,
|
||||
# each labelled as a percentage of the estimated total.
|
||||
assert "Context breakdown" in result
|
||||
assert "System prompt" in result
|
||||
assert "Tool definitions" in result
|
||||
assert "4,000" in result # system prompt tokens, comma-formatted
|
||||
assert "40%" in result # 4000 / 10000
|
||||
assert "60%" in result # 6000 / 10000
|
||||
# Zero-token category is dropped, not rendered.
|
||||
assert "Conversation" not in result
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_breakdown_failure_is_non_fatal(self):
|
||||
"""A breakdown engine error must not break the rest of /usage."""
|
||||
agent = _make_mock_agent()
|
||||
runner = _make_runner(SK, cached_agent=agent)
|
||||
runner.session_store.get_or_create_session.side_effect = RuntimeError("boom")
|
||||
event = MagicMock()
|
||||
|
||||
with patch("agent.rate_limit_tracker.format_rate_limit_compact", return_value="RPM: 50/60"), \
|
||||
patch("agent.context_breakdown.compute_session_context_breakdown",
|
||||
side_effect=RuntimeError("engine down")):
|
||||
result = await runner._handle_usage_command(event)
|
||||
|
||||
# Core usage lines still render; no breakdown header.
|
||||
assert "📊 **Session Token Usage**" in result
|
||||
assert "50,000" in result # total tokens
|
||||
assert "Context breakdown" not in result
|
||||
|
||||
@@ -229,93 +229,6 @@ def test_starting_state_does_not_autostart(tmp_path: Path) -> None:
|
||||
assert named[0].action == "registered"
|
||||
|
||||
|
||||
def test_draining_runtime_state_autostarts(tmp_path: Path) -> None:
|
||||
"""A gateway hard-killed mid-drain leaves `gateway_state=draining` as its
|
||||
last persisted value (the recreate SIGTERMs it before `_stop_impl` can
|
||||
write a terminal `stopped`/`running`). `draining` is a transient sub-state
|
||||
of RUNNING, not an operator stop, so with no explicit `desired_state` it
|
||||
must normalise to running-intent and auto-start — otherwise the gateway
|
||||
stays DOWN forever and messaging silently goes dark (the relay-opted-in
|
||||
staging wedge, 2026-06)."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "drained", state="draining")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert _named_actions(actions) == [ReconcileAction(
|
||||
profile="drained", prior_state="running", action="started",
|
||||
)]
|
||||
# Autostart means NO down-marker — the gateway comes back up.
|
||||
assert not (scandir / "gateway-drained" / "down").exists()
|
||||
|
||||
|
||||
def test_degraded_runtime_state_autostarts(tmp_path: Path) -> None:
|
||||
"""`degraded` is the same wedge class as `draining`: the gateway came up
|
||||
with some platforms queued for retry, then fell through to the normal
|
||||
running state (gateway/run.py #5196) and is serving cron + connected
|
||||
platforms. A hard-kill there strands `gateway_state=degraded`, which is
|
||||
NOT an operator stop and NOT a failed boot. With no explicit
|
||||
`desired_state` it must normalise to running-intent and auto-start —
|
||||
otherwise the gateway stays DOWN forever exactly like the draining wedge."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(tmp_path, "degraded-box", state="degraded")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert _named_actions(actions) == [ReconcileAction(
|
||||
profile="degraded-box", prior_state="running", action="started",
|
||||
)]
|
||||
assert not (scandir / "gateway-degraded-box" / "down").exists()
|
||||
|
||||
|
||||
def test_draining_default_root_autostarts(tmp_path: Path) -> None:
|
||||
"""The hosted-agent path: the default (root) profile, not a named one.
|
||||
A managed Fly instance runs the root profile; a stranded `draining` there
|
||||
is exactly what wedged the relay-opted-in staging instance. Mirror the
|
||||
named-profile case for the default slot."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_seed_default_root(tmp_path, state="draining")
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
default_action = next(a for a in actions if a.profile == "default")
|
||||
assert default_action.prior_state == "running"
|
||||
assert default_action.action == "started"
|
||||
assert not (scandir / "gateway-default" / "down").exists()
|
||||
|
||||
|
||||
def test_desired_state_stopped_overrides_draining_runtime(tmp_path: Path) -> None:
|
||||
"""An explicit operator stop must survive even when the transient runtime
|
||||
state is `draining`. The `desired_state` is the durable intent and is
|
||||
honoured verbatim — the draining→running normalisation only applies to the
|
||||
legacy/transient `gateway_state` fallback, never over an explicit
|
||||
`desired_state`."""
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
_make_profile(
|
||||
tmp_path,
|
||||
"stopped-while-draining",
|
||||
state="draining",
|
||||
desired_state="stopped",
|
||||
)
|
||||
|
||||
actions = reconcile_profile_gateways(
|
||||
hermes_home=tmp_path, scandir=scandir, dry_run=False,
|
||||
)
|
||||
|
||||
assert _named_actions(actions) == [ReconcileAction(
|
||||
profile="stopped-while-draining",
|
||||
prior_state="stopped",
|
||||
action="registered",
|
||||
)]
|
||||
assert (scandir / "gateway-stopped-while-draining" / "down").exists()
|
||||
|
||||
|
||||
def test_stale_runtime_files_are_removed(tmp_path: Path) -> None:
|
||||
scandir = tmp_path / "run-service"; scandir.mkdir()
|
||||
profile = _make_profile(tmp_path, "coder", state="running", with_pid=True)
|
||||
|
||||
@@ -18,7 +18,6 @@ from hermes_cli.plugins import (
|
||||
get_plugin_command_handler,
|
||||
get_plugin_commands,
|
||||
get_pre_tool_call_block_message,
|
||||
get_pre_verify_continue_message,
|
||||
has_middleware,
|
||||
resolve_plugin_command_result,
|
||||
)
|
||||
@@ -859,73 +858,6 @@ class TestPreToolCallBlocking:
|
||||
assert get_pre_tool_call_block_message("terminal", {}) == "first blocker"
|
||||
|
||||
|
||||
class TestGetPreVerifyContinueMessage:
|
||||
"""`pre_verify` directive aggregation — mirrors the pre_tool_call block path."""
|
||||
|
||||
def test_continue_canonical(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [{"action": "continue", "message": "run checks"}],
|
||||
)
|
||||
assert get_pre_verify_continue_message(session_id="s") == "run checks"
|
||||
|
||||
def test_claude_block_means_continue(self, monkeypatch):
|
||||
# Claude-Code Stop: "block" the stop == keep going; reason → message.
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [{"decision": "block", "reason": "run the formatter"}],
|
||||
)
|
||||
assert get_pre_verify_continue_message() == "run the formatter"
|
||||
|
||||
def test_first_actionable_directive_wins(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [
|
||||
"noise", # not a dict
|
||||
{"action": "continue"}, # no message → skipped
|
||||
{"action": "continue", "message": "second"},
|
||||
{"action": "continue", "message": "third"},
|
||||
],
|
||||
)
|
||||
assert get_pre_verify_continue_message() == "second"
|
||||
|
||||
def test_message_is_trimmed(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [{"action": "continue", "message": " tidy up "}],
|
||||
)
|
||||
assert get_pre_verify_continue_message() == "tidy up"
|
||||
|
||||
def test_invalid_returns_ignored(self, monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.plugins.invoke_hook",
|
||||
lambda hook_name, **kwargs: [
|
||||
{"action": "allow"}, # wrong action
|
||||
{"context": "noise"}, # not a directive
|
||||
{"action": "continue", "message": " "}, # blank message
|
||||
{"action": "continue", "message": 42}, # message not str
|
||||
],
|
||||
)
|
||||
assert get_pre_verify_continue_message() is None
|
||||
|
||||
def test_none_when_no_hooks(self, monkeypatch):
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", lambda hook_name, **kwargs: [])
|
||||
assert get_pre_verify_continue_message() is None
|
||||
|
||||
def test_forwards_scope_signals_to_hooks(self, monkeypatch):
|
||||
seen = {}
|
||||
|
||||
def capture(hook_name, **kwargs):
|
||||
seen.update(kwargs)
|
||||
return []
|
||||
|
||||
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", capture)
|
||||
get_pre_verify_continue_message(coding=True, attempt=2, changed_paths=["a.py"])
|
||||
assert seen["coding"] is True
|
||||
assert seen["attempt"] == 2
|
||||
assert seen["changed_paths"] == ["a.py"]
|
||||
|
||||
|
||||
class TestThreadToolWhitelist:
|
||||
"""Tests for the thread-local tool whitelist used by background review forks."""
|
||||
|
||||
|
||||
@@ -127,34 +127,14 @@ def _mint_id_token(
|
||||
)
|
||||
|
||||
|
||||
def _make_provider(
|
||||
rsa_keypair,
|
||||
*,
|
||||
scopes: str | None = None,
|
||||
client_secret: str | None = None,
|
||||
auth_methods: Any = "__unset__",
|
||||
):
|
||||
"""Construct a provider with discovery + JWKS stubbed (no network).
|
||||
|
||||
``client_secret`` flips the provider into confidential mode. ``auth_methods``
|
||||
overrides ``token_endpoint_auth_methods_supported`` in the seeded discovery
|
||||
doc (pass a list, or ``None`` to omit the key entirely); left unset, the
|
||||
discovery doc carries no auth-methods key (the absent-key default).
|
||||
"""
|
||||
def _make_provider(rsa_keypair, *, scopes: str | None = None):
|
||||
"""Construct a provider with discovery + JWKS stubbed (no network)."""
|
||||
kwargs: Dict[str, Any] = {"issuer": _ISSUER, "client_id": _CLIENT_ID}
|
||||
if scopes is not None:
|
||||
kwargs["scopes"] = scopes
|
||||
if client_secret is not None:
|
||||
kwargs["client_secret"] = client_secret
|
||||
p = oidc_plugin.SelfHostedOIDCProvider(**kwargs)
|
||||
# Pre-seed discovery so nothing hits the network.
|
||||
disco = dict(_DISCOVERY_DOC)
|
||||
if auth_methods != "__unset__":
|
||||
if auth_methods is None:
|
||||
disco.pop("token_endpoint_auth_methods_supported", None)
|
||||
else:
|
||||
disco["token_endpoint_auth_methods_supported"] = auth_methods
|
||||
p._discovery = disco
|
||||
p._discovery = dict(_DISCOVERY_DOC)
|
||||
p._discovery_fetched_at = time.time()
|
||||
# Patch the JWKS client to return our fixture key.
|
||||
fake_key = MagicMock()
|
||||
@@ -675,185 +655,6 @@ class TestCompleteLogin:
|
||||
assert kwargs["data"]["client_id"] == _CLIENT_ID
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Confidential client (client_secret) — token-endpoint client authentication
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
_GOOD_TOKEN_RESP_KEYS = {"token_type": "Bearer", "refresh_token": "rt_initial"}
|
||||
|
||||
|
||||
def _decode_basic(header_value: str) -> tuple[str, str]:
|
||||
"""Decode a ``Basic <b64>`` Authorization header back to (user, pass)."""
|
||||
assert header_value.startswith("Basic ")
|
||||
raw = base64.b64decode(header_value[len("Basic ") :]).decode("utf-8")
|
||||
user, _, pw = raw.partition(":")
|
||||
# client_id / secret are form-url-encoded before base64 (RFC 6749 §2.3.1).
|
||||
return urllib.parse.unquote(user), urllib.parse.unquote(pw)
|
||||
|
||||
|
||||
class TestConfidentialClient:
|
||||
"""A configured ``client_secret`` authenticates the client at the token
|
||||
endpoint (basic header or post body, auto-selected from discovery), while
|
||||
PKCE is still sent. A public client (no secret) is byte-identical to the
|
||||
pre-confidential-client behaviour — no secret anywhere, no auth header."""
|
||||
|
||||
def _complete(self, provider, rsa_keypair):
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(200, {"id_token": id_token, **_GOOD_TOKEN_RESP_KEYS})
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
provider.complete_login(
|
||||
code="the-code",
|
||||
state="s",
|
||||
code_verifier="the-verifier",
|
||||
redirect_uri="https://hermes.example/auth/callback",
|
||||
)
|
||||
_, kwargs = mock_post.call_args
|
||||
return kwargs
|
||||
|
||||
# -- public client: nothing changes ------------------------------------
|
||||
|
||||
def test_public_client_sends_no_secret_or_auth_header(self, rsa_keypair):
|
||||
# No client_secret configured → no Authorization header, no
|
||||
# client_secret in the body. Pins the unchanged public-client contract.
|
||||
provider = _make_provider(rsa_keypair) # public
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
assert "Authorization" not in kwargs["headers"]
|
||||
assert "client_secret" not in kwargs["data"]
|
||||
# PKCE still present.
|
||||
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||
# Header is exactly the pre-feature value.
|
||||
assert kwargs["headers"] == {"Accept": "application/json"}
|
||||
|
||||
# -- basic (default & explicit) ----------------------------------------
|
||||
|
||||
def test_confidential_defaults_to_basic_when_methods_absent(self, rsa_keypair):
|
||||
# Discovery advertises no auth methods → OIDC default is Basic.
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret="s3cr3t", auth_methods=None
|
||||
)
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
assert "client_secret" not in kwargs["data"] # not in body for basic
|
||||
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
|
||||
# PKCE still sent alongside the secret.
|
||||
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||
|
||||
def test_confidential_basic_when_explicitly_advertised(self, rsa_keypair):
|
||||
provider = _make_provider(
|
||||
rsa_keypair,
|
||||
client_secret="s3cr3t",
|
||||
auth_methods=["client_secret_basic", "client_secret_post"],
|
||||
)
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
# When both are advertised we prefer basic (secret stays out of body).
|
||||
assert "client_secret" not in kwargs["data"]
|
||||
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
|
||||
|
||||
# -- post --------------------------------------------------------------
|
||||
|
||||
def test_confidential_post_when_only_post_advertised(self, rsa_keypair):
|
||||
provider = _make_provider(
|
||||
rsa_keypair,
|
||||
client_secret="s3cr3t",
|
||||
auth_methods=["client_secret_post"],
|
||||
)
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
assert kwargs["data"]["client_secret"] == "s3cr3t"
|
||||
assert "Authorization" not in kwargs["headers"]
|
||||
assert kwargs["data"]["code_verifier"] == "the-verifier"
|
||||
|
||||
# -- url-encoding of reserved chars ------------------------------------
|
||||
|
||||
def test_basic_url_encodes_reserved_chars_in_secret(self, rsa_keypair):
|
||||
# A secret with ':' / '@' / space must round-trip through the Basic
|
||||
# header exactly — these are exactly the chars that corrupt a naive
|
||||
# "id:secret" concatenation.
|
||||
tricky = "p@ss:wo rd/+="
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret=tricky, auth_methods=["client_secret_basic"]
|
||||
)
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||
assert user == _CLIENT_ID
|
||||
assert pw == tricky
|
||||
|
||||
# -- blank secret is treated as public ---------------------------------
|
||||
|
||||
def test_whitespace_secret_is_public(self, rsa_keypair):
|
||||
# A provisioned-but-blank secret must NOT flip us into confidential
|
||||
# mode (which would send an empty secret and break the exchange).
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret=" ", auth_methods=["client_secret_basic"]
|
||||
)
|
||||
kwargs = self._complete(provider, rsa_keypair)
|
||||
assert "Authorization" not in kwargs["headers"]
|
||||
assert "client_secret" not in kwargs["data"]
|
||||
|
||||
# -- refresh grant also authenticates ----------------------------------
|
||||
|
||||
def test_refresh_grant_authenticates_confidential_client(self, rsa_keypair):
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_post"]
|
||||
)
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(
|
||||
200, {"id_token": id_token, "token_type": "Bearer", "refresh_token": "rt2"}
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
provider.refresh_session(refresh_token="rt_old")
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["data"]["grant_type"] == "refresh_token"
|
||||
assert kwargs["data"]["client_secret"] == "s3cr3t"
|
||||
|
||||
def test_refresh_grant_basic_header_confidential_client(self, rsa_keypair):
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_basic"]
|
||||
)
|
||||
id_token = _mint_id_token(rsa_keypair)
|
||||
mock_resp = _mock_post(
|
||||
200, {"id_token": id_token, "token_type": "Bearer", "refresh_token": "rt2"}
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post", return_value=mock_resp
|
||||
) as mock_post:
|
||||
provider.refresh_session(refresh_token="rt_old")
|
||||
_, kwargs = mock_post.call_args
|
||||
user, pw = _decode_basic(kwargs["headers"]["Authorization"])
|
||||
assert (user, pw) == (_CLIENT_ID, "s3cr3t")
|
||||
|
||||
# -- revocation also authenticates -------------------------------------
|
||||
|
||||
def test_revoke_authenticates_confidential_client(self, rsa_keypair):
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret="s3cr3t", auth_methods=["client_secret_post"]
|
||||
)
|
||||
with patch(
|
||||
"plugins.dashboard_auth.self_hosted.httpx.post"
|
||||
) as mock_post:
|
||||
provider.revoke_session(refresh_token="rt_old")
|
||||
_, kwargs = mock_post.call_args
|
||||
assert kwargs["data"]["client_secret"] == "s3cr3t"
|
||||
|
||||
# -- the secret never appears in logs ----------------------------------
|
||||
|
||||
def test_secret_not_in_repr_or_log(self, rsa_keypair, caplog):
|
||||
import logging
|
||||
|
||||
with caplog.at_level(logging.INFO):
|
||||
provider = _make_provider(
|
||||
rsa_keypair, client_secret="sup3r-s3cr3t", auth_methods=None
|
||||
)
|
||||
# The provider object's repr must not leak the secret.
|
||||
assert "sup3r-s3cr3t" not in repr(provider)
|
||||
assert "sup3r-s3cr3t" not in caplog.text
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# verify_session
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1052,7 +853,6 @@ class TestPluginRegister:
|
||||
"HERMES_DASHBOARD_OIDC_ISSUER",
|
||||
"HERMES_DASHBOARD_OIDC_CLIENT_ID",
|
||||
"HERMES_DASHBOARD_OIDC_SCOPES",
|
||||
"HERMES_DASHBOARD_OIDC_CLIENT_SECRET",
|
||||
):
|
||||
monkeypatch.delenv(var, raising=False)
|
||||
|
||||
@@ -1178,87 +978,3 @@ class TestPluginRegister:
|
||||
oidc_plugin.register(ctx)
|
||||
ctx.register_dashboard_auth_provider.assert_not_called()
|
||||
assert "construction failed" in oidc_plugin.LAST_SKIP_REASON
|
||||
|
||||
# -- client_secret wiring ----------------------------------------------
|
||||
|
||||
def test_registers_public_when_no_secret(self, patch_config, monkeypatch):
|
||||
patch_config(None)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_secret == ""
|
||||
|
||||
def test_secret_from_env(self, patch_config, monkeypatch):
|
||||
patch_config(None)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_secret == "env-secret"
|
||||
|
||||
def test_secret_from_config_yaml(self, patch_config):
|
||||
patch_config(
|
||||
{
|
||||
"self_hosted": {
|
||||
"issuer": _ISSUER,
|
||||
"client_id": _CLIENT_ID,
|
||||
"client_secret": "cfg-secret",
|
||||
}
|
||||
}
|
||||
)
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_secret == "cfg-secret"
|
||||
|
||||
def test_env_secret_overrides_config(self, patch_config, monkeypatch):
|
||||
patch_config(
|
||||
{
|
||||
"self_hosted": {
|
||||
"issuer": _ISSUER,
|
||||
"client_id": _CLIENT_ID,
|
||||
"client_secret": "cfg-secret",
|
||||
}
|
||||
}
|
||||
)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "env-secret")
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_secret == "env-secret"
|
||||
|
||||
def test_empty_env_secret_does_not_shadow_config(self, patch_config, monkeypatch):
|
||||
patch_config(
|
||||
{
|
||||
"self_hosted": {
|
||||
"issuer": _ISSUER,
|
||||
"client_id": _CLIENT_ID,
|
||||
"client_secret": "cfg-secret",
|
||||
}
|
||||
}
|
||||
)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "")
|
||||
ctx = MagicMock()
|
||||
oidc_plugin.register(ctx)
|
||||
registered = ctx.register_dashboard_auth_provider.call_args.args[0]
|
||||
assert registered._client_secret == "cfg-secret"
|
||||
|
||||
def test_register_log_reports_confidential_not_secret(
|
||||
self, patch_config, monkeypatch, caplog
|
||||
):
|
||||
import logging
|
||||
|
||||
patch_config(None)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_ISSUER", _ISSUER)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_ID", _CLIENT_ID)
|
||||
monkeypatch.setenv("HERMES_DASHBOARD_OIDC_CLIENT_SECRET", "logme-secret")
|
||||
ctx = MagicMock()
|
||||
with caplog.at_level(logging.INFO):
|
||||
oidc_plugin.register(ctx)
|
||||
# The success log reports confidentiality as a boolean, never the value.
|
||||
assert "logme-secret" not in caplog.text
|
||||
assert "confidential=True" in caplog.text
|
||||
|
||||
@@ -16,17 +16,9 @@ import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _fake_api_key(monkeypatch, tmp_path):
|
||||
def _fake_api_key(monkeypatch):
|
||||
"""Ensure XAI_API_KEY is set for all tests."""
|
||||
monkeypatch.setenv("XAI_API_KEY", "test-key-12345")
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
try:
|
||||
import hermes_cli.config as cfg_mod
|
||||
|
||||
if hasattr(cfg_mod, "_invalidate_load_config_cache"):
|
||||
cfg_mod._invalidate_load_config_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -88,13 +80,6 @@ class TestXAIImageGenProvider:
|
||||
assert schema["env_vars"] == []
|
||||
assert schema["post_setup"] == "xai_grok"
|
||||
|
||||
def test_capabilities_expose_total_source_image_limit(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
caps = XAIImageGenProvider().capabilities()
|
||||
assert caps["max_reference_images"] == 2
|
||||
assert caps["max_source_images"] == 3
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Config tests
|
||||
@@ -333,131 +318,6 @@ class TestGenerate:
|
||||
f"resolution must be the literal '1k' or '2k', got {payload['resolution']!r}"
|
||||
)
|
||||
|
||||
def test_image_edit_rejects_bare_file_id_input(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="make the robot red",
|
||||
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_image_url"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_image_edit_accepts_public_https_url(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
public_url = "https://files-cdn.x.ai/token/file_abc.png"
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="make the robot red",
|
||||
image_url=public_url,
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
||||
assert payload["image"] == {"url": public_url, "type": "image_url"}
|
||||
|
||||
def test_multi_image_edit_rejects_bare_file_id_inputs(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"url": "https://xai.image/edited.png"}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_url_image", return_value="/tmp/edited.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="combine these robots into one product shot",
|
||||
image_url="file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
|
||||
reference_image_urls=[
|
||||
"file_54b48d6d-28ad-4982-9d72-bd3ac677c9bc",
|
||||
"file_aa11bb22-cc33-44dd-88ee-ff0011223344",
|
||||
],
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "invalid_image_url"
|
||||
mock_post.assert_not_called()
|
||||
|
||||
def test_multi_image_edit_rejects_more_than_three_sources(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(
|
||||
prompt="combine too many references",
|
||||
image_url="file_1",
|
||||
reference_image_urls=["file_2", "file_3", "file_4"],
|
||||
)
|
||||
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "too_many_references"
|
||||
|
||||
def test_storage_options_are_sent_by_default(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {"data": [{"b64_json": "dGVzdA=="}]}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp) as mock_post, \
|
||||
patch("plugins.image_gen.xai.save_b64_image", return_value="/tmp/test.png"):
|
||||
provider = XAIImageGenProvider()
|
||||
provider.generate(prompt="test")
|
||||
|
||||
payload = mock_post.call_args.kwargs.get("json") or mock_post.call_args[1].get("json")
|
||||
assert payload["storage_options"]["public_url"] is True
|
||||
assert "expires_after" not in payload["storage_options"]
|
||||
assert payload["storage_options"]["filename"].endswith(".png")
|
||||
|
||||
def test_public_url_file_output_wins_over_temporary_url(self):
|
||||
from plugins.image_gen.xai import XAIImageGenProvider
|
||||
|
||||
mock_resp = MagicMock()
|
||||
mock_resp.status_code = 200
|
||||
mock_resp.raise_for_status = MagicMock()
|
||||
mock_resp.json.return_value = {
|
||||
"data": [{
|
||||
"url": "https://imgen.x.ai/xai-tmp-imgen-test.jpeg",
|
||||
"file_output": {
|
||||
"file_id": "file-123",
|
||||
"filename": "stored.png",
|
||||
"public_url": "https://xai-files.example/stored.png",
|
||||
"public_url_expires_at": 1234567890,
|
||||
},
|
||||
}],
|
||||
}
|
||||
|
||||
with patch("plugins.image_gen.xai.requests.post", return_value=mock_resp), \
|
||||
patch("plugins.image_gen.xai.save_url_image") as mock_save_url:
|
||||
provider = XAIImageGenProvider()
|
||||
result = provider.generate(prompt="A cat playing piano")
|
||||
|
||||
assert result["success"] is True
|
||||
assert result["image"] == "https://xai-files.example/stored.png"
|
||||
assert result["public_url"] == "https://xai-files.example/stored.png"
|
||||
assert "file_id" not in result
|
||||
mock_save_url.assert_not_called()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration test
|
||||
@@ -474,21 +334,3 @@ class TestRegistration:
|
||||
provider = mock_ctx.register_image_gen_provider.call_args[0][0]
|
||||
assert isinstance(provider, XAIImageGenProvider)
|
||||
assert provider.name == "xai"
|
||||
|
||||
|
||||
def test_xai_image_field_expands_user_home(tmp_path, monkeypatch):
|
||||
"""A ~-prefixed local image path must load (expanduser), not raise io_error.
|
||||
|
||||
Pre-flight validation uses ``Path(source).expanduser()`` so a ``~/...`` path
|
||||
passes; ``_xai_image_field`` must expand it too or the load fails spuriously.
|
||||
"""
|
||||
from plugins.image_gen.xai import _xai_image_field
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
img = tmp_path / "pic.png"
|
||||
img.write_bytes(b"\x89PNG\r\n\x1a\n")
|
||||
|
||||
field = _xai_image_field("~/pic.png")
|
||||
assert field["type"] == "image_url"
|
||||
assert field["url"].startswith("data:image/png;base64,")
|
||||
|
||||
@@ -32,9 +32,9 @@ def test_xai_provider_lists_text_and_current_image_video_models():
|
||||
ids = [model["id"] for model in models]
|
||||
|
||||
assert ids[0] == "grok-imagine-video"
|
||||
assert ids[1] == "grok-imagine-video-1.5"
|
||||
assert ids[1] == "grok-imagine-video-1.5-preview"
|
||||
assert models[1]["modalities"] == ["image"]
|
||||
assert "aliases" not in models[1]
|
||||
assert models[1]["aliases"] == ["grok-imagine-video-1.5-2026-05-30"]
|
||||
|
||||
|
||||
def test_xai_routes_default_models_by_modality():
|
||||
@@ -49,7 +49,7 @@ def test_xai_routes_default_models_by_modality():
|
||||
"grok-imagine-video",
|
||||
modality="image",
|
||||
explicit_model=False,
|
||||
) == "grok-imagine-video-1.5"
|
||||
) == "grok-imagine-video-1.5-preview"
|
||||
assert _resolve_model_for_modality(
|
||||
"grok-imagine-video-1.5-preview",
|
||||
modality="text",
|
||||
@@ -62,11 +62,15 @@ def test_xai_routes_default_models_by_modality():
|
||||
) == "grok-imagine-video-1.5-preview"
|
||||
|
||||
|
||||
def test_xai_capabilities_keep_generate_surface_only():
|
||||
def test_xai_capabilities_text_and_image_only():
|
||||
"""xAI was previously advertised with edit/extend operations. The
|
||||
simplified surface only exposes text-to-video and image-to-video —
|
||||
confirm those are the only modalities advertised."""
|
||||
from plugins.video_gen.xai import XAIVideoGenProvider
|
||||
|
||||
caps = XAIVideoGenProvider().capabilities()
|
||||
assert caps["modalities"] == ["text", "image"]
|
||||
# No 'operations' key in the simplified surface
|
||||
assert "operations" not in caps
|
||||
assert caps["max_reference_images"] == 7
|
||||
|
||||
@@ -144,45 +148,3 @@ def test_xai_no_operation_kwarg():
|
||||
assert result["success"] is False
|
||||
# auth_required, NOT some signature error
|
||||
assert result["error_type"] in {"auth_required", "api_error"}
|
||||
|
||||
|
||||
def test_xai_video_output_urls_prefers_stored_public_url():
|
||||
from plugins.video_gen.xai import _xai_video_output_urls
|
||||
|
||||
public_url, temporary, stored = _xai_video_output_urls({
|
||||
"url": "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4",
|
||||
"file_output": {
|
||||
"public_url": "https://files-cdn.x.ai/token/file_abc.mp4",
|
||||
"file_id": "file_abc",
|
||||
},
|
||||
})
|
||||
assert public_url == "https://files-cdn.x.ai/token/file_abc.mp4"
|
||||
assert stored == "https://files-cdn.x.ai/token/file_abc.mp4"
|
||||
assert temporary == "https://vidgen.x.ai/xai-vidgen-bucket/out.mp4"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_video_input_from_public_url_uses_url_field():
|
||||
from plugins.video_gen.xai import _video_input_from_public_url
|
||||
|
||||
url = "https://files-cdn.x.ai/kRQVP6PRQlioVAUNC3GAdg/file_1faca9c3-9411-46ad-bb41-b9b8527789e6.mp4"
|
||||
result = await _video_input_from_public_url(
|
||||
url,
|
||||
api_key="test-key",
|
||||
base_url="https://api.x.ai/v1",
|
||||
)
|
||||
assert result == {"url": url}
|
||||
|
||||
|
||||
def test_video_input_from_public_url_rejects_bare_file_id():
|
||||
import asyncio
|
||||
from plugins.video_gen.xai import _video_input_from_public_url
|
||||
|
||||
result = asyncio.run(
|
||||
_video_input_from_public_url(
|
||||
"file_1faca9c3-9411-46ad-bb41-b9b8527789e6",
|
||||
api_key="test-key",
|
||||
base_url="https://api.x.ai/v1",
|
||||
)
|
||||
)
|
||||
assert result is None
|
||||
|
||||
@@ -122,7 +122,7 @@ class TestXAIPayload:
|
||||
provider, captured = xai_provider
|
||||
provider.generate("animate this", image_url="https://example.com/cat.png")
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video-1.5"
|
||||
assert payload["model"] == "grok-imagine-video-1.5-preview"
|
||||
assert payload["image"] == {"url": "https://example.com/cat.png"}
|
||||
|
||||
def test_local_image_path_is_sent_as_data_uri(self, xai_provider, tmp_path):
|
||||
@@ -133,7 +133,7 @@ class TestXAIPayload:
|
||||
provider.generate("animate this", image_url=str(image_path))
|
||||
|
||||
payload = _last_post(captured)["json"]
|
||||
assert payload["model"] == "grok-imagine-video-1.5"
|
||||
assert payload["model"] == "grok-imagine-video-1.5-preview"
|
||||
assert payload["image"]["url"].startswith("data:image/png;base64,")
|
||||
|
||||
def test_explicit_model_override_is_honored_for_image(self, xai_provider):
|
||||
|
||||
@@ -854,7 +854,7 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():
|
||||
|
||||
assert server._history_to_messages(history) == [
|
||||
{"role": "user", "text": "first prompt"},
|
||||
{"context": "Searching files for resume", "name": "search_files", "role": "tool"},
|
||||
{"context": "resume", "name": "search_files", "role": "tool"},
|
||||
{"role": "assistant", "text": "first answer"},
|
||||
{"role": "user", "text": "second prompt"},
|
||||
]
|
||||
@@ -5461,23 +5461,13 @@ def test_session_create_no_race_keeps_worker_alive(monkeypatch):
|
||||
assert built, "agent build did not complete within timeout"
|
||||
|
||||
# Build finished without a close race — nothing should have been
|
||||
# cleaned up by the orphan check. Scope the assertions to THIS
|
||||
# test's own session_key: a daemon build thread leaked from a prior
|
||||
# session.create test in the same shard process can fire close/
|
||||
# unregister against its own (foreign) key after we've patched the
|
||||
# global hooks, polluting these lists. Filtering by this session's
|
||||
# key keeps the regression intent (this session's worker/notify must
|
||||
# survive) while making the test immune to shard composition.
|
||||
# (flaky under -j 8: foreign key e.g. 20260629_210208_d4f545)
|
||||
own_key = session["session_key"]
|
||||
own_closed = [k for k in closed_workers if k == own_key]
|
||||
own_unregistered = [k for k in unregistered_keys if k == own_key]
|
||||
# cleaned up by the orphan check.
|
||||
assert (
|
||||
own_closed == []
|
||||
), f"build thread closed its own worker despite no race: {own_closed}"
|
||||
closed_workers == []
|
||||
), f"build thread closed its own worker despite no race: {closed_workers}"
|
||||
assert (
|
||||
own_unregistered == []
|
||||
), f"build thread unregistered its own notify despite no race: {own_unregistered}"
|
||||
unregistered_keys == []
|
||||
), f"build thread unregistered its own notify despite no race: {unregistered_keys}"
|
||||
|
||||
# Session should have the live worker installed.
|
||||
assert session.get("slash_worker") is not None
|
||||
|
||||
@@ -589,108 +589,11 @@ def test_complete_retry_with_corrected_created_cards_succeeds(worker_env):
|
||||
}))
|
||||
assert ok.get("ok") is True
|
||||
|
||||
|
||||
def test_complete_goal_mode_rejected_by_judge(monkeypatch, tmp_path):
|
||||
"""Goal-mode tasks must pass the auxiliary judge before completion.
|
||||
Regression for #38367: workers bypassing the judge via early kanban_complete."""
|
||||
from pathlib import Path as _Path
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
# Set up isolated HERMES_HOME
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_PROFILE", "test-worker")
|
||||
monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
|
||||
monkeypatch.setattr(_Path, "home", lambda: tmp_path)
|
||||
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
kb.init_db()
|
||||
conn = kb.connect()
|
||||
try:
|
||||
goal_task_id = kb.create_task(
|
||||
conn, title="goal-mode-test", assignee="test-worker",
|
||||
body="Must achieve X with verified evidence.", goal_mode=True
|
||||
)
|
||||
kb.claim_task(conn, goal_task_id)
|
||||
assert kb.get_task(conn, worker_env).status == "done"
|
||||
finally:
|
||||
conn.close()
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", goal_task_id)
|
||||
|
||||
# Mock the judge to reject the completion. The gate only runs when a
|
||||
# judge is reachable, so force the availability probe True as well.
|
||||
def mock_judge_goal(goal, last_response, *, timeout=30.0, subgoals=None):
|
||||
return "continue", "missing verification evidence", False
|
||||
|
||||
monkeypatch.setattr("tools.kanban_tools.judge_goal", mock_judge_goal)
|
||||
monkeypatch.setattr("tools.kanban_tools._goal_judge_available", lambda: True)
|
||||
|
||||
# Attempt to complete should be rejected
|
||||
out = kt._handle_complete({"summary": "I did some stuff but not X"})
|
||||
d = json.loads(out)
|
||||
assert "error" in d
|
||||
assert "Goal completion rejected by judge" in d["error"]
|
||||
assert "missing verification evidence" in d["error"]
|
||||
assert f"parents=[{goal_task_id}]" in d["error"]
|
||||
|
||||
# Verify the task is NOT completed in the DB
|
||||
conn2 = kb.connect()
|
||||
try:
|
||||
task = kb.get_task(conn2, goal_task_id)
|
||||
assert task.status == "running" # Should still be running, not done
|
||||
finally:
|
||||
conn2.close()
|
||||
|
||||
|
||||
def test_complete_goal_mode_allows_when_judge_unavailable(monkeypatch, tmp_path):
|
||||
"""Fail-open: an unreachable judge must not wedge a goal_mode worker.
|
||||
|
||||
judge_goal returns a "continue" verdict when no auxiliary model is
|
||||
configured, which is indistinguishable from a real "not done" judgment.
|
||||
The gate probes availability first, so completion proceeds rather than
|
||||
being rejected forever when no judge can be reached."""
|
||||
from pathlib import Path as _Path
|
||||
from hermes_cli import kanban_db as kb
|
||||
from tools import kanban_tools as kt
|
||||
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir()
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_PROFILE", "test-worker")
|
||||
monkeypatch.delenv("HERMES_SESSION_ID", raising=False)
|
||||
monkeypatch.setattr(_Path, "home", lambda: tmp_path)
|
||||
|
||||
kb._INITIALIZED_PATHS.clear()
|
||||
kb.init_db()
|
||||
conn = kb.connect()
|
||||
try:
|
||||
goal_task_id = kb.create_task(
|
||||
conn, title="goal-mode-test", assignee="test-worker",
|
||||
body="Must achieve X with verified evidence.", goal_mode=True
|
||||
)
|
||||
kb.claim_task(conn, goal_task_id)
|
||||
finally:
|
||||
conn.close()
|
||||
monkeypatch.setenv("HERMES_KANBAN_TASK", goal_task_id)
|
||||
|
||||
# No judge reachable. judge_goal must not even be consulted; if it were,
|
||||
# this stub would reject — so reaching "done" proves the probe short-circuit.
|
||||
def fail_if_called(goal, last_response, *, timeout=30.0, subgoals=None):
|
||||
raise AssertionError("judge_goal must not run when no judge is available")
|
||||
|
||||
monkeypatch.setattr("tools.kanban_tools.judge_goal", fail_if_called)
|
||||
monkeypatch.setattr("tools.kanban_tools._goal_judge_available", lambda: False)
|
||||
|
||||
out = kt._handle_complete({"summary": "done enough"})
|
||||
d = json.loads(out)
|
||||
assert d.get("ok") is True
|
||||
|
||||
conn2 = kb.connect()
|
||||
try:
|
||||
assert kb.get_task(conn2, goal_task_id).status == "done"
|
||||
finally:
|
||||
conn2.close()
|
||||
|
||||
|
||||
def test_block_happy_path(worker_env):
|
||||
|
||||
@@ -35,9 +35,6 @@ class _RecordingProvider(VideoGenProvider):
|
||||
def default_model(self) -> Optional[str]:
|
||||
return "model-a"
|
||||
|
||||
def capabilities(self) -> Dict[str, Any]:
|
||||
return {"modalities": ["text", "image"]}
|
||||
|
||||
def generate(self, prompt, **kwargs):
|
||||
self.last_kwargs = {"prompt": prompt, **kwargs}
|
||||
modality = "image" if kwargs.get("image_url") else "text"
|
||||
@@ -116,25 +113,14 @@ class TestUnifiedDispatch:
|
||||
assert "error" in result
|
||||
assert "prompt" in result["error"].lower()
|
||||
|
||||
def test_edit_extend_args_are_rejected_by_generate_tool(self):
|
||||
provider = _RecordingProvider("rec")
|
||||
video_gen_registry.register_provider(provider)
|
||||
result = self._run({
|
||||
"prompt": "make it rain",
|
||||
"operation": "edit",
|
||||
"video_url": "https://example.com/in.mp4",
|
||||
})
|
||||
assert "error" in result
|
||||
assert "provider-specific tool" in result["error"]
|
||||
|
||||
def test_provider_exception_caught(self):
|
||||
video_gen_registry.register_provider(_RaisingProvider())
|
||||
result = self._run({"prompt": "x"})
|
||||
assert result["success"] is False
|
||||
assert result["error_type"] == "provider_exception"
|
||||
|
||||
def test_edit_extend_fields_not_in_schema(self):
|
||||
def test_operation_field_not_in_schema(self):
|
||||
"""Make sure we removed the operation field from the schema."""
|
||||
from tools.video_generation_tool import VIDEO_GENERATE_SCHEMA
|
||||
props = VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
|
||||
assert "operation" not in props
|
||||
assert "video_url" not in props
|
||||
assert "operation" not in VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
|
||||
assert "video_url" not in VIDEO_GENERATE_SCHEMA["parameters"]["properties"]
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the dynamic schema builder."""
|
||||
"""Tests for the dynamic schema builder under the simplified surface."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -91,13 +91,20 @@ class TestDynamicSchemaBuilder:
|
||||
assert "No video backend is configured" in desc
|
||||
assert "hermes tools" in desc
|
||||
|
||||
def test_generic_description_keeps_edit_extend_out_of_surface(self, cfg_home):
|
||||
def test_does_not_mention_edit_or_extend(self, cfg_home):
|
||||
"""The simplified surface only does text→video and image→video.
|
||||
The description must not mention edit/extend anywhere."""
|
||||
from tools.video_generation_tool import _build_dynamic_video_schema, _GENERIC_DESCRIPTION
|
||||
|
||||
desc = _build_dynamic_video_schema()["description"]
|
||||
assert "Video edit/extend workflows are not part of this unified surface" in desc
|
||||
assert "operation='edit'" not in _GENERIC_DESCRIPTION
|
||||
assert "operation='extend'" not in _GENERIC_DESCRIPTION
|
||||
# Block words that would suggest functionality we removed
|
||||
assert "edit" not in desc.lower() or "audio" in desc.lower() # 'audio' contains 'audi' not 'edit'
|
||||
# Stronger: no occurrence of the words "edit" or "extend" as standalone
|
||||
for forbidden in (" edit ", " edits ", " extend ", " extends "):
|
||||
assert forbidden not in desc.lower(), f"description leaks '{forbidden.strip()}'"
|
||||
# Sanity: the generic blurb itself is also clean
|
||||
for forbidden in ("edit", "extend"):
|
||||
assert forbidden not in _GENERIC_DESCRIPTION.lower()
|
||||
|
||||
def test_both_modalities_advertises_auto_routing(self, cfg_home):
|
||||
from tools.video_generation_tool import _build_dynamic_video_schema
|
||||
@@ -116,6 +123,7 @@ class TestDynamicSchemaBuilder:
|
||||
assert "Active backend: Both" in desc
|
||||
assert "text-to-video" in desc and "image-to-video" in desc
|
||||
assert "routes automatically" in desc
|
||||
# operations bullet is gone
|
||||
assert "operations supported" not in desc
|
||||
|
||||
def test_image_only_model_warns_about_required_image_url(self, cfg_home):
|
||||
|
||||
@@ -79,21 +79,10 @@ def matrix_env(tmp_path, monkeypatch):
|
||||
xai_calls.append({"url": url, "json": json})
|
||||
return _Resp({"request_id": "req-1"})
|
||||
async def get(self, url, headers=None, timeout=None):
|
||||
payload = xai_calls[-1]["json"]
|
||||
storage_options = payload.get("storage_options") or {}
|
||||
return _Resp({
|
||||
"status": "done",
|
||||
"video": {
|
||||
"url": "https://xai-cdn/out.mp4",
|
||||
"duration": 8,
|
||||
"file_output": {
|
||||
"file_id": "file-123",
|
||||
"filename": storage_options.get("filename", "out.mp4"),
|
||||
"public_url": "https://xai-files.example/out.mp4",
|
||||
"public_url_expires_at": 1234567890,
|
||||
},
|
||||
},
|
||||
"model": payload.get("model", "grok-imagine-video"),
|
||||
"video": {"url": "https://xai-cdn/out.mp4", "duration": 8},
|
||||
"model": xai_calls[-1]["json"].get("model", "grok-imagine-video"),
|
||||
})
|
||||
import plugins.video_gen.xai as xai_plugin
|
||||
monkeypatch.setattr(xai_plugin.httpx, "AsyncClient", lambda: _Client())
|
||||
@@ -111,7 +100,7 @@ def matrix_env(tmp_path, monkeypatch):
|
||||
return tmp_path, fal_calls, xai_calls
|
||||
|
||||
|
||||
def _invoke_tool(home, cfg: dict, args: dict, tool_name: str = "video_generate") -> dict:
|
||||
def _invoke_tool(home, cfg: dict, args: dict) -> dict:
|
||||
"""Write config, invoke the registered tool handler, return parsed JSON."""
|
||||
(home / "config.yaml").write_text(yaml.safe_dump(cfg))
|
||||
import hermes_cli.config as cfg_mod
|
||||
@@ -119,9 +108,9 @@ def _invoke_tool(home, cfg: dict, args: dict, tool_name: str = "video_generate")
|
||||
cfg_mod._invalidate_load_config_cache()
|
||||
|
||||
from tools.registry import discover_builtin_tools, registry
|
||||
if tool_name not in registry._tools:
|
||||
if "video_generate" not in registry._tools:
|
||||
discover_builtin_tools()
|
||||
handler = registry._tools[tool_name].handler
|
||||
handler = registry._tools["video_generate"].handler
|
||||
return json.loads(handler(args))
|
||||
|
||||
|
||||
@@ -216,11 +205,6 @@ def test_xai_text_only_via_tool_surface(matrix_env):
|
||||
assert payload["model"] == "grok-imagine-video"
|
||||
assert "image" not in payload
|
||||
assert "reference_images" not in payload
|
||||
assert payload["storage_options"]["public_url"] is True
|
||||
assert "expires_after" not in payload["storage_options"]
|
||||
assert result["video"] == "https://xai-files.example/out.mp4"
|
||||
assert result["public_url"] == "https://xai-files.example/out.mp4"
|
||||
assert result.get("temporary_url") == "https://xai-cdn/out.mp4"
|
||||
|
||||
|
||||
def test_xai_text_plus_image_via_tool_surface(matrix_env):
|
||||
@@ -238,157 +222,10 @@ def test_xai_text_plus_image_via_tool_surface(matrix_env):
|
||||
assert len(xai_calls) == 1
|
||||
assert xai_calls[0]["url"].endswith("/videos/generations")
|
||||
payload = xai_calls[0]["json"] or {}
|
||||
assert payload["model"] == "grok-imagine-video-1.5"
|
||||
assert payload["model"] == "grok-imagine-video-1.5-preview"
|
||||
assert payload["image"] == {"url": "https://example.com/img.png"}
|
||||
|
||||
|
||||
def test_xai_image_to_video_rejects_bare_file_id_via_tool_surface(matrix_env):
|
||||
home, _, xai_calls = matrix_env
|
||||
|
||||
result = _invoke_tool(
|
||||
home,
|
||||
{"video_gen": {"provider": "xai"}},
|
||||
{
|
||||
"prompt": "animate this robot waving",
|
||||
"image_url": "file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
|
||||
},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result.get("error_type") == "invalid_image_url"
|
||||
assert len(xai_calls) == 0
|
||||
|
||||
|
||||
def test_xai_reference_to_video_via_tool_surface(matrix_env):
|
||||
home, _, xai_calls = matrix_env
|
||||
|
||||
result = _invoke_tool(
|
||||
home,
|
||||
{"video_gen": {"provider": "xai"}},
|
||||
{
|
||||
"prompt": "put the jacket from the reference on the runway model",
|
||||
"reference_image_urls": [
|
||||
"https://example.com/model.png",
|
||||
"https://example.com/jacket.png",
|
||||
],
|
||||
"duration": 15,
|
||||
},
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["modality"] == "reference"
|
||||
assert result["provider"] == "xai"
|
||||
|
||||
payload = xai_calls[0]["json"] or {}
|
||||
assert xai_calls[0]["url"].endswith("/videos/generations")
|
||||
assert payload["model"] == "grok-imagine-video"
|
||||
assert payload["duration"] == 10
|
||||
assert payload["reference_images"] == [
|
||||
{"url": "https://example.com/model.png"},
|
||||
{"url": "https://example.com/jacket.png"},
|
||||
]
|
||||
|
||||
|
||||
def test_xai_reference_to_video_rejects_bare_file_ids_via_tool_surface(matrix_env):
|
||||
home, _, xai_calls = matrix_env
|
||||
|
||||
result = _invoke_tool(
|
||||
home,
|
||||
{"video_gen": {"provider": "xai"}},
|
||||
{
|
||||
"prompt": "use these references for a robot product shot",
|
||||
"reference_image_urls": [
|
||||
"file_03eb65b1-aa97-482f-9ef0-b04f9172ea00",
|
||||
"file_54b48d6d-28ad-4982-9d72-bd3ac677c9bc",
|
||||
],
|
||||
},
|
||||
)
|
||||
assert result["success"] is False
|
||||
assert result.get("error_type") == "invalid_reference_image_urls"
|
||||
assert len(xai_calls) == 0
|
||||
|
||||
|
||||
def test_xai_video_edit_via_tool_surface(matrix_env):
|
||||
home, _, xai_calls = matrix_env
|
||||
|
||||
result = _invoke_tool(
|
||||
home,
|
||||
{"video_gen": {"provider": "xai"}},
|
||||
{
|
||||
"prompt": "make the sky stormy",
|
||||
"video_url": "https://example.com/source.mp4",
|
||||
},
|
||||
tool_name="xai_video_edit",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["modality"] == "edit"
|
||||
|
||||
payload = xai_calls[0]["json"] or {}
|
||||
assert xai_calls[0]["url"].endswith("/videos/edits")
|
||||
assert payload["model"] == "grok-imagine-video"
|
||||
assert payload["video"] == {"url": "https://example.com/source.mp4"}
|
||||
assert "duration" not in payload
|
||||
assert "aspect_ratio" not in payload
|
||||
assert "resolution" not in payload
|
||||
|
||||
|
||||
def test_xai_video_extend_via_tool_surface(matrix_env):
|
||||
home, _, xai_calls = matrix_env
|
||||
|
||||
result = _invoke_tool(
|
||||
home,
|
||||
{"video_gen": {"provider": "xai"}},
|
||||
{
|
||||
"prompt": "the camera pulls back to reveal the city",
|
||||
"video_url": "https://example.com/source.mp4",
|
||||
"duration": 15,
|
||||
},
|
||||
tool_name="xai_video_extend",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["modality"] == "extend"
|
||||
|
||||
payload = xai_calls[0]["json"] or {}
|
||||
assert xai_calls[0]["url"].endswith("/videos/extensions")
|
||||
assert payload["model"] == "grok-imagine-video"
|
||||
assert payload["video"] == {"url": "https://example.com/source.mp4"}
|
||||
assert payload["duration"] == 10
|
||||
|
||||
|
||||
def test_xai_video_edit_rejects_bare_file_id_via_tool_surface(matrix_env):
|
||||
home, _, xai_calls = matrix_env
|
||||
|
||||
result = _invoke_tool(
|
||||
home,
|
||||
{"video_gen": {"provider": "xai"}},
|
||||
{
|
||||
"prompt": "make the sky stormy",
|
||||
"video_url": "file-123",
|
||||
},
|
||||
tool_name="xai_video_edit",
|
||||
)
|
||||
assert result.get("success") is not True
|
||||
assert "error" in result
|
||||
assert "url" in result["error"].lower()
|
||||
assert len(xai_calls) == 0
|
||||
|
||||
|
||||
def test_xai_video_extend_rejects_bare_file_id_via_tool_surface(matrix_env):
|
||||
home, _, xai_calls = matrix_env
|
||||
|
||||
result = _invoke_tool(
|
||||
home,
|
||||
{"video_gen": {"provider": "xai"}},
|
||||
{
|
||||
"prompt": "continue into a sunrise",
|
||||
"video_url": "file_25ac1c31-d6d8-48b2-8504-a97d282310c4",
|
||||
},
|
||||
tool_name="xai_video_extend",
|
||||
)
|
||||
assert result.get("success") is not True
|
||||
assert "error" in result
|
||||
assert "url" in result["error"].lower()
|
||||
assert len(xai_calls) == 0
|
||||
|
||||
|
||||
def test_xai_explicit_model_override_via_tool_surface(matrix_env):
|
||||
home, _, xai_calls = matrix_env
|
||||
|
||||
|
||||
@@ -1,132 +0,0 @@
|
||||
"""Tests for xAI Imagine storage helper behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
def _invalidate_config_cache():
|
||||
try:
|
||||
import hermes_cli.config as cfg_mod
|
||||
|
||||
if hasattr(cfg_mod, "_invalidate_load_config_cache"):
|
||||
cfg_mod._invalidate_load_config_cache()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def test_storage_defaults_to_permanent_public_urls(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
_invalidate_config_cache()
|
||||
|
||||
from tools.xai_http import build_xai_storage_options
|
||||
|
||||
storage = build_xai_storage_options(
|
||||
"image_gen",
|
||||
filename_prefix="hermes-xai-image",
|
||||
extension="png",
|
||||
)
|
||||
|
||||
assert storage is not None
|
||||
assert storage["public_url"] is True
|
||||
assert "expires_after" not in storage
|
||||
assert storage["filename"].startswith("hermes-xai-image-")
|
||||
assert storage["filename"].endswith(".png")
|
||||
|
||||
|
||||
def test_storage_can_be_disabled(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"video_gen": {
|
||||
"xai": {
|
||||
"storage": {
|
||||
"enabled": False,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
_invalidate_config_cache()
|
||||
|
||||
from tools.xai_http import build_xai_storage_options, xai_storage_notice_text
|
||||
|
||||
assert build_xai_storage_options(
|
||||
"video_gen",
|
||||
filename_prefix="hermes-xai-video",
|
||||
extension="mp4",
|
||||
) is None
|
||||
assert xai_storage_notice_text("video_gen") == ""
|
||||
|
||||
|
||||
def test_storage_can_be_permanent(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"image_gen": {
|
||||
"xai": {
|
||||
"storage": {
|
||||
"expires_after": "permanent",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
_invalidate_config_cache()
|
||||
|
||||
from tools.xai_http import build_xai_storage_options
|
||||
|
||||
storage = build_xai_storage_options(
|
||||
"image_gen",
|
||||
filename_prefix="hermes-xai-image",
|
||||
extension="png",
|
||||
)
|
||||
|
||||
assert storage is not None
|
||||
assert "expires_after" not in storage
|
||||
|
||||
|
||||
def test_storage_can_use_finite_retention(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"image_gen": {
|
||||
"xai": {
|
||||
"storage": {
|
||||
"expires_after": 172800,
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
_invalidate_config_cache()
|
||||
|
||||
from tools.xai_http import build_xai_storage_options
|
||||
|
||||
storage = build_xai_storage_options(
|
||||
"image_gen",
|
||||
filename_prefix="hermes-xai-image",
|
||||
extension="png",
|
||||
)
|
||||
|
||||
assert storage is not None
|
||||
assert storage["expires_after"] == 172800
|
||||
|
||||
|
||||
def test_invalid_storage_retention_falls_back_to_bounded_ttl(tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
||||
(tmp_path / "config.yaml").write_text(yaml.safe_dump({
|
||||
"video_gen": {
|
||||
"xai": {
|
||||
"storage": {
|
||||
"expires_after": "definitely-not-a-duration",
|
||||
},
|
||||
},
|
||||
},
|
||||
}))
|
||||
_invalidate_config_cache()
|
||||
|
||||
from tools.xai_http import build_xai_storage_options
|
||||
|
||||
storage = build_xai_storage_options(
|
||||
"video_gen",
|
||||
filename_prefix="hermes-xai-video",
|
||||
extension="mp4",
|
||||
)
|
||||
|
||||
assert storage is not None
|
||||
assert storage["expires_after"] == 172800
|
||||
@@ -34,7 +34,6 @@ import os
|
||||
from typing import Any, Optional
|
||||
|
||||
from agent.redact import redact_sensitive_text
|
||||
from hermes_cli.goals import judge_goal
|
||||
from tools.registry import registry, tool_error
|
||||
from hermes_cli.config import cfg_get, load_config
|
||||
|
||||
@@ -179,27 +178,6 @@ def _connect(board: Optional[str] = None):
|
||||
return kb, kb.connect(board=board)
|
||||
|
||||
|
||||
def _goal_judge_available() -> bool:
|
||||
"""True when an auxiliary client is configured for the goal judge.
|
||||
|
||||
``judge_goal`` is fail-open at the source: when no auxiliary model can
|
||||
be reached it returns a ``"continue"`` verdict that is indistinguishable
|
||||
from a real "not done yet" judgment. The completion gate must not treat
|
||||
that as a rejection, or an unconfigured/degraded auxiliary model would
|
||||
wedge every ``goal_mode`` worker (it could never close its own task).
|
||||
|
||||
So we probe availability first and only enforce the gate when a judge is
|
||||
actually reachable. This mirrors the same client lookup ``judge_goal``
|
||||
performs internally.
|
||||
"""
|
||||
try:
|
||||
from agent.auxiliary_client import get_text_auxiliary_client
|
||||
client, model = get_text_auxiliary_client("goal_judge")
|
||||
except Exception:
|
||||
return False
|
||||
return client is not None and bool(model)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Runtime-activity → board-heartbeat bridge (#31752)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -589,37 +567,6 @@ def _handle_complete(args: dict, **kw) -> str:
|
||||
try:
|
||||
kb, conn = _connect(board=board)
|
||||
try:
|
||||
# Goal-mode pre-completion judge gate (Issue #38367).
|
||||
# Prevent workers from bypassing the auxiliary judge by
|
||||
# calling kanban_complete before acceptance criteria are met.
|
||||
# Only enforce when a judge is actually reachable — see
|
||||
# _goal_judge_available for why an unavailable judge fails open.
|
||||
task = kb.get_task(conn, tid)
|
||||
if task and task.goal_mode and _goal_judge_available():
|
||||
verdict = "done"
|
||||
reason = ""
|
||||
try:
|
||||
verdict, reason, _ = judge_goal(
|
||||
goal=f"{task.title}\n\n{task.body or ''}".strip(),
|
||||
last_response=(summary or result or "").strip(),
|
||||
)
|
||||
except Exception as judge_exc:
|
||||
# Defensive: judge_goal swallows its own errors, but if
|
||||
# it ever raises, fail open rather than wedge the worker.
|
||||
logger.warning(
|
||||
"goal judge check failed, allowing completion: %s",
|
||||
judge_exc,
|
||||
exc_info=True,
|
||||
)
|
||||
if verdict != "done":
|
||||
return tool_error(
|
||||
f"Goal completion rejected by judge: {reason}. "
|
||||
f"To proceed, either: (1) provide explicit acceptance "
|
||||
f"evidence in your summary matching the task's criteria, "
|
||||
f"or (2) create continuation tasks with parents=[{tid}] "
|
||||
f"and keep this task alive."
|
||||
)
|
||||
|
||||
try:
|
||||
ok = kb.complete_task(
|
||||
conn, tid,
|
||||
|
||||
@@ -18,11 +18,13 @@ Generation.
|
||||
|
||||
Unified surface
|
||||
---------------
|
||||
One tool covers the common cases - text-to-video, image-to-video, and
|
||||
reference-to-video - with a compact schema:
|
||||
One tool covers the common cases — text-to-video, image-to-video, video
|
||||
edit, video extend — with a compact schema:
|
||||
|
||||
prompt text instruction (required)
|
||||
image_url drives image-to-video
|
||||
prompt text instruction (required for generate/edit)
|
||||
operation "generate" | "edit" | "extend"
|
||||
image_url drives image-to-video when operation=generate
|
||||
video_url source video for edit/extend
|
||||
reference_image_urls list, up to provider-declared cap
|
||||
duration seconds (provider clamps)
|
||||
aspect_ratio "16:9" | "9:16" | "1:1" | ...
|
||||
@@ -36,9 +38,6 @@ Providers ignore parameters they do not support. The tool layer does
|
||||
**lightweight** validation (type/required-prompt) and lets each provider
|
||||
do its own clamping inside :meth:`VideoGenProvider.generate` — that keeps
|
||||
the tool surface stable as new providers ship with different capabilities.
|
||||
|
||||
Video edit and video extend are intentionally not exposed here; providers with
|
||||
those workflows should expose separate tools.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -81,20 +80,21 @@ VIDEO_GENERATE_SCHEMA: Dict[str, Any] = {
|
||||
"image_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Optional public HTTPS URL of a still image. When provided, "
|
||||
"Optional public URL of a still image. When provided, "
|
||||
"the active backend routes to its image-to-video "
|
||||
"endpoint (animate the image); when omitted, it routes "
|
||||
"to text-to-video. For xAI chaining, use the `image` or "
|
||||
"`public_url` HTTPS URL from a prior Imagine result."
|
||||
"to text-to-video. Pass either a URL the user supplied "
|
||||
"or a path/URL from the conversation."
|
||||
),
|
||||
},
|
||||
"reference_image_urls": {
|
||||
"type": "array",
|
||||
"items": {"type": "string"},
|
||||
"description": (
|
||||
"Optional list of public HTTPS reference image URLs "
|
||||
"(style or character refs). For xAI chaining, use "
|
||||
"`image` or `public_url` from prior Imagine results."
|
||||
"Optional list of reference image URLs (style or "
|
||||
"character refs). Only supported by some backends; "
|
||||
"the active backend's description below indicates whether "
|
||||
"this is honored and what the max is."
|
||||
),
|
||||
},
|
||||
"duration": {
|
||||
@@ -324,11 +324,6 @@ def _handle_video_generate(args: Dict[str, Any], **_kw: Any) -> str:
|
||||
# endpoint but our surface always needs a prompt.
|
||||
if not prompt:
|
||||
return tool_error("prompt is required for video generation")
|
||||
if "operation" in args or "video_url" in args:
|
||||
return tool_error(
|
||||
"video_generate only supports text-to-video, image-to-video, and "
|
||||
"reference-to-video; use a provider-specific tool for video edit/extend"
|
||||
)
|
||||
|
||||
# Resolve the active provider.
|
||||
configured = _read_configured_video_provider()
|
||||
@@ -403,13 +398,13 @@ def _handle_video_generate(args: Dict[str, Any], **_kw: Any) -> str:
|
||||
# Dynamic schema — reflect the active backend's actual capabilities
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# Why dynamic: the user's configured backend determines which modalities
|
||||
# (text / image / refs), aspect ratios, resolutions, durations, and
|
||||
# audio/negative-prompt flags are real. A model that calls video_generate
|
||||
# without knowing the active backend wastes a turn on something like
|
||||
# "fal-ai/veo3.1/image-to-video requires image_url". Surfacing the per-model
|
||||
# surface in the description means the model usually gets the call right on
|
||||
# the first try.
|
||||
# Why dynamic: the user's configured backend determines which operations
|
||||
# (generate/edit/extend), modalities (text / image / refs), aspect ratios,
|
||||
# resolutions, durations, and audio/negative-prompt flags are real. A model
|
||||
# that calls video_generate without knowing the active backend wastes a
|
||||
# turn on something like "fal-ai/veo3.1/image-to-video requires image_url".
|
||||
# Surfacing the per-model surface in the description means the model
|
||||
# usually gets the call right on the first try.
|
||||
#
|
||||
# Memoization: model_tools.get_tool_definitions() keys its cache on
|
||||
# config.yaml mtime, so when the user changes provider/model via
|
||||
@@ -417,12 +412,11 @@ def _handle_video_generate(args: Dict[str, Any], **_kw: Any) -> str:
|
||||
|
||||
|
||||
_GENERIC_DESCRIPTION = (
|
||||
"Generate a video from a text prompt (text-to-video), animate a "
|
||||
"still image (image-to-video), or guide generation with reference images. "
|
||||
"Pass `image_url` to animate an image or `reference_image_urls` for "
|
||||
"reference-to-video. Video edit/extend workflows are not part of this "
|
||||
"unified surface; use a dedicated provider-specific tool when one is "
|
||||
"available. The backend and model family are user-configured via "
|
||||
"Generate a video from a text prompt (text-to-video) or animate a "
|
||||
"still image (image-to-video) using the user's configured video "
|
||||
"generation backend. Pass `image_url` to animate that image; omit it "
|
||||
"to generate from text alone. The backend auto-routes to the right "
|
||||
"endpoint. The backend and model family are user-configured via "
|
||||
"`hermes tools` → Video Generation; the agent does not pick them. "
|
||||
"Long-running generations may take 30 seconds to several minutes — "
|
||||
"the call blocks until the video is ready. Returns the result in the "
|
||||
@@ -548,21 +542,6 @@ def _build_dynamic_video_schema() -> Dict[str, Any]:
|
||||
max_refs = caps.get("max_reference_images") or 0
|
||||
if max_refs:
|
||||
parts.append(f"- reference_image_urls: up to {max_refs} images")
|
||||
if configured == "xai":
|
||||
parts.append(
|
||||
"- chaining: for edit/extend pass the public HTTPS MP4 in `video` "
|
||||
"or `public_url` from the prior Imagine result (files-cdn). For "
|
||||
"image-to-video / reference-to-video pass public image URLs the "
|
||||
"same way"
|
||||
)
|
||||
try:
|
||||
from tools.xai_http import xai_storage_notice_text
|
||||
|
||||
notice = xai_storage_notice_text("video_gen")
|
||||
except Exception:
|
||||
notice = ""
|
||||
if notice:
|
||||
parts.append(f"- storage: {notice}")
|
||||
|
||||
return {"description": "\n".join(parts)}
|
||||
|
||||
|
||||
+16
-165
@@ -2,15 +2,9 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
import json
|
||||
import os
|
||||
import uuid
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
|
||||
MAX_XAI_STORAGE_EXPIRES_AFTER_SECONDS = 30 * 24 * 60 * 60
|
||||
SAFE_XAI_STORAGE_EXPIRES_AFTER_SECONDS = 2 * 24 * 60 * 60
|
||||
from typing import Dict
|
||||
|
||||
|
||||
def has_xai_credentials() -> bool:
|
||||
@@ -78,149 +72,6 @@ def hermes_xai_user_agent() -> str:
|
||||
return f"Hermes-Agent/{__version__}"
|
||||
|
||||
|
||||
def _load_config_section(section_name: str) -> Dict[str, Any]:
|
||||
"""Return a top-level Hermes config section as a dict, or empty."""
|
||||
try:
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
cfg = load_config()
|
||||
section = cfg.get(section_name) if isinstance(cfg, dict) else None
|
||||
return section if isinstance(section, dict) else {}
|
||||
except Exception:
|
||||
return {}
|
||||
|
||||
|
||||
def _coerce_bool(value: Any, default: bool) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on", "enabled"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off", "disabled"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _coerce_expires_after(value: Any) -> Optional[int]:
|
||||
"""Normalize an xAI storage TTL.
|
||||
|
||||
Returns:
|
||||
int seconds for an expiring file,
|
||||
None for permanent storage (omit expires_after on the wire).
|
||||
"""
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"", "default"}:
|
||||
return None
|
||||
if normalized in {"none", "null", "never", "permanent", "forever", "0"}:
|
||||
return None
|
||||
try:
|
||||
value = int(normalized)
|
||||
except ValueError:
|
||||
return SAFE_XAI_STORAGE_EXPIRES_AFTER_SECONDS
|
||||
if isinstance(value, (int, float)):
|
||||
seconds = int(value)
|
||||
if seconds <= 0:
|
||||
return None
|
||||
return min(seconds, MAX_XAI_STORAGE_EXPIRES_AFTER_SECONDS)
|
||||
return SAFE_XAI_STORAGE_EXPIRES_AFTER_SECONDS
|
||||
|
||||
|
||||
def read_xai_imagine_storage_config(section_name: str) -> Dict[str, Any]:
|
||||
"""Read storage settings for xAI Imagine under image_gen/video_gen config.
|
||||
|
||||
Supported config shape:
|
||||
|
||||
image_gen:
|
||||
xai:
|
||||
storage:
|
||||
enabled: true
|
||||
public_url: true
|
||||
expires_after: null # omit for permanent public URLs
|
||||
|
||||
The same shape is accepted under ``video_gen.xai.storage``. Storage is on
|
||||
by default so xAI returns permanent public URLs instead of short-lived CDN URLs.
|
||||
"""
|
||||
section = _load_config_section(section_name)
|
||||
xai_section = section.get("xai") if isinstance(section, dict) else None
|
||||
storage = xai_section.get("storage") if isinstance(xai_section, dict) else None
|
||||
storage = storage if isinstance(storage, dict) else {}
|
||||
|
||||
enabled = _coerce_bool(storage.get("enabled"), True)
|
||||
public_url = _coerce_bool(storage.get("public_url"), True)
|
||||
expires_after = _coerce_expires_after(storage.get("expires_after"))
|
||||
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"public_url": public_url,
|
||||
"expires_after": expires_after,
|
||||
}
|
||||
|
||||
|
||||
def build_xai_storage_options(
|
||||
section_name: str,
|
||||
*,
|
||||
filename_prefix: str,
|
||||
extension: str,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Return an xAI ``storage_options`` payload, or None when disabled."""
|
||||
cfg = read_xai_imagine_storage_config(section_name)
|
||||
if not cfg["enabled"]:
|
||||
return None
|
||||
|
||||
now = datetime.datetime.now(datetime.UTC)
|
||||
ts = now.strftime("%Y%m%d-%H%M%S")
|
||||
short = uuid.uuid4().hex[:8]
|
||||
ext = extension.lstrip(".") or "bin"
|
||||
payload: Dict[str, Any] = {
|
||||
"filename": f"{filename_prefix}-{ts}-{short}.{ext}",
|
||||
"public_url": bool(cfg["public_url"]),
|
||||
}
|
||||
if cfg["expires_after"] is not None:
|
||||
payload["expires_after"] = cfg["expires_after"]
|
||||
return payload
|
||||
|
||||
|
||||
def xai_storage_notice_text(section_name: str) -> str:
|
||||
"""User-facing notice for first xAI Imagine storage use."""
|
||||
cfg = read_xai_imagine_storage_config(section_name)
|
||||
if not cfg["enabled"]:
|
||||
return ""
|
||||
if cfg["expires_after"] is None:
|
||||
retention = "without an automatic expiry"
|
||||
else:
|
||||
days = cfg["expires_after"] / (24 * 60 * 60)
|
||||
retention = f"for about {days:g} day{'s' if days != 1 else ''}"
|
||||
return (
|
||||
"xAI Imagine storage is enabled so generated media gets a reusable "
|
||||
f"public URL {retention}. xAI may bill for stored files and public URL "
|
||||
f"hosting. Disable this with `{section_name}.xai.storage.enabled: false` "
|
||||
"or set `expires_after` to change the retention."
|
||||
)
|
||||
|
||||
|
||||
def maybe_mark_xai_storage_notice_seen(section_name: str) -> Optional[str]:
|
||||
"""Return the storage notice once per Hermes home, then mark it seen."""
|
||||
notice = xai_storage_notice_text(section_name)
|
||||
if not notice:
|
||||
return None
|
||||
try:
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
marker_dir = get_hermes_home() / "state"
|
||||
marker_dir.mkdir(parents=True, exist_ok=True)
|
||||
marker = marker_dir / f"{section_name}_xai_storage_notice_seen"
|
||||
if marker.exists():
|
||||
return None
|
||||
marker.write_text(datetime.datetime.now(datetime.UTC).isoformat() + "\n")
|
||||
return notice
|
||||
except Exception:
|
||||
return notice
|
||||
|
||||
|
||||
def resolve_xai_http_credentials(*, force_refresh: bool = False) -> Dict[str, str]:
|
||||
"""Resolve bearer credentials for direct xAI HTTP endpoints.
|
||||
|
||||
@@ -237,21 +88,6 @@ def resolve_xai_http_credentials(*, force_refresh: bool = False) -> Dict[str, st
|
||||
tokens where the proactive JWT check is a no-op, etc.), not as a default —
|
||||
the auth-store lock is held for the duration of the refresh.
|
||||
"""
|
||||
try:
|
||||
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
|
||||
|
||||
creds = resolve_xai_oauth_runtime_credentials(force_refresh=force_refresh)
|
||||
access_token = str(creds.get("api_key") or "").strip()
|
||||
base_url = str(creds.get("base_url") or "").strip().rstrip("/")
|
||||
if access_token:
|
||||
return {
|
||||
"provider": "xai-oauth",
|
||||
"api_key": access_token,
|
||||
"base_url": base_url or "https://api.x.ai/v1",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
if not force_refresh:
|
||||
try:
|
||||
from hermes_cli.runtime_provider import resolve_runtime_provider
|
||||
@@ -268,6 +104,21 @@ def resolve_xai_http_credentials(*, force_refresh: bool = False) -> Dict[str, st
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
try:
|
||||
from hermes_cli.auth import resolve_xai_oauth_runtime_credentials
|
||||
|
||||
creds = resolve_xai_oauth_runtime_credentials(force_refresh=force_refresh)
|
||||
access_token = str(creds.get("api_key") or "").strip()
|
||||
base_url = str(creds.get("base_url") or "").strip().rstrip("/")
|
||||
if access_token:
|
||||
return {
|
||||
"provider": "xai-oauth",
|
||||
"api_key": access_token,
|
||||
"base_url": base_url or "https://api.x.ai/v1",
|
||||
}
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
api_key = str(get_env_value("XAI_API_KEY") or "").strip()
|
||||
base_url = str(get_env_value("XAI_BASE_URL") or "https://api.x.ai/v1").strip().rstrip("/")
|
||||
return {
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""xAI-specific Imagine video edit and extend tools."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
from plugins.video_gen.xai import (
|
||||
has_xai_video_credentials,
|
||||
run_xai_video_edit,
|
||||
run_xai_video_extend,
|
||||
)
|
||||
from tools.registry import registry, tool_error
|
||||
|
||||
|
||||
def _configured_for_xai_video() -> bool:
|
||||
try:
|
||||
cfg = load_config()
|
||||
except Exception:
|
||||
return False
|
||||
section = cfg.get("video_gen") if isinstance(cfg, dict) else None
|
||||
return isinstance(section, dict) and section.get("provider") == "xai"
|
||||
|
||||
|
||||
def _check_xai_video_requirements() -> bool:
|
||||
return _configured_for_xai_video() and has_xai_video_credentials()
|
||||
|
||||
|
||||
def _clean_string(value: Any) -> Optional[str]:
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def _coerce_int(value: Any) -> Optional[int]:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, bool):
|
||||
return None
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _provider_not_configured_error() -> str:
|
||||
return json.dumps({
|
||||
"success": False,
|
||||
"error": (
|
||||
"xAI video edit/extend tools require `video_gen.provider` to be "
|
||||
"configured as `xai` via `hermes tools` -> Video Generation."
|
||||
),
|
||||
"error_type": "provider_not_configured",
|
||||
"provider": "xai",
|
||||
})
|
||||
|
||||
|
||||
def _normalize_public_video_url(video_url: Any) -> Optional[str]:
|
||||
"""Require a public HTTPS MP4 URL (``http``/``https`` only)."""
|
||||
cleaned = _clean_string(video_url)
|
||||
if not cleaned:
|
||||
return None
|
||||
if cleaned.lower().startswith(("http://", "https://")):
|
||||
return cleaned
|
||||
return None
|
||||
|
||||
|
||||
XAI_VIDEO_EDIT_SCHEMA: Dict[str, Any] = {
|
||||
"name": "xai_video_edit",
|
||||
"description": (
|
||||
"Edit an existing video with xAI Imagine. This is separate from "
|
||||
"`video_generate` because video editing is provider-specific. "
|
||||
"`video_url` must be the public HTTPS MP4 URL from a prior Imagine "
|
||||
"result (`video` or `public_url` on files-cdn)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Instruction for how xAI should modify the source video.",
|
||||
},
|
||||
"video_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Public HTTPS MP4 URL of the source video — the `video` or "
|
||||
"`public_url` from a prior xAI Imagine result."
|
||||
),
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional xAI Imagine model override.",
|
||||
},
|
||||
},
|
||||
"required": ["prompt", "video_url"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
XAI_VIDEO_EXTEND_SCHEMA: Dict[str, Any] = {
|
||||
"name": "xai_video_extend",
|
||||
"description": (
|
||||
"Extend an existing video with xAI Imagine. This is separate from "
|
||||
"`video_generate` because video extension is provider-specific. "
|
||||
"`video_url` must be the public HTTPS MP4 URL from a prior Imagine "
|
||||
"result (`video` or `public_url` on files-cdn)."
|
||||
),
|
||||
"parameters": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"prompt": {
|
||||
"type": "string",
|
||||
"description": "Instruction for how xAI should continue the source video.",
|
||||
},
|
||||
"video_url": {
|
||||
"type": "string",
|
||||
"description": (
|
||||
"Public HTTPS MP4 URL of the source video — the `video` or "
|
||||
"`public_url` from a prior xAI Imagine result."
|
||||
),
|
||||
},
|
||||
"duration": {
|
||||
"type": "integer",
|
||||
"description": (
|
||||
"Desired extension duration in seconds. xAI clamps this "
|
||||
"to its supported range."
|
||||
),
|
||||
},
|
||||
"model": {
|
||||
"type": "string",
|
||||
"description": "Optional xAI Imagine model override.",
|
||||
},
|
||||
},
|
||||
"required": ["prompt", "video_url"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def _handle_xai_video_edit(args: Dict[str, Any], **_kw: Any) -> str:
|
||||
prompt = _clean_string(args.get("prompt"))
|
||||
video_url = _normalize_public_video_url(args.get("video_url"))
|
||||
model = _clean_string(args.get("model"))
|
||||
|
||||
if not prompt:
|
||||
return tool_error("prompt is required for xAI video edit")
|
||||
if not video_url:
|
||||
return tool_error(
|
||||
"video_url must be a public HTTPS MP4 URL (the `video`/`public_url` "
|
||||
"from a prior Imagine result)"
|
||||
)
|
||||
if not _configured_for_xai_video():
|
||||
return _provider_not_configured_error()
|
||||
|
||||
result = run_xai_video_edit(
|
||||
prompt=prompt,
|
||||
video_url=video_url,
|
||||
model=model,
|
||||
)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
def _handle_xai_video_extend(args: Dict[str, Any], **_kw: Any) -> str:
|
||||
prompt = _clean_string(args.get("prompt"))
|
||||
video_url = _normalize_public_video_url(args.get("video_url"))
|
||||
model = _clean_string(args.get("model"))
|
||||
duration = _coerce_int(args.get("duration"))
|
||||
|
||||
if not prompt:
|
||||
return tool_error("prompt is required for xAI video extend")
|
||||
if not video_url:
|
||||
return tool_error(
|
||||
"video_url must be a public HTTPS MP4 URL (the `video`/`public_url` "
|
||||
"from a prior Imagine result)"
|
||||
)
|
||||
if not _configured_for_xai_video():
|
||||
return _provider_not_configured_error()
|
||||
|
||||
result = run_xai_video_extend(
|
||||
prompt=prompt,
|
||||
video_url=video_url,
|
||||
duration=duration,
|
||||
model=model,
|
||||
)
|
||||
return json.dumps(result)
|
||||
|
||||
|
||||
registry.register(
|
||||
name="xai_video_edit",
|
||||
toolset="video_gen",
|
||||
schema=XAI_VIDEO_EDIT_SCHEMA,
|
||||
handler=_handle_xai_video_edit,
|
||||
check_fn=_check_xai_video_requirements,
|
||||
requires_env=[],
|
||||
is_async=False,
|
||||
emoji="video",
|
||||
)
|
||||
|
||||
registry.register(
|
||||
name="xai_video_extend",
|
||||
toolset="video_gen",
|
||||
schema=XAI_VIDEO_EXTEND_SCHEMA,
|
||||
handler=_handle_xai_video_extend,
|
||||
check_fn=_check_xai_video_requirements,
|
||||
requires_env=[],
|
||||
is_async=False,
|
||||
emoji="video",
|
||||
)
|
||||
+2
-3
@@ -139,11 +139,10 @@ TOOLSETS = {
|
||||
"description": (
|
||||
"Video generation tools. Single ``video_generate`` tool covers "
|
||||
"text-to-video (prompt only) and image-to-video (prompt + "
|
||||
"image_url), plus reference-to-video. Provider-specific edit/"
|
||||
"extend workflows may appear as separate tools. Configure via "
|
||||
"image_url) — the active backend auto-routes. Configure via "
|
||||
"``hermes tools`` → Video Generation."
|
||||
),
|
||||
"tools": ["video_generate", "xai_video_edit", "xai_video_extend"],
|
||||
"tools": ["video_generate"],
|
||||
"includes": []
|
||||
},
|
||||
|
||||
|
||||
@@ -3160,9 +3160,9 @@ def _session_info(agent, session: dict | None = None) -> dict:
|
||||
|
||||
def _tool_ctx(name: str, args: dict) -> str:
|
||||
try:
|
||||
from agent.display import build_tool_label
|
||||
from agent.display import build_tool_preview
|
||||
|
||||
return build_tool_label(name, args, max_len=80) or ""
|
||||
return build_tool_preview(name, args, max_len=80) or ""
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
@@ -382,7 +382,6 @@ def register(ctx):
|
||||
| [`post_tool_call`](#post_tool_call) | After any tool returns | ignored |
|
||||
| [`pre_llm_call`](#pre_llm_call) | Once per turn, before the tool-calling loop | `{"context": str}` to prepend context to the user message |
|
||||
| [`post_llm_call`](#post_llm_call) | Once per turn, after the tool-calling loop | ignored |
|
||||
| [`pre_verify`](#pre_verify) | Once per turn when the agent edited code, before it verifies/finishes | `{"action": "continue", "message": str}` to keep going |
|
||||
| [`on_session_start`](#on_session_start) | New session created (first turn only) | ignored |
|
||||
| [`on_session_end`](#on_session_end) | Session ends | ignored |
|
||||
| [`on_session_finalize`](#on_session_finalize) | CLI/gateway tears down an active session (flush, save, stats) | ignored |
|
||||
@@ -653,71 +652,6 @@ def register(ctx):
|
||||
|
||||
---
|
||||
|
||||
### `pre_verify`
|
||||
|
||||
Fires **once per turn when the agent edited code**, just before it finishes (after the built-in verify-on-stop guard). This is a user/plugin policy gate: a callback can keep the agent going — run a check, defer it, tidy the diff — instead of letting it stop.
|
||||
|
||||
Hermes' shipped verification guidance is not a default `pre_verify` hook. It is appended to the evidence-based verify-on-stop nudge when edited code lacks fresh verification evidence, so it does not create a second default continuation path. Set `agent.verify_guidance: false` to keep that built-in evidence nudge terse.
|
||||
|
||||
**Callback signature:**
|
||||
|
||||
```python
|
||||
def my_callback(session_id: str, platform: str, model: str, coding: bool,
|
||||
attempt: int, final_response: str, changed_paths: list, **kwargs):
|
||||
```
|
||||
|
||||
| Parameter | Type | Description |
|
||||
|-----------|------|-------------|
|
||||
| `session_id` | `str` | Unique identifier for the current session |
|
||||
| `platform` | `str` | Where the session is running (`"cli"`, `"telegram"`, …) |
|
||||
| `model` | `str` | The model identifier |
|
||||
| `coding` | `bool` | Whether the turn is in the coding posture (in a code workspace) — scope your hook on this |
|
||||
| `attempt` | `int` | How many times this turn has already been nudged (0 on the first) — self-throttle on this |
|
||||
| `final_response` | `str` | The answer the agent is about to deliver |
|
||||
| `changed_paths` | `list` | Files the agent edited this turn (sorted, always non-empty here) |
|
||||
|
||||
Scope a hook to the coding context by checking `coding` and make it one-shot with `attempt` (shell hooks read both from `.extra`), the same way a `pre_tool_call` hook scopes on `tool_name` — so you can register several `pre_verify` hooks, each firing only where it should.
|
||||
|
||||
**Fires:** In `agent/conversation_loop.py`, at the point the agent would accept a final answer, immediately after the verify-on-stop check — but only when the agent edited code this turn and at least one `pre_verify` hook is registered.
|
||||
|
||||
**Return value — keep the agent going:**
|
||||
|
||||
```python
|
||||
return {"action": "continue", "message": "Run the formatter on your changes, then finish."}
|
||||
```
|
||||
|
||||
The `message` is appended as a synthetic user turn and the loop runs again. The Claude-Code Stop shape (`{"decision": "block", "reason": "..."}`, where blocking the stop means *keep going*) is accepted too. A directive with no message — or any other return — lets the turn finish.
|
||||
|
||||
**Bounded:** consecutive continue directives in one turn are capped by `agent.max_verify_nudges` (default 3), so a hook that always says continue can never trap the loop. The attempted answer is kept in history but not surfaced to the user while the agent is being nudged.
|
||||
|
||||
**Make it idempotent:** the hook re-fires after each nudge, so gate on `attempt` (`if attempt: return None`) — otherwise it just nudges until the bound is hit.
|
||||
|
||||
**Use cases:** defer tests/lints during creative iteration, require green checks for certain paths, block "done" until a changelog entry exists, run a project-specific verification checklist.
|
||||
|
||||
**Example — defer checks on creative UI work, scoped + one-shot:**
|
||||
|
||||
```python
|
||||
UI = (".tsx", ".jsx", ".css", ".scss")
|
||||
|
||||
def defer_ui_checks(coding, attempt, changed_paths, **kwargs):
|
||||
if attempt or not coding:
|
||||
return None # one-shot, coding only
|
||||
if not all(p.endswith(UI) for p in changed_paths):
|
||||
return None # only pure-UI edits
|
||||
return {
|
||||
"action": "continue",
|
||||
"message": "This is UI work — don't run tests/lints yet; ask the user to "
|
||||
"eyeball it first, and clean the diff before any commit.",
|
||||
}
|
||||
|
||||
def register(ctx):
|
||||
ctx.register_hook("pre_verify", defer_ui_checks)
|
||||
```
|
||||
|
||||
For standing guidance that should shape the built-in missing-evidence nudge, use `agent.verify_guidance`. For broader coding posture rules that don't need to *gate* verification, prefer `agent.coding_instructions` in `config.yaml` — it rides the coding brief and costs no extra turn.
|
||||
|
||||
---
|
||||
|
||||
### `on_session_start`
|
||||
|
||||
Fires **once** when a brand-new session is created. Does **not** fire on session continuation (when the user sends a second message in an existing session).
|
||||
@@ -1350,10 +1284,6 @@ Each time the event fires, Hermes spawns a subprocess for every matching hook (m
|
||||
// Inject context for pre_llm_call:
|
||||
{"context": "Today is Friday, 2026-04-17"}
|
||||
|
||||
// Keep the agent going at the verify gate (pre_verify); both shapes accepted:
|
||||
{"action": "continue", "message": "Run the formatter, then finish."}
|
||||
{"decision": "block", "reason": "Run the formatter, then finish."}
|
||||
|
||||
// Silent no-op — any empty / non-matching output is fine:
|
||||
```
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user