diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 7d95ba76c9a2..0794b5ed4f8e 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -7,7 +7,7 @@ description: >- inputs: github-token: - description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow. + description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow. required: false default: ${{ github.token }} diff --git a/.github/actions/get-app-token/action.yml b/.github/actions/get-app-token/action.yml new file mode 100644 index 000000000000..611533f28337 --- /dev/null +++ b/.github/actions/get-app-token/action.yml @@ -0,0 +1,59 @@ +name: Get GitHub App Token +description: >- + Mint a short-lived (1-hour) installation access token from the repo's + GitHub App, replacing the long-lived AUTOFIX_BOT_PAT. App tokens get + 5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN) + and are scoped to the App's installation permissions, not a user account. + + Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set — + this happens on fork PRs where repo secrets are unavailable. The fallback + ensures classification, timings, and review comments still work on + forks (with the lower GITHUB_TOKEN rate limit). + + Composite actions cannot access the secrets context directly, so the + calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY + as inputs. When both are empty (fork PRs), the fallback fires. + +inputs: + client-id: + description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow. + required: false + default: '' + private-key: + description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow. + required: false + default: '' + +outputs: + token: + description: A GitHub App installation access token (1-hour TTL), or GITHUB_TOKEN on forks. + value: ${{ steps.app-token.outputs.token || steps.fallback.outputs.token }} + +runs: + using: composite + steps: + - name: Check if App credentials exist + id: check + shell: bash + env: + CLIENT_ID: ${{ inputs.client-id }} + run: | + if [ -n "$CLIENT_ID" ]; then + echo "has_app=true" >> "$GITHUB_OUTPUT" + else + echo "has_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub App token + id: app-token + if: steps.check.outputs.has_app == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + client-id: ${{ inputs.client-id }} + private-key: ${{ inputs.private-key }} + + - name: Fall back to GITHUB_TOKEN + id: fallback + if: steps.check.outputs.has_app != 'true' + shell: bash + run: echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faae3b6f2704..9cf15a1a6e02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: permissions: contents: read - pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment) + pull-requests: write # needed by lint (PR comment) + supply-chain review_status actions: read # needed by osv-scanner (SARIF upload) security-events: write # needed by osv-scanner (SARIF upload) packages: write # needed by docker build @@ -49,13 +49,19 @@ jobs: event_name: ${{ github.event_name }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Detect affected areas id: classify uses: ./.github/actions/detect-changes with: - # Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to - # the built-in read-only token so classification still works there. - github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} + # The get-app-token composite action falls back to GITHUB_TOKEN + # on fork PRs where APP_ID is unavailable. + github-token: ${{ steps.app-token.outputs.token }} # ───────────────────────────────────────────────────────────────────── # Lane-gated sub-workflows. Each runs in parallel after detect finishes. @@ -73,11 +79,10 @@ jobs: lint: name: Python lints needs: detect - if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true' + if: needs.detect.outputs.python == 'true' uses: ./.github/workflows/lint.yml with: event_name: ${{ needs.detect.outputs.event_name }} - ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} secrets: inherit js-tests: @@ -87,6 +92,12 @@ jobs: uses: ./.github/workflows/js-tests.yml secrets: inherit + e2e-desktop: + name: Desktop E2E + needs: detect + if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' + uses: ./.github/workflows/e2e-desktop.yml + docs-site: name: Docs Site needs: detect @@ -138,12 +149,20 @@ jobs: supply-chain: name: Supply-chain scan needs: detect - if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true') + if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true') uses: ./.github/workflows/supply-chain-audit.yml with: event_name: ${{ needs.detect.outputs.event_name }} scan: ${{ needs.detect.outputs.scan == 'true' }} deps: ${{ needs.detect.outputs.deps == 'true' }} + + review-labels: + name: Review label gate + needs: detect + if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true') + uses: ./.github/workflows/review-labels.yml + with: + ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }} secrets: inherit @@ -152,19 +171,99 @@ jobs: uses: ./.github/workflows/osv-scanner.yml secrets: inherit + # ───────────────────────────────────────────────────────────────────── + # Live-updating PR review comment. + # + # A single ``comment-live`` job polls the GitHub Actions API every 15s + # for job statuses in this run, re-assembles the review comment from + # whatever results are available, and upserts it via the + # ```` marker. + # + # The poller exits when all non-infra jobs are completed (or on + # timeout). ci-timings' review_status is picked up automatically when + # its artifact becomes available — the poller downloads and merges it. + # ───────────────────────────────────────────────────────────────────── + comment-live: + name: CI review comment (live) + needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check] + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run live comment poller + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + # Commit info for the review comment header. + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} + COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }} + COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }} + # Structured review statuses from workflow_call jobs. + # Each job outputs a JSON array of {source, results: [...]} objects + # that the assembler renders directly — no hardcoded job-name + # matching. We merge all available outputs into one array. + REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }} + run: | + set -uo pipefail + + # REVIEW_STATUSES is a JSON array of strings (some may be empty + # when a job was skipped). Parse each string and merge into one + # flat array for the assembler. + python3 - <<'PYEOF' + import json, os, sys + + raw = os.environ.get("REVIEW_STATUSES", "") + merged = [] + if raw: + try: + arr = json.loads(raw) + except (json.JSONDecodeError, TypeError): + arr = [] + for item in arr: + if not item: + continue + try: + statuses = json.loads(item) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(statuses, list): + merged.extend(statuses) + + # Write merged array to a temp file the poller reads. + with open("/tmp/review_statuses.json", "w") as f: + json.dump(merged, f) + print(f"Merged {len(merged)} review status entries") + PYEOF + + python3 scripts/ci/live_comment.py \ + --interval 15 \ + --timeout 2100 \ + --review-statuses-file /tmp/review_statuses.json + # ───────────────────────────────────────────────────────────────────── # Gate: runs after everything. ``if: always()`` ensures it reports a # status even when some deps were skipped. Only actual ``failure`` # results cause it to fail; ``skipped`` is treated as success. # # Branch protection should require ONLY this check. + # + # Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so + # the live comment poller can list failed jobs in the PR comment. # ───────────────────────────────────────────────────────────────────── all-checks-pass: name: All required checks pass needs: + - detect - tests - lint - js-tests + - e2e-desktop - docs-site - history-check - contributor-check @@ -172,20 +271,30 @@ jobs: - lockfile-diff - docker-lint - supply-chain + - review-labels - osv-scanner + # comment-live is a polling job — it doesn't block the gate. # we don't require docker to pass rn because it's so slow lol # - docker if: always() runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + needs-json: ${{ steps.evaluate.outputs.needs-json }} steps: - name: Evaluate job results + id: evaluate env: NEEDS: ${{ toJSON(needs) }} run: | echo "$NEEDS" | python3 -c " import json, sys needs = json.load(sys.stdin) + # Emit compact {job_name: result} for the comment assembler. + compact = {name: info['result'] for name, info in needs.items()} + print(f'needs-json={json.dumps(compact)}') + with open('$GITHUB_OUTPUT', 'a') as f: + f.write(f'needs-json={json.dumps(compact)}\n') failed = [name for name, info in needs.items() if info['result'] == 'failure'] for name, info in sorted(needs.items()): result = info['result'] @@ -202,6 +311,9 @@ jobs: # 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. + # + # The live comment poller picks up ci-timings' completion automatically — + # it reads review-status.json from the artifact when the job finishes. # ───────────────────────────────────────────────────────────────────── ci-timings: name: CI timing report @@ -213,6 +325,13 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Restore baseline cache (PR only) if: github.event_name == 'pull_request' uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 @@ -229,24 +348,28 @@ jobs: # Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to # the built-in read-only token so the timings API read still works # there instead of hard-failing this advisory job on every fork PR. - GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} + # The get-app-token composite action falls back to GITHUB_TOKEN + # on fork PRs where APP_ID is unavailable. + GITHUB_TOKEN: ${{ steps.app-token.outputs.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 + --summary-out ci-timings-summary.md \ + --review-status-out review-status.json - - name: Upload HTML report + - name: Upload HTML report + review status # Advisory report — artifact-service blips must not fail the job. continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 id: ci-timings-artifact with: name: ci-timings-report - path: ci-timings-report.html + path: | + ci-timings-report.html + review-status.json retention-days: 14 - archive: false - name: Output summary env: diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 2c5db6f311de..014e1e2ff93d 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -2,6 +2,10 @@ name: Contributor Attribution Check on: workflow_call: + outputs: + review_status: + description: "JSON array of review status objects" + value: ${{ jobs.check-attribution.outputs.review_status }} permissions: contents: read @@ -10,12 +14,15 @@ jobs: check-attribution: runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + review_status: ${{ steps.check-emails.outputs.review_status }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Full history needed for git log - name: Check for unmapped contributor emails + id: check-emails run: | # Get the merge base between this PR and main MERGE_BASE=$(git merge-base origin/main HEAD) @@ -25,6 +32,7 @@ jobs: if [ -z "$NEW_EMAILS" ]; then echo "No new commits to check." + echo "review_status=[]" >> "$GITHUB_OUTPUT" exit 0 fi @@ -67,6 +75,16 @@ jobs: echo "" echo "To find the GitHub username for an email:" echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'" + + # Emit review_status for unmapped emails + DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //') + HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"": "",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n' + REVIEW_STATUS=$(jq -nc \ + --arg detail "$DETAIL" \ + --arg how_to_fix "$HOW_TO_FIX" \ + '[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]') + echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT" + exit 1 else echo "✅ All contributor emails are mapped." diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index e06a0842a633..fd09205a055c 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -56,6 +56,13 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -73,8 +80,8 @@ jobs: - name: Prepare skills index (unified multi-source catalog) env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} - GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }} REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }} run: | diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml new file mode 100644 index 000000000000..c07a94be8a3e --- /dev/null +++ b/.github/workflows/e2e-desktop.yml @@ -0,0 +1,212 @@ +name: E2E Desktop + +on: + workflow_call: + +permissions: + contents: read + +concurrency: + group: e2e-desktop-${{ github.ref }} + cancel-in-progress: true + +jobs: + e2e: + name: Playwright E2E (Linux) + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + # ── System deps for Electron on headless Ubuntu ─────────────────── + # Electron needs GTK, NSS,atk, etc. even under xvfb. Playwright's + # install-deps covers browsers; for Electron we install the apt + # packages directly. + - name: Install system dependencies for Electron + run: | + sudo apt-get update -qq + sudo apt-get install -y -qq \ + xvfb \ + libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \ + xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64 + + # ── Node ─────────────────────────────────────────────────────────── + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 + with: + node-version: 22 + cache: npm + # Full npm ci (not --ignore-scripts): electron's postinstall + # downloads the binary we launch, and node-pty's native build is + # needed for the terminal pane. + - uses: ./.github/actions/retry + with: + command: npm ci + + # ── Python (for the hermes serve backend) ────────────────────────── + - name: Install uv + uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 + with: + enable-cache: true + cache-dependency-glob: | + pyproject.toml + uv.lock + - name: Set up Python 3.11 + run: uv python install 3.11 + - name: Install Python dependencies + uses: ./.github/actions/retry + with: + command: uv sync --locked --python 3.11 --extra all --extra dev + + # ── Build desktop app ───────────────────────────────────────────── + - run: npm run --prefix apps/desktop build + + # ── Restore visual baseline screenshots from main ────────────────── + # Baselines are generated on main (via --update-snapshots) and cached. + # On PRs, we restore them so toHaveScreenshot has something to compare + # against. The cache key is keyed on the desktop source files so a + # UI change naturally invalidates it — but we fall back to the main + # cache to avoid cold starts on unrelated PRs. + - name: Restore visual baseline screenshots + id: restore-baselines + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: apps/desktop/e2e/*-snapshots + key: visual-baselines-${{ github.ref_name }} + restore-keys: | + visual-baselines-main + + # ── Run Playwright E2E under xvfb ───────────────────────────────── + # xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron + # window always has a consistent viewport for screenshot comparison. + # On main, we run with --update-snapshots to generate baselines. + - name: Run Playwright E2E tests + working-directory: apps/desktop + run: | + if [ "${{ github.ref_name }}" = "main" ]; then + echo "On main — generating/updating baseline screenshots" + xvfb-run -a --server-args="-screen 0 1280x1024x24" \ + npx playwright test --reporter=list --update-snapshots + else + echo "On PR — comparing against cached baselines" + xvfb-run -a --server-args="-screen 0 1280x1024x24" \ + npx playwright test --reporter=list + fi + env: + CI: "true" + # Ensure no real API keys leak into the test env. + OPENROUTER_API_KEY: "" + OPENAI_API_KEY: "" + NOUS_API_KEY: "" + + # ── Save updated baselines to cache (main only) ─────────────────── + - name: Save updated baselines to cache + if: github.ref_name == 'main' && always() + uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 + with: + path: apps/desktop/e2e/*-snapshots + key: visual-baselines-main + + # ── Upload Playwright report (HTML + traces) ────────────────────── + - name: Upload Playwright report + id: upload-report + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-report-${{ github.sha }} + path: apps/desktop/playwright-report + retention-days: 14 + overwrite: true + + # ── Upload test results (screenshots, traces, diffs) ─────────────── + - name: Upload test results + id: upload-results + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: playwright-test-results-${{ github.sha }} + path: apps/desktop/test-results + retention-days: 14 + overwrite: true + + # ── Upload just the visual diffs (small, fast to review) ────────── + - name: Upload visual diffs + id: upload-diffs + if: always() && github.ref_name != 'main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: visual-diffs-${{ github.sha }} + path: | + apps/desktop/test-results/**/*-diff.png + apps/desktop/test-results/**/*-actual.png + apps/desktop/test-results/**/*-expected.png + retention-days: 14 + overwrite: true + if-no-files-found: ignore + + # ── Generate step summary with visual diff info ─────────────────── + # Parse the JSON report + scan for diff images, then post a summary + # to the GitHub Actions step output so reviewers can see what changed + # without downloading artifacts. Runs AFTER uploads so it can link + # the artifact download URLs from their step outputs. + - name: Generate visual diff summary + if: always() + working-directory: apps/desktop + env: + REPORT_URL: ${{ steps.upload-report.outputs.artifact-url }} + RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }} + DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }} + run: | + echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + + # Count diff images (playwright writes *-diff.png on mismatch) + DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l) + ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l) + + if [ "$DIFF_COUNT" -eq 0 ]; then + echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY" + else + echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY" + echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY" + + # List each diff image with a link to the artifact + for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do + base=$(echo "$diff" | sed 's/-diff\.png$//') + test_name=$(basename "$base") + echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY" + done + fi + + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + if [ -n "$RESULTS_URL" ]; then + echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY" + fi + if [ -n "$REPORT_URL" ]; then + echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY" + fi + if [ -n "$DIFFS_URL" ]; then + echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY" + fi + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY" + + # Also parse the JSON report for pass/fail counts + if [ -f playwright-report/results.json ]; then + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "### Test Results" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + node -e " + const r = require('./playwright-report/results.json'); + const stats = r.stats || {}; + console.log('| Status | Count |'); + console.log('|--------|-------|'); + console.log('| ✅ Passed | ' + (stats.expected || 0) + ' |'); + console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |'); + console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |'); + console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |'); + " >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true + fi diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index a48dba8cb8af..668f0f795dd9 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -15,6 +15,10 @@ name: History Check on: workflow_call: + outputs: + review_status: + description: "JSON array of review_status objects for the synthesizer." + value: ${{ jobs.check-common-ancestor.outputs.review_status }} permissions: contents: read @@ -23,18 +27,23 @@ jobs: check-common-ancestor: runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + review_status: ${{ steps.merge-base-check.outputs.review_status }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # full history both sides for merge-base - - name: Reject PRs with no common ancestor on main + - id: merge-base-check + name: Reject PRs with no common ancestor on main run: | # `git merge-base` exits non-zero AND prints nothing when the two # commits share no ancestor. We check both conditions explicitly # so the failure message is clear regardless of which signal fires # first. if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then + STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]' + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" echo "" echo "::error::This PR has no common ancestor with main." echo "" @@ -56,3 +65,4 @@ jobs: exit 1 fi echo "::notice::Common ancestor with main: $BASE" + echo "review_status=[]" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml index 38494abd0e38..8fb0460bc056 100644 --- a/.github/workflows/js-autofix.yml +++ b/.github/workflows/js-autofix.yml @@ -7,7 +7,7 @@ name: auto-fix lint issues & formatting # auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint # check in typecheck.yml fails only when un-fixable errors remain. # -# NOTE: AUTOFIX_BOT_PAT pushes DO trigger further workflow runs (unlike +# NOTE: App token pushes DO trigger further workflow runs (unlike # secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }}) # with cancel-in-progress: true prevents an infinite loop — a re-triggered # run cancels the in-flight one, and since the second run finds no new fixes @@ -128,6 +128,13 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Download patch uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -170,7 +177,7 @@ jobs: - name: Create/update PR and enable auto-merge env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} BOT_BRANCH: bot/js-autofix run: | set -euo pipefail @@ -193,7 +200,7 @@ jobs: - name: Wait for merge, auto-close on failure or stale env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} START_SHA: ${{ github.sha }} run: | set -euo pipefail diff --git a/.github/workflows/label-rerun.yml b/.github/workflows/label-rerun.yml new file mode 100644 index 000000000000..fbd8b3b89327 --- /dev/null +++ b/.github/workflows/label-rerun.yml @@ -0,0 +1,81 @@ +name: Label rerun + +# When the ``ci-reviewed`` label is added to a PR, rerun all failed jobs in +# the latest CI run. This re-evaluates ``review-labels`` (which now sees the +# label) and GitHub automatically reruns dependent jobs (``comment-live``, +# ``all-checks-pass``) — so the review comment gets updated too. +# +# If the CI run is still in progress when the label is added, we wait for it +# to finish before rerunning (``gh run rerun`` only works on completed runs). +# The wait can be long (20+ min for a full CI run), but it's better than +# silently failing and leaving the reviewer stuck. + +on: + pull_request: + types: [labeled] + +permissions: + actions: write + pull-requests: read + +concurrency: + group: label-rerun-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + rerun-review-labels: + name: Rerun review-labels job + if: github.event.label.name == 'ci-reviewed' + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Wait for CI run to finish, then rerun failed jobs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + + # Find the latest CI run for this PR's head SHA. + RUN_ID=$(gh run list \ + --repo "$REPO" \ + --commit "$HEAD_SHA" \ + --workflow ci.yml \ + --limit 1 \ + --json databaseId,status \ + --jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true) + + if [ -z "$RUN_ID" ]; then + echo "No CI run found for this PR — nothing to rerun." + exit 0 + fi + + # Split "RUN_ID STATUS" into two vars. + RUN_ID="${RUN_ID%% *}" + STATUS="${RUN_ID##* }" + + echo "Latest CI run: $RUN_ID (status: $STATUS)" + + # If the run is still in progress, wait for it to finish. + # gh run rerun only works on completed runs — if we try while it's + # running, GitHub rejects with "cannot be rerun; This workflow is + # already running". + if [ "$STATUS" != "completed" ]; then + echo "Run is $STATUS — waiting for completion (this may take a while)..." + # gh run watch --exit-status exits non-zero if the run fails, + # which is expected (the label gate fails). Don't let that kill + # the workflow — we WANT to rerun failed jobs. + timeout 2100 gh run watch "$RUN_ID" --repo "$REPO" --interval 15 || true + + # Verify it's actually completed now. + STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status' 2>/dev/null || echo "unknown") + if [ "$STATUS" != "completed" ]; then + echo "Run is still $STATUS after wait — giving up." + exit 0 + fi + fi + + echo "Run completed. Rerunning all failed jobs..." + gh run rerun "$RUN_ID" --repo "$REPO" --failed || true + echo "Done. GitHub will rerun review-labels and all dependent jobs." diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 28df38ad3e5d..670b6f2a44a2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,11 +2,14 @@ name: Lint (ruff + ty) # Two things here: # 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch. -# Posts a Markdown summary and a PR comment. Exit zero always. +# Writes a Markdown summary to the run page. Exit zero always. # 2. Blocking ``ruff check .`` — enforces the explicit rules in # ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge. -# Separate job so the advisory diff still runs and posts even when -# enforcement fails. +# Separate job so the advisory diff still runs even when enforcement +# fails. +# +# CI-sensitive file review was previously here as a ``ci-review`` job but +# has moved to ``review-labels.yml`` so it can be rerun independently. on: workflow_call: @@ -15,14 +18,9 @@ on: description: The event name from the calling orchestrator (pull_request or push). type: string required: true - ci_review: - description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label. - type: boolean - default: false permissions: contents: read - pull-requests: write # needed to post/update PR comments concurrency: group: lint-${{ github.ref }} @@ -162,115 +160,3 @@ jobs: - name: Run footgun checker run: python scripts/check-windows-footguns.py --all - - ci-review: - # Require explicit maintainer review when CI-sensitive files change: - # eslint config, workflow YAMLs, or composite actions. These files - # influence what code the js-autofix job executes and pushes to - # main, so a malicious PR could inject arbitrary code via a custom eslint - # rule's `fix` function. The label gate ensures a human reviews before - # merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml. - name: CI-sensitive file review - if: inputs.event_name == 'pull_request' && inputs.ci_review - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Require ci-reviewed label - id: label-check - env: - # Read-only label lookup. Use the built-in GITHUB_TOKEN (present and - # read-only on forks) so the gate works on fork PRs; fall back to it - # when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to - # "label absent" rather than hard-failing the step. - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} - run: | - set -euo pipefail - PR="${{ github.event.pull_request.number }}" - LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true) - if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then - echo "reviewed=true" >> "$GITHUB_OUTPUT" - echo "ci-reviewed label present." - exit 0 - fi - echo "reviewed=false" >> "$GITHUB_OUTPUT" - - # On failure: find the bot's previous comment and edit it, or create - # a new one if none exists. Using an HTML comment marker so we can - # locate it reliably across runs without parsing the body text. - # Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API - # call would fail. The label gate still holds via the step below. - - name: Post or update review warning - if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true - env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} - run: | - set -euo pipefail - PR="${{ github.event.pull_request.number }}" - MARKER="" - BODY="${MARKER} - ## ⚠️ CI-sensitive file review required - - This PR changes CI-sensitive files (eslint config, workflow YAMLs, - or composite actions). These files influence what code the - js-autofix job executes and pushes to main. - - A maintainer should verify: - - no new eslint rules with custom \`fix\` functions that write outside linted paths, - - no workflow changes that widen permissions or remove guards, - - no composite action changes that alter what gets executed. - - After review, add the \`ci-reviewed\` label and re-run this check." - - # Find an existing comment with our marker. - COMMENT_ID=$(gh api \ - "repos/${{ github.repository }}/issues/${PR}/comments" \ - --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ - | head -1 || true) - - if [ -n "$COMMENT_ID" ]; then - gh api --method PATCH \ - "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ - -f body="$BODY" - else - gh pr comment "$PR" --body "$BODY" - fi - - # Fail the job when the label is missing — always runs (including - # fork PRs) so the security gate holds even when the comment step - # was skipped above. - - name: Fail on missing label - if: steps.label-check.outputs.reviewed != 'true' - run: | - echo "::error::CI-sensitive changes require the ci-reviewed label." - exit 1 - - # On success: if a previous warning comment exists, edit it to show - # the review passed so the PR doesn't have a stale ⚠️ sitting around. - # Skipped on fork PRs — no comment was ever posted to update. - - name: Update previous warning to passed - if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true - env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} - run: | - set -euo pipefail - PR="${{ github.event.pull_request.number }}" - MARKER="" - - # Find an existing comment with our marker. - COMMENT_ID=$(gh api \ - "repos/${{ github.repository }}/issues/${PR}/comments" \ - --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ - | head -1 || true) - - if [ -n "$COMMENT_ID" ]; then - BODY="${MARKER} - ## ✅ CI-sensitive file review passed - - The \`ci-reviewed\` label is present on this PR." - - gh api --method PATCH \ - "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ - -f body="$BODY" - fi diff --git a/.github/workflows/lockfile-diff.yml b/.github/workflows/lockfile-diff.yml index 2dcf66ea7c55..9d8d59f6da7e 100644 --- a/.github/workflows/lockfile-diff.yml +++ b/.github/workflows/lockfile-diff.yml @@ -7,22 +7,25 @@ name: Lockfile diff # the ``packages`` map at the merge base and at HEAD and set-diffs the # {install path: version} maps instead. # -# The comment is upserted: the script embeds a hidden HTML marker and the -# workflow PATCHes the existing comment when one is found, so a PR gets -# exactly one lockfile-diff comment that tracks the latest push instead -# of a stack of stale ones. When a later push reverts all lockfile -# changes, the comment is updated to say so (deleting it would be more -# surprising than telling the reviewer it's resolved). +# The semantic diff is exposed as a workflow_call output ``review_status`` +# (a JSON array in the unified status format) and an artifact +# (``lockfile-diff`` containing the markdown fragment) for the step +# summary. # -# Never blocking — this is review signal, not enforcement. Exit is 0 even -# when commenting fails (fork PRs get a read-only GITHUB_TOKEN). +# Never blocking — this is review signal, not enforcement. on: workflow_call: + outputs: + changed: + description: Whether package-lock.json changed relative to the target branch. + value: ${{ jobs.diff.outputs.changed }} + review_status: + description: JSON array of review status objects for the unified PR comment. + value: ${{ jobs.diff.outputs.review_status }} permissions: contents: read - pull-requests: write # post/update the diff comment concurrency: group: lockfile-diff-${{ github.event.pull_request.number || github.ref }} @@ -33,6 +36,9 @@ jobs: name: package-lock.json semantic diff runs-on: ubuntu-latest timeout-minutes: 5 + outputs: + changed: ${{ steps.diff.outputs.changed }} + review_status: ${{ steps.emit-status.outputs.review_status }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -54,45 +60,36 @@ jobs: --output /tmp/lockfile-diff.md if [ -s /tmp/lockfile-diff.md ]; then echo "changed=true" >> "$GITHUB_OUTPUT" - cat /tmp/lockfile-diff.md >> "$GITHUB_STEP_SUMMARY" + { + echo "## package-lock.json semantic diff" + echo "" + cat /tmp/lockfile-diff.md + } >> "$GITHUB_STEP_SUMMARY" else echo "changed=false" >> "$GITHUB_OUTPUT" + : > /tmp/lockfile-diff.md fi - - name: Post or update PR comment - env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - CHANGED: ${{ steps.diff.outputs.changed }} + - name: Emit review_status + id: emit-status run: | set -euo pipefail - MARKER='' + CHANGED="${{ steps.diff.outputs.changed }}" + STATUS="[]" - # Find our previous comment (paginated — busy PRs exceed one page). - EXISTING=$(gh api --paginate "repos/${REPO}/issues/${PR}/comments" \ - --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" \ - | head -1 || true) - - if [ "$CHANGED" != "true" ]; then - if [ -n "$EXISTING" ]; then - # A previous push changed the lockfile but the latest one - # doesn't — update the comment rather than leave stale info. - printf '%s\n✅ package-lock.json changes from an earlier push have been reverted — locked versions now match the target branch.\n' "$MARKER" > /tmp/lockfile-diff.md - else - echo "No lockfile changes and no existing comment — nothing to do." - exit 0 - fi - fi - - if [ -n "$EXISTING" ]; then - echo "Updating existing comment ${EXISTING}" - gh api --method PATCH "repos/${REPO}/issues/comments/${EXISTING}" \ - -F body=@/tmp/lockfile-diff.md > /dev/null \ - || echo "::warning::Could not update PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)" + if [ "$CHANGED" = "true" ]; then + CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") + STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}" else - echo "Creating new comment" - gh api "repos/${REPO}/issues/${PR}/comments" \ - -F body=@/tmp/lockfile-diff.md > /dev/null \ - || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)" + STATUS="[]" fi + + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" + + - name: Upload diff artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: lockfile-diff + path: /tmp/lockfile-diff.md + retention-days: 1 + overwrite: true diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index e5a983b1bca2..37fed82fbbf9 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -14,14 +14,14 @@ name: OSV-Scanner # code patterns in PR diffs) by covering the orthogonal "currently-pinned # dep became known-vulnerable" case. # -# Steps below are inlined from Google's officially-recommended reusable -# workflow (google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml), -# rather than called via `uses:` so we can set a `timeout-minutes` in the -# degenerate case where this job hangs. - +# Uses Google's officially-recommended reusable workflow, pinned by SHA. # Findings land in the repo's Security tab (Code Scanning > OSV-Scanner). # fail-on-vuln is disabled so the job does not block merges on pre-existing # vulnerabilities in pinned deps that we may need to patch deliberately. +# +# The reusable workflow can't emit custom outputs, so a wrapper job +# downloads the SARIF result and summarizes the vulnerability count into +# a review_status for the unified PR comment. on: workflow_call: @@ -40,62 +40,85 @@ permissions: jobs: scan: name: Scan lockfiles - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + # Scan explicit lockfiles rather than recursing, so we only look at + # the three sources of truth and skip vendored / test / worktree dirs. + scan-args: |- + --lockfile=uv.lock + --lockfile=package-lock.json + --lockfile=website/package-lock.json + fail-on-vuln: false - - name: 'Run scanner' - uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + emit-status: + name: Emit review status + runs-on: ubuntu-latest + needs: scan + if: always() + outputs: + review_status: ${{ steps.emit.outputs.review_status }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download SARIF result + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - # Scan explicit lockfiles rather than recursing, so we only look at - # the three sources of truth and skip vendored / test / worktree dirs. - scan-args: |- - --output=results.json - --format=json - --lockfile=uv.lock - --lockfile=package-lock.json - --lockfile=website/package-lock.json + name: osv-results + path: /tmp/osv-results continue-on-error: true - - name: 'Run osv-scanner-reporter' - uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 - with: - scan-args: |- - --output=results.sarif - --new=results.json - --gh-annotations=false - --fail-on-vuln=false - - # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF - # format to the repository Actions tab. - - name: 'Upload artifact' - id: 'upload_artifact' - if: ${{ !cancelled() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: OSV Scanner SARIF file - path: results.sarif - retention-days: 5 - - # Upload the results to GitHub's code scanning dashboard. - - name: 'Upload to code-scanning' - if: ${{ !cancelled() }} - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 - with: - sarif_file: results.sarif - - - name: 'Print Code Scanning URL' - if: ${{ !cancelled() }} + - name: Emit review_status + id: emit run: | - echo "View the OSV-Scanner results in the 'Security' tab, using the following link:" - echo "${{ github.server_url }}/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}+tool%3Aosv-scanner" - env: - GITHUB_REF_NAME: ${{ github.ref_name }} + set -euo pipefail + STATUS="[]" - - name: 'Error troubleshooter' - if: ${{ always() && steps.upload_artifact.outcome == 'failure' }} - run: | - echo "::error::Artifact upload failed. This is most likely caused by a error during scanning earlier in the workflow." - exit 1 + if [ -f /tmp/osv-results/osv-results.sarif ]; then + # Count vulnerabilities from the SARIF file + VULN_COUNT=$(python3 -c " + import json, sys + try: + with open('/tmp/osv-results/osv-results.sarif') as f: + data = json.load(f) + count = 0 + vulns = [] + for run in data.get('runs', []): + for result in run.get('results', []): + count += 1 + rule_id = result.get('ruleId', 'unknown') + message = result.get('message', {}).get('text', '') + loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '') + vulns.append(f'- {rule_id} in {loc}: {message}') + print(count) + if vulns: + print('\n'.join(vulns[:20]), file=sys.stderr) + except Exception: + print(0) + ") + + VULN_DETAIL="" + if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then + VULN_PLURAL=$([ "$VULN_COUNT" -eq 1 ] && echo "y" || echo "ies") + VULN_DETAIL=$(python3 -c " + import json, sys + try: + with open('/tmp/osv-results/osv-results.sarif') as f: + data = json.load(f) + vulns = [] + for run in data.get('runs', []): + for result in run.get('results', []): + rule_id = result.get('ruleId', 'unknown') + loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '') + vulns.append(f'- {rule_id} in {loc}') + print(json.dumps('\n'.join(vulns[:20]))) + except Exception: + print(json.dumps('')) + ") + STATUS="[{\"source\":\"osv scan\",\"results\":[{\"kind\":\"warning\",\"title\":\"OSV vulnerability scan\",\"summary\":\"${VULN_COUNT} known vulnerabilit${VULN_PLURAL} found in pinned dependencies.\",\"detail\":${VULN_DETAIL},\"how_to_fix\":\"Review the findings in the [Security tab](../../security/code-scanning). Update the affected dependencies if a patched version is available.\"}]}]" + else + STATUS="[]" + fi + fi + + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/review-labels.yml b/.github/workflows/review-labels.yml new file mode 100644 index 000000000000..f0167b486742 --- /dev/null +++ b/.github/workflows/review-labels.yml @@ -0,0 +1,92 @@ +name: Review labels + +# Require explicit maintainer review when CI-sensitive files or the MCP +# catalog change. Previously this was split across two jobs in two +# workflows: ``ci-review`` in lint.yml (gated on ``ci_review``) and +# ``mcp-catalog-review`` in supply-chain-audit.yml (gated on +# ``mcp_catalog``). Both checked for their own label. +# +# Now consolidated: a single ``ci-reviewed`` label covers both. The +# comment sections tell the reviewer exactly what to verify per area, +# so one label is enough — the human reads the comment, not the label +# name. +# +# Outputs: +# ci_reviewed — "true" / "false" / "" (empty when neither lane ran) +# review_status — JSON array of status objects consumed by the review +# comment assembler. See scripts/ci/emit_review_status.py. + +on: + workflow_call: + inputs: + ci_review: + description: Whether CI-sensitive files (eslint config, workflows, actions) changed. + type: boolean + default: false + mcp_catalog: + description: Whether the MCP catalog / installer changed. + type: boolean + default: false + outputs: + ci_reviewed: + description: Whether the ci-reviewed label is present. Empty when neither input was true. + value: ${{ jobs.check.outputs.ci_reviewed }} + review_status: + description: JSON array of status objects for the review comment assembler. + value: ${{ jobs.check.outputs.review_status }} + +permissions: + contents: read + pull-requests: read # read PR labels + +jobs: + check: + name: Review label gate + if: inputs.ci_review || inputs.mcp_catalog + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + ci_reviewed: ${{ steps.label-check.outputs.ci_reviewed }} + review_status: ${{ steps.build-status.outputs.review_status }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check ci-reviewed label + id: label-check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + PR="${{ github.event.pull_request.number }}" + LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' || true) + + if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then + echo "ci-reviewed label present." + echo "ci_reviewed=true" >> "$GITHUB_OUTPUT" + else + echo "ci-reviewed label missing." + echo "ci_reviewed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build review_status JSON + id: build-status + env: + CI_REVIEW: ${{ inputs.ci_review }} + MCP_CATALOG: ${{ inputs.mcp_catalog }} + LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }} + run: | + set -euo pipefail + ARGS="" + if [ "$CI_REVIEW" = "true" ]; then ARGS="$ARGS --ci-review"; fi + if [ "$MCP_CATALOG" = "true" ]; then ARGS="$ARGS --mcp-catalog"; fi + if [ "$LABEL_PRESENT" = "true" ]; then ARGS="$ARGS --label-present"; fi + + python3 scripts/ci/emit_review_status.py $ARGS --output "$GITHUB_OUTPUT" + + - name: Fail on missing label + if: steps.label-check.outputs.ci_reviewed != 'true' + run: | + echo "::error::CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check." + exit 1 diff --git a/.github/workflows/skills-index-freshness.yml b/.github/workflows/skills-index-freshness.yml index 5a9bf98a0f46..4931ccaa0101 100644 --- a/.github/workflows/skills-index-freshness.yml +++ b/.github/workflows/skills-index-freshness.yml @@ -108,10 +108,18 @@ jobs: echo "Summary: ${{ steps.probe.outputs.summary }}" fi + - name: Get GitHub App token + if: steps.probe.outputs.status != 'ok' + id: app-token + uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Open issue on degraded / failed probe if: steps.probe.outputs.status != 'ok' env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} STATUS: ${{ steps.probe.outputs.status }} DETAIL: ${{ steps.probe.outputs.detail }} run: | diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 8930a636fc08..ae05c9e70466 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -24,6 +24,13 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" @@ -35,7 +42,7 @@ jobs: - name: Build skills index env: - GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} run: python scripts/build_skills_index.py - name: Upload index artifact @@ -54,7 +61,13 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Trigger Deploy Site workflow env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }} diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 648a1f7c6a63..1b8a35cb301c 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -10,9 +10,17 @@ name: Supply Chain Audit # advisory-only workflow instead. # # Path-gating is handled centrally by the ``ci.yml`` orchestrator's -# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` / -# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those -# inputs instead of re-computing the diff. +# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as +# inputs; this workflow's jobs gate on those inputs instead of re-computing +# the diff. MCP catalog review was previously here but has moved to +# ``review-labels.yml`` so it can be rerun independently. +# +# Outputs: +# review_status — JSON array of status objects consumed by the review +# comment assembler (scripts/ci/assemble_review_comment.py). +# Each job (``scan``, ``dep-bounds``) emits its own +# array; an ``aggregate`` job merges them into the +# workflow-level output. on: workflow_call: @@ -29,10 +37,10 @@ on: description: Whether pyproject.toml changed. type: boolean required: true - mcp_catalog: - description: Whether the MCP catalog / installer changed. - type: boolean - required: true + outputs: + review_status: + description: JSON array of review status objects for the review comment assembler. + value: ${{ jobs.aggregate.outputs.review_status }} permissions: pull-requests: write @@ -44,16 +52,25 @@ jobs: if: inputs.scan runs-on: ubuntu-latest timeout-minutes: 15 + outputs: + review_status: ${{ steps.emit-status.outputs.review_status }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + - name: Scan diff for critical patterns id: scan env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail @@ -61,7 +78,7 @@ jobs: HEAD="${{ github.event.pull_request.head.sha }}" # Added lines only, excluding lockfiles. - # Three-dot diff (base...head) diffs from the merge base to HEAD, + # Three-point diff (base...head) diffs from the merge base to HEAD, # so only changes introduced by this PR are included — not changes # that landed on main after the PR branched off. DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true) @@ -139,26 +156,41 @@ jobs: echo "found=false" >> "$GITHUB_OUTPUT" fi - - name: Post critical finding comment - if: steps.scan.outputs.found == 'true' + - name: Emit review_status + id: emit-status + if: always() env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + FOUND: ${{ steps.scan.outputs.found }} run: | - BODY="## 🚨 CRITICAL Supply Chain Risk Detected + python3 - <<'PYEOF' + import json, os - This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. + found = os.environ.get("FOUND", "") == "true" - $(cat /tmp/findings.md) + if found: + with open("/tmp/findings.md", encoding="utf-8") as f: + detail = f.read() + status = [{ + "source": "supply chain", + "results": [{ + "kind": "error", + "title": "Critical supply chain risk", + "summary": "Critical supply chain risk patterns detected in this PR.", + "detail": detail, + "how_to_fix": "Review the flagged code carefully. If intentional, add the `ci-reviewed` label." + }] + }] + else: + status = [] - --- - *Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*" - - gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)" + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"review_status={json.dumps(status)}\n") + PYEOF - name: Fail on critical findings if: steps.scan.outputs.found == 'true' run: | - echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details." + echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the review comment for details." exit 1 dep-bounds: @@ -166,6 +198,8 @@ jobs: if: inputs.deps runs-on: ubuntu-latest timeout-minutes: 15 + outputs: + review_status: ${{ steps.emit-status.outputs.review_status }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -188,7 +222,7 @@ jobs: exit 0 fi - # Match PyPI dep specs that have >= but no < ceiling. + # Match PyPI dep specs that have >= and no < ceiling. # Pattern: "package>=version" without a following ",<" bound. # Excludes git+ URLs (which use commit SHAs) and comments. UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true) @@ -200,26 +234,36 @@ jobs: echo "found=false" >> "$GITHUB_OUTPUT" fi - - name: Post unbounded dep warning - if: steps.bounds.outputs.found == 'true' + - name: Emit review_status + id: emit-status + if: always() env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + FOUND: ${{ steps.bounds.outputs.found }} run: | - BODY="## ⚠️ Unbounded PyPI Dependency Detected + python3 - <<'PYEOF' + import json, os - This PR adds PyPI dependencies without a \`=floor,=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.' + }] + }] + else: + status = [] - **Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\` - - --- - *See PR #2810 and CONTRIBUTING.md for the full policy rationale.*" - - gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)" + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"review_status={json.dumps(status)}\n") + PYEOF - name: Fail on unbounded deps if: steps.bounds.outputs.found == 'true' @@ -227,45 +271,36 @@ jobs: echo "::error::PyPI dependencies without upper bounds detected. Add - diff --git a/.github/workflows/uv-lockfile-check.yml b/.github/workflows/uv-lockfile-check.yml index 27b072a99410..aff4f0eb8cbd 100644 --- a/.github/workflows/uv-lockfile-check.yml +++ b/.github/workflows/uv-lockfile-check.yml @@ -45,6 +45,10 @@ name: uv.lock check on: workflow_call: + outputs: + review_status: + description: "JSON review status for the review-status aggregator" + value: ${{ jobs.check.outputs.review_status }} permissions: contents: read @@ -58,6 +62,8 @@ jobs: name: uv lock --check runs-on: ubuntu-latest timeout-minutes: 5 + outputs: + review_status: ${{ steps.verify.outputs.review_status }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -73,6 +79,7 @@ jobs: # of this file) — failures often mean "your branch is behind main, # rebase and regenerate uv.lock." - name: Verify uv.lock is up-to-date + id: verify run: | # uv lock --check re-resolves against PyPI (network). Retry so a # registry blip doesn't read as "lockfile stale". A genuinely stale @@ -117,5 +124,9 @@ jobs: on `main` post-merge. EOF echo "::error title=uv.lock out of sync::Run \`uv lock\` locally and commit the result. If on a PR, sync with main first." + review_status='[{"source":"uv.lock check","results":[{"kind":"action_required","title":"uv.lock out of sync","summary":"uv.lock is out of sync with pyproject.toml.","how_to_fix":"Run `uv lock` locally and commit the result. If on a PR, sync with main first:\n```\ngit fetch origin main\ngit rebase origin/main\nuv lock\ngit add uv.lock\ngit commit -m \"chore: refresh uv.lock\"\n```\n"}]}]' + echo "review_status=${review_status}" >> "$GITHUB_OUTPUT" exit 1 fi + review_status='[]' + echo "review_status=${review_status}" >> "$GITHUB_OUTPUT" diff --git a/.gitignore b/.gitignore index 6f1b3be6d92b..c4fb20049ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ /_pycache/ *.pyc* __pycache__/ +act/ +.act-sandbox-agent.* .venv/ .venv .vscode/ @@ -54,6 +56,10 @@ __pycache__/ hermes_agent.egg-info/ wandb/ testlogs +playwright-report/ +test-results/ +# Playwright visual regression baselines — cached from main in CI, not committed +*-snapshots/ # CLI config (may contain sensitive SSH paths) cli-config.yaml diff --git a/acp_registry/agent.json b/acp_registry/agent.json index 09319a1d02a6..1d3752bf15a1 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -1,7 +1,7 @@ { "id": "hermes-agent", "name": "Hermes Agent", - "version": "0.18.2", + "version": "0.19.0", "description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.", "repository": "https://github.com/NousResearch/hermes-agent", "website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp", @@ -9,7 +9,7 @@ "license": "MIT", "distribution": { "uvx": { - "package": "hermes-agent[acp]==0.18.2", + "package": "hermes-agent[acp]==0.19.0", "args": ["hermes-acp"] } } diff --git a/agent/billing_view.py b/agent/billing_view.py index 0e9930fd3ea6..a535aee1f142 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -1,4 +1,4 @@ -"""Surface-agnostic core for the Phase 2b terminal-billing screens. +"""Surface-agnostic core for the Phase 2b Remote Spending screens. One fetch/parse per concern, consumed identically by the CLI handler (``cli.py::_show_billing``), the TUI JSON-RPC methods diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 791c3ddb649b..dd7b75796b5c 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1236,8 +1236,29 @@ def compress_context( # Flush any un-persisted current-turn messages to the OLD # session before ending it, so they survive in the preserved # parent transcript (#47202). (In-place skips this — see above.) + # + # Pass the already-durable prefix as conversation_history so + # the flush skips it by identity (#68196). Preflight + # compression runs BEFORE the normal turn flush has stamped + # the cold-resumed history dicts with _DB_PERSISTED_MARKER, so + # without a boundary _flush_messages_to_session_db treats every + # restored row as new and re-appends the whole transcript to + # the parent. turn_context anchors _persist_user_message_idx at + # the current-turn user message before preflight runs, so + # messages[:idx] is exactly the persisted prefix; only the + # current turn's new messages get written. + current_idx = getattr(agent, "_persist_user_message_idx", None) + persisted_history = ( + messages[:current_idx] + if isinstance(current_idx, int) + and 0 <= current_idx <= len(messages) + else None + ) try: - agent._flush_messages_to_session_db(messages) + agent._flush_messages_to_session_db( + messages, + conversation_history=persisted_history, + ) except Exception: pass # best-effort — don't block compression on a flush error # Propagate title to the new session with auto-numbering diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 501e49b54d55..ed7c911b4515 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -91,6 +91,11 @@ INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model r # itself, so every exception passes through them, which would make # _hit_local always True and misclassify transient API/network errors as # non-retryable local bugs. (#66267) +# +# AttributeError is handled separately in the outer except block — it is +# ALWAYS a local programming bug when it targets agent attributes (especially +# missing methods introduced by a commit splice after an auto-update rewrites +# source underneath a live process). See the dedicated guard below. (#68178) _LOCAL_PROCESSING_MODULES = frozenset({ "agent_runtime_helpers", "message_content", @@ -719,6 +724,39 @@ def run_conversation( ) while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call: + # ── Code skew guard (#68178) ─────────────────────────────── + # Check whether the source tree has been updated underneath this + # long-lived process. If so, a lazy import can resolve newly-added + # symbols against the stale in-memory AIAgent class, producing an + # AttributeError that would otherwise retry indefinitely. + # Perform the check on every iteration (it is cheap once confirmed). + _skew_warning = getattr(agent, "_check_code_skew_before_turn", lambda: None)() + if _skew_warning: + logger.warning("Code skew detected at API call #%d: %s", api_call_count + 1, _skew_warning) + _turn_exit_reason = "code_skew_detected" + final_response = ( + f"I apologize, but the agent has detected that its source code " + f"has been updated while running. To avoid compatibility issues, " + f"please restart the application. ({_skew_warning})" + ) + messages.append({"role": "assistant", "content": final_response}) + return finalize_turn( + agent, + final_response=final_response, + api_call_count=api_call_count, + interrupted=False, + failed=True, + messages=messages, + conversation_history=conversation_history, + effective_task_id=effective_task_id, + turn_id=turn_id, + user_message=user_message, + original_user_message=original_user_message, + _should_review_memory=_should_review_memory, + _turn_exit_reason=_turn_exit_reason, + _pending_verification_response=_pending_verification_response, + _pending_verification_response_previewed=_pending_verification_response_previewed, + ) # Reset per-turn checkpoint dedup so each iteration can take one snapshot agent._checkpoint_mgr.new_turn() @@ -5706,7 +5744,21 @@ def run_conversation( _is_local_processing_error = _hit_local and not _hit_api - if _is_local_processing_error: + # AttributeError on the agent object is ALWAYS a local bug — + # it means the live process is running spliced commits (the + # method does not exist on the in-memory AIAgent class but + # conversation_loop.py references it). Circuit-break + # immediately to avoid burning provider API calls. (#68178) + _is_agent_attribute_error = ( + isinstance(e, AttributeError) + and ("run_agent" in tb_module_names or "agent" in tb_module_names) + ) + + if _is_agent_attribute_error: + error_msg = ( + f"Fatal local code error in API call #{api_call_count}: {str(e)}" + ) + elif _is_local_processing_error: error_msg = ( f"Error during local message processing after " f"OpenAI-compatible API call #{api_call_count}: {str(e)}" @@ -5760,13 +5812,22 @@ def run_conversation( # role-alternation invariants. # If we're near the limit, break to avoid infinite loops. - # Local processing errors are deterministic — stop immediately - # rather than retrying until the budget is exhausted. + # Local processing errors and agent AttributeError (commit-splice + # symptom) are deterministic — stop immediately rather than + # retrying until the budget is exhausted. (#68178) if ( - _is_local_processing_error + _is_agent_attribute_error + or _is_local_processing_error or api_call_count >= agent.max_iterations - 1 ): - if _is_local_processing_error: + if _is_agent_attribute_error: + _turn_exit_reason = f"code_skew_attribute_error({error_msg[:80]})" + final_response = ( + f"I apologize, but the agent process has detected a code " + f"mismatch (running stale code after an update). " + f"Please restart the application. Error: {error_msg}" + ) + elif _is_local_processing_error: _turn_exit_reason = f"local_processing_error({error_msg[:80]})" final_response = f"I apologize, but I encountered an error while processing the model response: {error_msg}" else: diff --git a/agent/lsp/client.py b/agent/lsp/client.py index 2aab98c2b76f..9207cb75bdaa 100644 --- a/agent/lsp/client.py +++ b/agent/lsp/client.py @@ -18,9 +18,15 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`. Implementation notes: -- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from - ``textDocument/publishDiagnostics`` notifications. Pull diagnostics - go in :attr:`_pull_diagnostics`. The merged view dedupes by content. +- All per-document state lives in one :class:`_DocState` keyed by + absolute path. Freshness is tracked with **document versions**, + not timestamps: every didChange bumps ``version``, and each stored + push/pull result is tagged with the version it describes. A + result is fresh iff its tag >= the version being waited on, so a + didChange implicitly invalidates everything older — no clearing, + no clock comparisons, no race windows. This is what prevents + "ghost diagnostics": a slow server's leftovers from the previous + edit can never masquerade as a verdict on the current content. - Whole-document sync. Even when the server advertises incremental sync, we send a single ``contentChanges`` entry replacing the @@ -45,6 +51,7 @@ import asyncio import logging import os import sys +from dataclasses import dataclass, field from pathlib import Path from typing import Any, Awaitable, Callable, Dict, List, Optional, Set from urllib.parse import quote, unquote @@ -124,6 +131,40 @@ def _end_position(text: str) -> Dict[str, int]: return {"line": last_line, "character": last_col} +@dataclass +class _DocState: + """Everything the client tracks for one open document. + + ``version`` is the LSP document version we last sent (didOpen=0, + each didChange +1). It doubles as the freshness token: stored + push/pull results are tagged with the version they describe + (``push_version`` / ``pull_version``), and a result is *fresh* + iff its tag has caught up to ``version``. Bumping the version on + didChange therefore invalidates all older results implicitly — + no store-clearing, no timestamps. + + ``push_version``/``pull_version`` start at -1 = "no data yet". + Servers that echo a document version in publishDiagnostics get + exact tagging; those that don't are credited with the current + version at receipt time (a push observed after we sent the + change describes the changed content or newer). + """ + + version: int = 0 + text: str = "" + push: List[Dict[str, Any]] = field(default_factory=list) + pull: List[Dict[str, Any]] = field(default_factory=list) + push_version: int = -1 + pull_version: int = -1 + seed_seen: bool = False + + def fresh_push(self, version: Optional[int] = None) -> bool: + return self.push_version >= (self.version if version is None else version) + + def fresh_pull(self, version: Optional[int] = None) -> bool: + return self.pull_version >= (self.version if version is None else version) + + class LSPClient: """Async LSP client tied to one server process and one workspace root. @@ -186,18 +227,10 @@ class LSPClient: # is silently dropped by default. } - # Tracked file state — required for didChange version bumps. - self._files: Dict[str, Dict[str, Any]] = {} - # Diagnostic stores, keyed by file path (NOT URI). - self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {} - self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {} - # Per-path "last published" time so wait-for-fresh logic works. - self._published: Dict[str, float] = {} - # Per-path version of the latest push (matches our didChange - # version when the server respects it). - self._published_version: Dict[str, int] = {} - # First-push seen flag, for typescript-style seed-on-first-push. - self._first_push_seen: Set[str] = set() + # Per-document state (version, text, diagnostic stores, and + # their freshness tags), keyed by absolute file path (NOT URI). + # See _DocState for the version-based freshness model. + self._docs: Dict[str, _DocState] = {} # Capability registrations — only diagnostic ones are tracked. self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {} @@ -647,25 +680,25 @@ class LSPClient: if not isinstance(diagnostics, list): diagnostics = [] version = params.get("version") - loop_time = asyncio.get_event_loop().time() - if self._seed_first_push and path not in self._first_push_seen: - # First push: seed without firing the event so a waiter - # doesn't resolve on the very first push (which arrives - # before the user-triggered didChange could've produced - # fresh diagnostics). - self._first_push_seen.add(path) - self._push_diagnostics[path] = diagnostics - self._published[path] = loop_time - if isinstance(version, int): - self._published_version[path] = version + doc = self._docs.setdefault(path, _DocState(version=-1)) + if self._seed_first_push and not doc.seed_seen: + # First push: seed the store WITHOUT a freshness tag. It + # arrives before the user-triggered didChange could've + # produced fresh diagnostics, so it must never satisfy a + # waiter — it's baseline data only. + doc.seed_seen = True + doc.push = diagnostics return - self._push_diagnostics[path] = diagnostics - self._published[path] = loop_time - if isinstance(version, int): - self._published_version[path] = version - self._first_push_seen.add(path) + doc.seed_seen = True + doc.push = diagnostics + # Tag with the echoed document version when the server provides + # one; otherwise credit the current version — a push observed + # after we sent the change describes the changed content (or + # newer). Note doc.version is -1 for never-opened paths + # (e.g. relatedDocuments spillover), keeping them unfresh. + doc.push_version = version if isinstance(version, int) else doc.version # Bump the monotonic push counter and wake every waiter. We # keep the Event sticky-set so any wait already in progress # resolves; waiters re-check their predicate after waking and @@ -694,16 +727,16 @@ class LSPClient: raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e uri = file_uri(abs_path) - existing = self._files.get(abs_path) + doc = self._docs.get(abs_path) - if existing is not None: + if doc is not None and doc.version >= 0: # Re-open: bump version, fire didChangeWatchedFiles + didChange. await self._send_notification( "workspace/didChangeWatchedFiles", {"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED ) - new_version = existing["version"] + 1 - old_text = existing["text"] + new_version = doc.version + 1 + old_text = doc.text content_changes: List[Dict[str, Any]] if self._sync_kind == 2: content_changes = [ @@ -724,7 +757,11 @@ class LSPClient: "contentChanges": content_changes, }, ) - self._files[abs_path] = {"version": new_version, "text": text} + # Bumping the version is the whole invalidation story: + # every stored result tagged with an older version is now + # stale by definition (see _DocState). + doc.version = new_version + doc.text = text return new_version # First open: didChangeWatchedFiles CREATED + didOpen. @@ -732,12 +769,9 @@ class LSPClient: "workspace/didChangeWatchedFiles", {"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED ) - # Clear any stale push/pull entries — fresh open should start - # from scratch. - self._push_diagnostics.pop(abs_path, None) - self._pull_diagnostics.pop(abs_path, None) - self._published.pop(abs_path, None) - self._published_version.pop(abs_path, None) + # Fresh doc state — anything stashed under this path by a + # pre-open push (relatedDocuments spillover etc.) is discarded. + self._docs[abs_path] = _DocState(version=0, text=text) await self._send_notification( "textDocument/didOpen", { @@ -749,7 +783,6 @@ class LSPClient: } }, ) - self._files[abs_path] = {"version": 0, "text": text} return 0 async def save_file(self, path: str) -> None: @@ -769,12 +802,19 @@ class LSPClient: async def _pull_document_diagnostics(self, path: str) -> None: """Send ``textDocument/diagnostic`` for one file. - Stores results into :attr:`_pull_diagnostics`. Silently - no-ops on errors (server may not support the pull endpoint). + Stores results into the doc's pull store, tagged with the + document version captured at request send time. If a didChange + races past the in-flight request, the version bump makes the + stored result stale automatically — no explicit invalidation. + Silently no-ops on errors (server may not support the pull + endpoint). """ + abs_path = os.path.abspath(path) + doc = self._docs.get(abs_path) + sent_version = doc.version if doc else -1 try: params: Dict[str, Any] = { - "textDocument": {"uri": file_uri(os.path.abspath(path))} + "textDocument": {"uri": file_uri(abs_path)} } result = await self._send_request_with_retry( "textDocument/diagnostic", @@ -788,7 +828,9 @@ class LSPClient: return items = result.get("items") if isinstance(items, list): - self._pull_diagnostics[os.path.abspath(path)] = items + doc = self._docs.setdefault(abs_path, _DocState(version=-1)) + doc.pull = items + doc.pull_version = sent_version related = result.get("relatedDocuments") if isinstance(related, dict): for uri, sub in related.items(): @@ -796,7 +838,11 @@ class LSPClient: continue sub_items = sub.get("items") if isinstance(sub_items, list): - self._pull_diagnostics[uri_to_path(uri)] = sub_items + rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1)) + rel.pull = sub_items + # Same send-anchored tagging: fresh only if that + # doc hasn't changed since the request went out. + rel.pull_version = rel.version async def wait_for_diagnostics( self, @@ -804,22 +850,36 @@ class LSPClient: version: int, *, mode: str = "document", - ) -> None: + timeout: Optional[float] = None, + ) -> bool: """Wait for the server to publish diagnostics for ``path`` at ``version``. ``mode`` is ``"document"`` (5s budget, document pulls) or - ``"full"`` (10s budget, also workspace pulls). Best-effort — - returns silently on timeout. Does NOT throw if the server - doesn't support pull diagnostics; we still get the push side. + ``"full"`` (10s budget, also workspace pulls). ``timeout`` + overrides the mode's default budget when provided — this is + how the user's ``lsp.wait_timeout`` config reaches the wait + loop (slow servers like tsserver on big projects need more + than the 5s default). + + Returns ``True`` when *fresh* diagnostics arrived (a push at + or after our didChange, or a pull answered after it) and + ``False`` on timeout. Callers must treat ``False`` as "no + data", NOT as "no errors" — the diagnostic stores may still + hold stale entries from the previous edit at that point. + Best-effort — never throws if the server doesn't support pull + diagnostics; we still get the push side. """ - budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT + if timeout is not None and timeout > 0: + budget = timeout + else: + budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT deadline = asyncio.get_event_loop().time() + budget abs_path = os.path.abspath(path) while True: remaining = deadline - asyncio.get_event_loop().time() if remaining <= 0: - return + return False # Concurrent: document pull + push wait. pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path)) @@ -838,26 +898,24 @@ class LSPClient: pass # If we got a fresh push for our version, we're done. - current_v = self._published_version.get(abs_path) - if abs_path in self._published and ( - current_v is None or current_v >= version - ): - return + doc = self._docs.get(abs_path) + if doc and doc.fresh_push(version): + return True - # Pull may have populated _pull_diagnostics — that's also - # success. - if abs_path in self._pull_diagnostics: - return + # Pull may have answered for the current version — that's + # also success. + if doc and doc.fresh_pull(version): + return True # Loop until budget runs out. async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None: - """Wait until a publishDiagnostics arrives for ``path`` at ``version``+.""" + """Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+.""" deadline = asyncio.get_event_loop().time() + timeout baseline = self._push_counter while True: - current_v = self._published_version.get(path) - if path in self._published and (current_v is None or current_v >= version): + doc = self._docs.get(path) + if doc and doc.fresh_push(version): # Debounce — wait a tick in case more diagnostics arrive # immediately after. TS often emits in pairs. We # snapshot the counter so we wake on a *new* push, not @@ -888,17 +946,28 @@ class LSPClient: except asyncio.TimeoutError: continue - def diagnostics_for(self, path: str) -> List[Dict[str, Any]]: + def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]: """Return current merged + deduped diagnostics for one file. Diagnostics from push and pull stores are concatenated and deduplicated by ``(severity, code, message, range)`` content key. Empty list if the server hasn't published anything. + + With ``fresh_only=True``, a store only contributes when its + version tag has caught up to the document's current version — + stale leftovers from the previous edit cycle are excluded. + This is what report paths should use: after an edit, "stale + errors" and "no errors" must not be conflated. """ - abs_path = os.path.abspath(path) - push = self._push_diagnostics.get(abs_path) or [] - pull = self._pull_diagnostics.get(abs_path) or [] - return _dedupe(push, pull) + doc = self._docs.get(os.path.abspath(path)) + if doc is None: + return [] + if fresh_only: + return _dedupe( + doc.push if doc.fresh_push() else [], + doc.pull if doc.fresh_pull() else [], + ) + return _dedupe(doc.push, doc.pull) def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]: diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index aebb4881c96e..d3b4244790b1 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -292,7 +292,10 @@ class LSPService: if not self.enabled_for(file_path): return try: - diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0) + # Outer join budget must exceed the inner wait budget or a + # slow-but-alive server gets falsely marked broken. + t = max(8.0, self._wait_timeout + 3.0) + diags = self._loop.run(self._snapshot_async(file_path), timeout=t) self._delta_baseline[os.path.abspath(file_path)] = diags or [] except Exception as e: # noqa: BLE001 logger.debug("baseline snapshot failed for %s: %s", file_path, e) @@ -341,7 +344,7 @@ class LSPService: try: t = timeout if timeout is not None else self._wait_timeout + 2.0 - diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or [] + diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) except asyncio.TimeoutError as e: eventlog.log_timeout(server_id, file_path) logger.debug("LSP diagnostics timeout for %s: %s", file_path, e) @@ -353,6 +356,17 @@ class LSPService: self._mark_broken_for_file(file_path, e) return [] + if diags is None: + # The server is alive but never produced diagnostics for the + # post-edit content within the wait budget (common for + # tsserver on large projects). Report "no data" rather than + # whatever stale state is in the stores — surfacing the + # previous edit's errors as if they were current is the + # ghost-diagnostics bug. The server is NOT marked broken: + # slow is not dead, and the next edit may well succeed. + eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics") + return [] + abs_path = os.path.abspath(file_path) if delta: baseline = self._delta_baseline.get(abs_path) or [] @@ -452,26 +466,43 @@ class LSPService: return [] try: version = await client.open_file(file_path, language_id=language_id_for(file_path)) - await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode) + fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode) except Exception as e: # noqa: BLE001 logger.debug("snapshot open/wait failed: %s", e) return [] self._last_used[(client.server_id, client.workspace_root)] = time.time() - return list(client.diagnostics_for(file_path)) + if not fresh: + # No fresh data for the pre-edit content — an empty baseline + # is safe: worst case the delta filter removes less, never + # more. Never seed the baseline from stale stores. + return [] + return list(client.diagnostics_for(file_path, fresh_only=True)) - async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]: + async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]: + """Open + wait for FRESH diagnostics. + + Returns the fresh diagnostic list, or ``None`` when the server + never produced post-change data within the wait budget. The + distinction matters: ``[]`` means "server checked the new + content, it's clean", ``None`` means "no verdict" — the caller + must not substitute stale data for either. + """ client = await self._get_or_spawn(file_path) if client is None: - return [] + return None try: version = await client.open_file(file_path, language_id=language_id_for(file_path)) await client.save_file(file_path) - await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode) + fresh = await client.wait_for_diagnostics( + file_path, version, mode=self._wait_mode, timeout=self._wait_timeout + ) except Exception as e: # noqa: BLE001 logger.debug("open/wait failed for %s: %s", file_path, e) - return [] + return None self._last_used[(client.server_id, client.workspace_root)] = time.time() - return list(client.diagnostics_for(file_path)) + if not fresh: + return None + return list(client.diagnostics_for(file_path, fresh_only=True)) async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]: ws, gated = resolve_workspace_for_file(file_path) @@ -482,7 +513,7 @@ class LSPService: client = self._clients.get((srv.server_id, ws)) if client is None: return [] - return list(client.diagnostics_for(file_path)) + return list(client.diagnostics_for(file_path, fresh_only=True)) async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]: srv = find_server_for_file(file_path) diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index a7629c3f5dcb..3f1708ceb0ae 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -15,6 +15,9 @@ and MoonshotAI/kimi-cli#1595: 2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not the parent. Presence of both causes "type should be defined in anyOf items instead of the parent schema". +3. Every object schema must carry a ``required`` array, even an empty one. + Standard JSON Schema allows omitting it; Moonshot 400s with + "required must be an array". The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it @@ -130,9 +133,32 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any: else: repaired.pop("enum") + # Rule 4: object schemas must carry a `required` array, even when empty. + if repaired.get("type") == "object": + repaired = _ensure_required_array(repaired) + return repaired +def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]: + """Guarantee an object schema carries a ``required`` array (Moonshot rule). + + Standard JSON Schema lets you omit ``required`` when nothing is required; + Moonshot 400s on that ("required must be an array"). Ensure the key is a + list. When ``properties`` is known, prune ``required`` entries that don't + name a real property — defensive against dangling names, which Moonshot + also rejects. Mutates and returns ``node``. + """ + props = node.get("properties") + req = node.get("required") + if isinstance(req, list): + if isinstance(props, dict): + node["required"] = [r for r in req if r in props] + else: + node["required"] = [] + return node + + def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]: """Infer a reasonable ``type`` if this schema node has none.""" node_type = node.get("type") @@ -174,17 +200,18 @@ def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]: applied. Input is not mutated. """ if not isinstance(parameters, dict): - return {"type": "object", "properties": {}} + return {"type": "object", "properties": {}, "required": []} repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True) if not isinstance(repaired, dict): - return {"type": "object", "properties": {}} + return {"type": "object", "properties": {}, "required": []} # Top-level must be an object schema if repaired.get("type") != "object": repaired["type"] = "object" if "properties" not in repaired: repaired["properties"] = {} + _ensure_required_array(repaired) return repaired diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 25d900341f49..4ae71cbb3a37 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -805,8 +805,19 @@ PLATFORM_HINTS = { ), "matrix": ( "You are in a Matrix room communicating with your user. " - "Matrix renders Markdown — bold, italic, code blocks, and links work; " - "the adapter converts your Markdown to HTML for rich display. " + "The adapter converts your Markdown to HTML for rich display — bold, " + "italic, inline code, fenced code blocks, headings, bullet and " + "numbered lists, blockquotes, and links all render.\n\n" + "Do NOT use Markdown tables: many popular Matrix clients (Element X, " + "Beeper, most mobile apps) do not render HTML tables, so the cells " + "collapse into one continuous run of text. Present tabular data as " + "labeled '**Label:** value' lines or bullet lists instead.\n\n" + "Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes " + "(- [ ] / - [x]) — they are not converted and appear as literal " + "characters.\n\n" + "LINKS: prefer [descriptive link text](url) over bare URLs. When " + "referencing something with an associated URL (events, sources, " + "people), make the name a clickable link.\n\n" "You can send media files natively: include MEDIA:/absolute/path/to/file " "in your response. Images (.jpg, .png, .webp) are sent as inline photos, " "audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, " diff --git a/agent/tool_executor.py b/agent/tool_executor.py index f740fedb79c6..d235de36c03d 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -53,6 +53,28 @@ from tools.budget_config import BudgetConfig, DEFAULT_BUDGET, budget_for_context logger = logging.getLogger(__name__) +def _ensure_file_checkpoint( + agent, + function_name: str, + function_args: dict, + effective_task_id: str, +) -> None: + """Checkpoint the same workspace path that the file tool will mutate.""" + file_path = function_args.get("path", "") + if not file_path: + return + + # File tools resolve relative paths against the task's live/session cwd, + # which can differ from the Hermes process cwd (notably in Docker). Resolve + # through that same path pipeline before asking the checkpoint manager to + # discover the project root. + from tools.file_tools import _resolve_path_for_task + + resolved_path = _resolve_path_for_task(file_path, effective_task_id or "default") + work_dir = agent._checkpoint_mgr.get_working_dir_for_path(str(resolved_path)) + agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}") + + def _budget_for_agent(agent) -> BudgetConfig: """Resolve a tool-result BudgetConfig scaled to the agent's context window. @@ -502,10 +524,12 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # Checkpoint for file-mutating tools if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled: try: - file_path = function_args.get("path", "") - if file_path: - work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path) - agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}") + _ensure_file_checkpoint( + agent, + function_name, + function_args, + effective_task_id, + ) except Exception: pass @@ -1188,12 +1212,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # Checkpoint: snapshot working dir before file-mutating tools if not _execution_blocked and function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled: try: - file_path = function_args.get("path", "") - if file_path: - work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path) - agent._checkpoint_mgr.ensure_checkpoint( - work_dir, f"before {function_name}" - ) + _ensure_file_checkpoint( + agent, + function_name, + function_args, + effective_task_id, + ) except Exception: pass # never block tool execution diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index ca3a3d44d054..6908c2a42343 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -125,9 +125,11 @@ normalization alike. Learn the shape, not a snapshot of the current rungs. Two auth-flavored corollaries worth naming because they are easy to get wrong: - **One-time credentials are never reused.** An OAuth gateway connection mints a - fresh WebSocket ticket on every dial; a mint failure means reauthentication, - not "fall back to the cached URL." Only long-lived token/local auth may reuse - a cached URL as a lower rung. + fresh WebSocket ticket on every dial and never falls back to the cached URL. + Only a confirmed 401/403 (or an explicitly tagged auth rejection) means + reauthentication; timeout, network, malformed-response, and server failures + remain connectivity errors. Only long-lived token/local auth may reuse a + cached URL as a lower rung. - **A connection test must exercise the leg you'll actually use.** An HTTP status probe passing while the WebSocket/auth leg fails is a false positive that ships as "it said connected but nothing works." diff --git a/apps/desktop/e2e/boot-failure.spec.ts b/apps/desktop/e2e/boot-failure.spec.ts new file mode 100644 index 000000000000..a94373ce84a9 --- /dev/null +++ b/apps/desktop/e2e/boot-failure.spec.ts @@ -0,0 +1,47 @@ +/** + * E2E boot-failure tests — verify the app shows an error overlay when the + * backend can't start. + * + * Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend + * resolution fails with a controlled error message. The app should show the + * BootFailureOverlay with retry/repair actions. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { test } from '@playwright/test' + +import { + type DeadBackendFixture, + setupDeadBackend, + waitForBootFailure, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: DeadBackendFixture | null = null + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('boot failure with dead backend', () => { + test('app shows error state', async () => { + // Inject a fake boot error so the backend resolution "fails" with a + // controlled error message. This is the only reliable way to trigger + // BootFailureOverlay in dev mode. + fixture = await setupDeadBackend({ fakeError: true }) + + await waitForBootFailure(fixture.page, 90_000) + }) + + test('screenshot of error state', async () => { + if (!fixture) { + test.skip(true, 'Previous test failed — no app running') + + return + } + + await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app }) + }) +}) diff --git a/apps/desktop/e2e/boot.spec.ts b/apps/desktop/e2e/boot.spec.ts new file mode 100644 index 000000000000..0fd73d845117 --- /dev/null +++ b/apps/desktop/e2e/boot.spec.ts @@ -0,0 +1,63 @@ +/** + * E2E smoke tests for the dev-mode desktop app. + * + * These tests launch the Electron app from the built dist/ (not the + * packaged binary) with a real `hermes serve` backend pointed at a mock + * inference server. The full chain is exercised: + * + * electron → hermes serve (python) → mock provider → renderer + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + * Run from the nix devshell: + * npm exec playwright test e2e/boot.spec.ts --reporter=list + */ +import { expect, test } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('dev-mode boot with mock backend', () => { + test('window opens with Hermes title', async () => { + const title = await fixture!.page.title() + expect(title).toContain('Hermes') + }) + + test('renderer mounts and shows DOM content', async () => { + const page = fixture!.page + // Wait for the React root to mount. The app renders into #root + // (see src/main.tsx), but content may arrive through portals — so + // check the body for any interactive content instead. + await page.waitForSelector('body', { state: 'attached' }) + // Wait for the main app shell — the composer is always present. + await page.waitForSelector('textarea, [contenteditable="true"]', { + state: 'attached', + timeout: 30_000, + }) + }) + + test('backend boots and app becomes ready', async () => { + // This is the big one — wait for the full boot chain to complete: + // electron starts → hermes serve is spawned → WS connects → config + // loaded → sessions loaded → boot overlay dismissed → composer visible. + await waitForAppReady(fixture!, 120_000) + }) + + test('screenshot after boot', async () => { + await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app }) + }) +}) diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts new file mode 100644 index 000000000000..0d12003df81b --- /dev/null +++ b/apps/desktop/e2e/chat.spec.ts @@ -0,0 +1,91 @@ +/** + * E2E chat tests — send a message and verify a response appears. + * + * Requires the full boot chain to complete (hermes serve + mock inference + * provider). The mock server returns a canned reply, so we verify the + * response text shows up in the chat transcript. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { test } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('chat interaction with mock backend', () => { + test('send a message and receive a response', async () => { + const page = fixture!.page + + // Find the composer — it's a contenteditable textbox. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + + // Click to focus, then type the message character by character. + // Using `type` instead of `fill` because the composer is a + // contenteditable div with custom keydown handling that tracks + // IME composition state — `fill` bypasses the event chain. + await composer.click() + await composer.type('Hello, can you hear me?', { delay: 20 }) + + // Submit with Enter — the composer's keydown handler intercepts + // plain Enter (without Shift) and calls submitDraft(). + await page.keyboard.press('Enter') + + // Wait for the user's message to appear in the transcript. + // The message renders as an assistant-ui message in the chat view. + await page.waitForFunction( + () => { + const body = document.body + + if (!body) { + return false + } + + return (body.textContent ?? '').includes('Hello, can you hear me?') + }, + undefined, + { timeout: 15_000 }, + ) + + // Wait for the mock response to appear. The canned reply is: + // "Hello from the mock inference server! The full boot chain is working." + // Give it a generous timeout — the inference request goes through the + // gateway → hermes serve → mock server → streaming SSE back. + await page.waitForFunction( + () => { + const body = document.body + + if (!body) { + return false + } + + const text = body.textContent ?? '' + + return text.includes('mock inference server') || text.includes('boot chain is working') + }, + undefined, + { timeout: 60_000 }, + ) + }) + + test('screenshot of chat with messages', async () => { + await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app }) + }) +}) diff --git a/apps/desktop/e2e/fix-electron-tracing.ts b/apps/desktop/e2e/fix-electron-tracing.ts new file mode 100644 index 000000000000..1c2a9825dad5 --- /dev/null +++ b/apps/desktop/e2e/fix-electron-tracing.ts @@ -0,0 +1,72 @@ +/** + * Monkey-patch: playwright's test runner never calls tracing.start() on + * Electron's internal BrowserContext because: + * 1. Playwright._allContexts() only returns [chromium, firefox, webkit] + * contexts — Electron's context is excluded. + * 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before + * beforeAll launches the electron app. + * 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron + * class (only on BrowserType). + * + * As a result, trace screenshots (screencast) and DOM snapshots are never + * captured for electron tests. + * + * This patch: + * 1. Patches _allContexts() to include electron contexts, so the test + * runner's didFinishTest() cleanup calls _stopTracing() → stopChunk() + * on the electron context (saving the trace chunk + merging it into + * the final trace.zip). + * 2. Manually calls tracing.start() + startChunk() after launch. + * 3. Wraps tracing.start to become startChunk after the first call, + * so the test runner's willStartTest doesn't throw "already started". + * + * Imported from playwright.config.ts so it runs before any test. + * + * Pinned dependency: this file reaches into Playwright internals (_playwright, + * _allContexts, _context) that have no public contract. @playwright/test is + * pinned exact (=1.58.2 in package.json) so a bump can't silently break the + * monkeypatch. When bumping, re-verify these private symbols still exist on + * the Electron / PlaywrightInternal classes and that tracing still merges. + */ + +import { _electron as electron, type BrowserContext } from '@playwright/test' +import * as crypto from 'node:crypto' + +const electronContexts = new Set() +const originalLaunch = electron.launch.bind(electron) + +electron.launch = async (options: any) => { + const app = await originalLaunch(options) + const ctx = (app as any)._context as BrowserContext + electronContexts.add(ctx) + ctx.once('close', () => electronContexts.delete(ctx)) + + // Patch _allContexts so the test runner sees the electron context + // (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip). + const pw = (electron as any)._playwright as any + if (pw && !pw.__electronTracingPatched) { + pw.__electronTracingPatched = true + const original = pw._allContexts.bind(pw) + pw._allContexts = () => [...original(), ...electronContexts] + } + + // Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext. + const traceName = crypto.randomUUID() + await ctx.tracing.start({ + screenshots: true, + snapshots: true, + sources: true, + }).catch(() => {}) + await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {}) + + // Wrap tracing.start to redirect to startChunk after the first call. + // The test runner's willStartTest calls tracing.start() on all contexts + // in _allContexts(). Since we already started, redirect to startChunk + // to avoid "Tracing has been already started" errors. + const tracing = ctx.tracing as any + tracing.start = async (opts: any) => { + return tracing.startChunk(opts) + } + + return app +} diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts new file mode 100644 index 000000000000..942e89dbe5b0 --- /dev/null +++ b/apps/desktop/e2e/fixtures.ts @@ -0,0 +1,674 @@ +/** + * Shared E2E fixtures for the Hermes desktop Playwright suite. + * + * Two fixture modes: + * + * 1. `mockBackend` — starts a mock inference server, writes a config.yaml + * that points at it, and launches the desktop app so the full chain + * (electron → hermes serve → provider → inference → renderer) is + * exercised with a real backend but a fake LLM. + * + * 2. `noProvider` — launches the app with an empty config (no provider + * configured). The onboarding overlay should appear. Used to test the + * first-run flow without real credentials. + * + * Both modes launch the *dev* Electron app (`electron .` against the built + * `dist/`), not the packaged binary. This avoids the multi-minute + * `electron-builder --dir` step and matches `hermes desktop --source`. The + * packaged-binary path is already covered by `launch.spec.ts`. + * + * Prerequisite: `npm run build` must have been run so that `dist/` exists. + */ + +import { spawnSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as os from 'node:os' +import * as path from 'node:path' + +import { _electron, type ElectronApplication, type Page } from '@playwright/test' + +import { startMockServer } from './mock-server' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') +const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release') + +// ─── Credential stripping (matches launch.spec.ts) ────────────────────── + +const CREDENTIAL_SUFFIXES: string[] = [ + '_API_KEY', + '_TOKEN', + '_SECRET', + '_PASSWORD', + '_CREDENTIALS', + '_ACCESS_KEY', + '_PRIVATE_KEY', + '_OAUTH_TOKEN', +] + +const CREDENTIAL_NAMES = new Set([ + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_TOKEN', + 'AWS_ACCESS_KEY_ID', + 'AWS_SECRET_ACCESS_KEY', + 'AWS_SESSION_TOKEN', + 'CUSTOM_API_KEY', + 'GEMINI_BASE_URL', + 'OPENAI_BASE_URL', + 'OPENROUTER_BASE_URL', + 'OLLAMA_BASE_URL', + 'GROQ_BASE_URL', + 'XAI_BASE_URL', +]) + +function isCredentialEnvVar(name: string): boolean { + if (CREDENTIAL_NAMES.has(name)) { + return true + } + + return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix)) +} + +function stripCredentials(env: Record): Record { + const clean: Record = {} + + for (const [key, value] of Object.entries(env)) { + if (!value) { + continue + } + + if (isCredentialEnvVar(key)) { + continue + } + + clean[key] = value + } + + return clean +} + +// ─── Sandbox creation ────────────────────────────────────────────────── + +export interface Sandbox { + root: string + hermesHome: string + userDataDir: string + cleanup: () => void +} + +function createSandbox(prefix: string): Sandbox { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`)) + const hermesHome = path.join(root, 'hermes-home') + const userDataDir = path.join(root, 'electron-user-data') + + fs.mkdirSync(hermesHome, { recursive: true }) + fs.mkdirSync(userDataDir, { recursive: true }) + + // Write a fixed window-state.json so the Electron window opens at a + // consistent size — helps with visual regression screenshots. The + // exact size is also enforced right before each screenshot (see + // expectVisualSnapshot in visual-snapshot.ts) because window managers + // may resize after launch. + fs.writeFileSync( + path.join(userDataDir, 'window-state.json'), + JSON.stringify( + { x: 0, y: 0, width: 1220, height: 800, isMaximized: false }, + null, + 2, + ), + 'utf8', + ) + + return { + root, + hermesHome, + userDataDir, + cleanup: () => { + try { + fs.rmSync(root, { recursive: true, force: true }) + } catch { + // best-effort + } + }, + } +} + +// ─── Config writing ───────────────────────────────────────────────────── + +/** + * Write a config.yaml that pre-configures a mock provider pointing at the + * mock inference server. The provider is set as the active model provider so + * the desktop app skips onboarding and boots straight to the chat UI. + */ +function writeMockProviderConfig(hermesHome: string, mockUrl: string): void { + const configPath = path.join(hermesHome, 'config.yaml') + + const config = `# Auto-generated by E2E test fixtures +model: + default: mock-model + provider: mock +providers: + mock: + api: ${mockUrl}/v1 + name: Mock + api_mode: chat_completions + key_env: MOCK_API_KEY + models: + mock-model: {} + context_length: 4096 +` + + fs.writeFileSync(configPath, config, 'utf8') +} + +/** + * Write a minimal .env with the mock API key. The key_env in config.yaml + * references MOCK_API_KEY, so the backend resolves credentials from here. + */ +function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void { + const envPath = path.join(hermesHome, '.env') + fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8') +} + +/** + * Write an empty config (no providers). The desktop app should show the + * onboarding overlay because no inference provider is configured. + */ +function writeEmptyConfig(hermesHome: string): void { + const configPath = path.join(hermesHome, 'config.yaml') + fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8') +} + +// ─── Env building ────────────────────────────────────────────────────── + +/** + * Build the environment for the Electron app process. + * + * Key env vars: + * - HERMES_HOME → sandbox hermes-home (isolated config/sessions) + * - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data + * - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH + * (we want the dev checkout at REPO_ROOT) + * - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution) + * - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock) + * - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux + */ +function buildAppEnv(sandbox: Sandbox, extra: Record = {}): Record { + const clean = stripCredentials(process.env) + + // XDG_RUNTIME_DIR is needed for Electron on Linux when running in a + // headless/CI context — without it the zygote may fail to initialize. + if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) { + clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR + } + + // DISPLAY — needed for Electron to open a window. + if (!clean.DISPLAY && process.env.DISPLAY) { + clean.DISPLAY = process.env.DISPLAY + } + + return { + ...clean, + HERMES_HOME: sandbox.hermesHome, + HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir, + HERMES_DESKTOP_IGNORE_EXISTING: '1', + HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT, + HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`, + // Clear dev-server override — we want the built dist/, not a vite server. + // The dev-server check in main.ts looks for this env var; if it's set, + // it loads from the vite URL instead of the local file. + ...extra, + } +} + +// ─── Electron launch ──────────────────────────────────────────────────── + +/** + * Verify that the desktop app has been built (dist/ exists). Playwright + * tests can't run without it — the Electron main process loads + * dist/electron-main.mjs and the renderer loads dist/index.html. + */ +function assertDistBuilt(): void { + const distDir = path.join(DESKTOP_ROOT, 'dist') + const electronMain = path.join(distDir, 'electron-main.mjs') + const indexHtml = path.join(distDir, 'index.html') + + if (!fs.existsSync(electronMain)) { + throw new Error( + `Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` + + `Missing: ${electronMain}`, + ) + } + + if (!fs.existsSync(indexHtml)) { + throw new Error( + `Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` + + `Missing: ${indexHtml}`, + ) + } +} + +/** + * Find the Electron binary. In the nix devshell, `electron` is on PATH. + * As a fallback, use the node_modules/.bin/electron from the desktop package. + */ +function findElectron(): string { + // In dev mode, we use the `electron` binary directly (not the packaged app). + // The dev:electron script in package.json does exactly this: `electron .` + // after building. We replicate that here. + const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron') + + if (fs.existsSync(localElectron)) { + return localElectron + } + + // Fall back to PATH + const result = spawnSync('which', ['electron'], { + encoding: 'utf8', + }) + + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim() + } + + throw new Error( + 'Electron binary not found. Run "npm install" from the repo root to install devDependencies.', + ) +} + +/** + * Launch the desktop app in dev mode. + * + * @param sandbox - isolated HERMES_HOME + userData + * @param env - the process environment (already has HERMES_HOME etc.) + * @returns the ElectronApplication + first Page + */ +async function launchDesktop( + env: Record, +): Promise<{ app: ElectronApplication; page: Page }> { + assertDistBuilt() + + const electronBin = findElectron() + + // `electron .` loads from the package.json `main` field + // (dist/electron-main.mjs after build). + const app = await _electron.launch({ + executablePath: electronBin, + args: [ + DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir + '--disable-gpu', + '--no-sandbox', + ], + env, + cwd: DESKTOP_ROOT, + }) + + const page = await app.firstWindow() + + return { app, page } +} + +// ─── Public fixtures ──────────────────────────────────────────────────── + +export interface MockBackendFixture { + app: ElectronApplication + page: Page + mockUrl: string + sandbox: Sandbox + cleanup: () => Promise +} + +/** + * Set up a full mock-backend E2E environment: + * 1. Start the mock inference server + * 2. Create a sandbox with config.yaml pointing at it + * 3. Launch the desktop app + * 4. Return handles for test interaction + */ +export async function setupMockBackend(): Promise { + // 1. Start mock server + const mock = await startMockServer() + + // 2. Create sandbox + write config + const sandbox = createSandbox('mock') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + + // 3. Build env + launch + const env = buildAppEnv(sandbox) + const { app, page } = await launchDesktop(env) + + return { + app, + page, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +export interface NoProviderFixture { + app: ElectronApplication + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +/** + * Launch the app with no provider configured. The onboarding overlay should + * appear because there's no inference provider in config.yaml. + */ +export async function setupNoProvider(): Promise { + const sandbox = createSandbox('noprovider') + writeEmptyConfig(sandbox.hermesHome) + + const env = buildAppEnv(sandbox) + const { app, page } = await launchDesktop(env) + + return { + app, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + sandbox.cleanup() + }, + } +} + +export interface DeadBackendFixture { + app: ElectronApplication + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +export interface DeadBackendOptions { + /** + * When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR + * so the backend resolution itself "fails" with a controlled error message. + * This is the only reliable way to trigger BootFailureOverlay in dev mode + * (the real backend always resolves via SOURCE_REPO_ROOT). + */ + fakeError?: boolean +} + +/** + * Launch the app with a provider pointing at a dead endpoint (port 1, which + * nothing listens on). By default the backend still boots (`hermes serve` + * starts fine — the dead endpoint only matters at chat time). Pass + * `{ fakeError: true }` to inject a fake boot failure, triggering the + * BootFailureOverlay. + */ +export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise { + const sandbox = createSandbox('dead') + const configPath = path.join(sandbox.hermesHome, 'config.yaml') + fs.writeFileSync( + configPath, + `# Auto-generated by E2E test fixtures — dead provider +model: + default: mock-model + provider: mock +providers: + mock: + api: http://127.0.0.1:1/v1 + name: Mock + api_mode: chat_completions + key_env: MOCK_API_KEY + models: + mock-model: {} + context_length: 4096 +`, + 'utf8', + ) + writeEnvFile(sandbox.hermesHome) + + const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {}) + const { app, page } = await launchDesktop(env) + + return { + app, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + sandbox.cleanup() + }, + } +} + +// ─── Packaged-binary fixture ─────────────────────────────────────────── + +/** + * Resolve the packaged Electron binary path, per-platform, matching + * electron-builder's output layout under release/. + */ +function resolvePackagedBinaryPath(): string { + if (process.platform === 'win32') { + return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe') + } + + if (process.platform === 'darwin') { + const arch = process.arch === 'arm64' ? 'arm64' : 'x64' + + return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes') + } + + return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes') +} + +export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath() + +export function packagedBinaryExists(): boolean { + return fs.existsSync(PACKAGED_BINARY_PATH) +} + +export interface PackagedAppFixture { + app: ElectronApplication + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +/** + * Launch the *packaged* Electron binary (from `npm run pack` → + * `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot + * progress without spawning a real Hermes backend. + * + * Uses the same sandbox isolation (credential stripping, isolated + * HERMES_HOME + userData, unique app name) as the dev-mode fixtures. + * + * Skips if the packaged binary doesn't exist — run `npm run pack` first. + */ +export async function setupPackagedApp(): Promise { + if (!packagedBinaryExists()) { + throw new Error( + `Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`, + ) + } + + const sandbox = createSandbox('packaged') + + // Build the sandbox env using the shared helpers, then add the + // packaged-binary-specific overrides. + const env = buildAppEnv(sandbox, { + // Fake boot: simulates progress steps without spawning the real backend. + HERMES_DESKTOP_BOOT_FAKE: '1', + HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120', + }) + + // Clear dev-server + hermes-root overrides — the packaged binary + // should use its own bundled renderer, not the dev checkout. + delete (env as Record).HERMES_DESKTOP_DEV_SERVER + delete (env as Record).HERMES_DESKTOP_HERMES + delete (env as Record).HERMES_DESKTOP_HERMES_ROOT + + const app = await _electron.launch({ + executablePath: PACKAGED_BINARY_PATH, + args: ['--disable-gpu', '--no-sandbox'], + env, + }) + + const page = await app.firstWindow() + + return { + app, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + sandbox.cleanup() + }, + } +} + +// ─── Wait helpers ────────────────────────────────────────────────────── + +/** + * Wait for the desktop app to finish booting and show the main chat UI. + * + * The boot overlay disappears when `completeDesktopBoot()` fires in the + * renderer — at that point the gateway is open, config is loaded, and + * sessions are loaded. We detect this by waiting for the boot/connecting + * overlay to become invisible and the main app shell to be present. + * + * Two things must both be true before we return: + * 1. The composer (chat input) is visible — it's disabled until the + * gateway is open. + * 2. No full-screen overlay (onboarding Preparing, connecting overlay, + * boot-failure) covers the viewport center. The composer can be + * "visible" in Playwright's eyes (non-zero bounding box, not + * display:none) even when a z-1300+ overlay is painted on top of it, + * so checking the composer alone catches the app mid-boot at ~92% + * with the loading bar still showing. + */ +export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise { + const { page, app } = fixture + + // Wait for the composer to exist in the DOM (not necessarily interactive yet). + await page.waitForSelector('textarea, [contenteditable="true"]', { + state: 'attached', + timeout: timeoutMs, + }) + + // Now poll until no full-screen overlay covers the viewport center. + // elementFromPoint returns the topmost element at a point — if it's part + // of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the + // app isn't ready yet. + await page.waitForFunction( + () => { + const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2) + + if (!el) { + return false + } + + // Walk up to the nearest positioned ancestor — overlays are + // `position: fixed; inset: 0`. If the hit element or an ancestor + // is a full-viewport fixed overlay, we're still covered. + let node: Element | null = el + while (node) { + const cs = window.getComputedStyle(node) + + if (cs.position === 'fixed') { + const rect = node.getBoundingClientRect() + + if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) { + return false + } + } + + node = node.parentElement + } + + return true + }, + undefined, + { timeout: timeoutMs }, + ) + + // On Electron 40.x, ready-to-show may never fire (electron/electron#51972) + // and the window stays hidden even though the DOM is rendered. The main + // process has a TEST_WORKER_INDEX-gated fallback that force-shows the + // window, but the DOM can be ready before that fires. Poll until the + // window is actually visible so interactions (click, screenshot) don't + // hit a hidden surface. + if (app) { + const deadline = Date.now() + timeoutMs + + while (Date.now() < deadline) { + const visible = await app.evaluate(({ BrowserWindow }) => { + const w = BrowserWindow.getAllWindows()[0] + + return w ? w.isVisible() : false + }).catch(() => false) + + if (visible) {break} + await page.waitForTimeout(500) + } + } +} + +/** + * Wait for the onboarding overlay to appear (no provider configured). + */ +export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise { + // The onboarding overlay contains a heading with "Choose your provider" + // or similar text. We look for any text that indicates the picker. + await page.waitForFunction( + () => { + const root = document.getElementById('root') + + if (!root) { + return false + } + + const text = root.textContent ?? '' + + return ( + text.includes('provider') || + text.includes('Provider') || + text.includes('Choose') || + text.includes('API key') || + text.includes('Sign in') + ) + }, + undefined, + { timeout: timeoutMs }, + ) +} + +/** + * Wait for the boot failure overlay to appear. + */ +export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise { + await page.waitForFunction( + () => { + // Boot failure is terminal: the backend gave up. The renderer shows + // either BootFailureOverlay (z-1400, with Retry/Repair buttons) or + // falls back to the onboarding picker (z-1300) as a recovery path. + // We wait for the failure dialog itself — the Preparing component may + // still paint its progress bar (recolored red) underneath the overlay, + // which is harmless. + const text = document.body.textContent ?? '' + + // BootFailureOverlay buttons. + const hasFailureUI = + text.includes('Retry') || + text.includes('Repair') || + text.includes('Use local gateway') || + text.includes('Connection settings') + + // The error toast / notification that fires on failDesktopBoot(). + const hasErrorToast = text.includes('Desktop boot failed') + + return hasFailureUI || hasErrorToast + }, + undefined, + { timeout: timeoutMs }, + ) +} diff --git a/apps/desktop/e2e/launch-packaged-app.spec.ts b/apps/desktop/e2e/launch-packaged-app.spec.ts new file mode 100644 index 000000000000..a404a01cf55a --- /dev/null +++ b/apps/desktop/e2e/launch-packaged-app.spec.ts @@ -0,0 +1,88 @@ +import { expect, test } from '@playwright/test' + +import { + PACKAGED_BINARY_PATH, + type PackagedAppFixture, + packagedBinaryExists, + setupPackagedApp, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +/** + * E2E smoke tests for the packaged Hermes desktop app. + * + * Launches the real packaged Electron binary (produced by `npm run pack` → + * `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation + * (credential stripping, isolated HERMES_HOME + userData, unique app name). + * + * Skips if the packaged binary doesn't exist — run `npm run pack` first. + */ + +let fixture: PackagedAppFixture | null = null + +test.beforeAll(async () => { + test.skip( + !packagedBinaryExists(), + `Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`, + ) + + fixture = await setupPackagedApp() +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('window opens with the Hermes title', async () => { + const title = await fixture!.page.title() + expect(title).toContain('Hermes') +}) + +test('renderer loads and shows DOM content', async () => { + const page = fixture!.page + await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 }) + const childCount = await page.locator('#root > *').count() + expect(childCount).toBeGreaterThan(0) +}) + +test('boot progress overlay fades out or shows error state', async () => { + const page = fixture!.page + await page.waitForFunction( + () => { + const root = document.getElementById('root') + + if (!root) { + return false + } + + const text = root.textContent ?? '' + + // Error path: boot failure overlay renders an error message. + if (text.includes('error') || text.includes('Error') || text.includes('failed')) { + return true + } + + // Success path: overlay disappears and the app renders. If there's + // no "boot" / "starting" / "installing" text visible, boot has + // completed (either to the main UI or to onboarding). + const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing'] + const lower = text.toLowerCase() + + return !bootIndicators.some((word) => lower.includes(word)) + }, + undefined, + { timeout: 60_000 }, + ) +}) + +test('can capture a screenshot for the CI artifact', async () => { + if (!fixture) { + test.skip(true, 'Previous test failed — no app running') + + return + } + + // Visual snapshot — won't fail on diff, just logs + generates diff image + await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app }) +}) diff --git a/apps/desktop/e2e/mock-backend-setup.spec.ts b/apps/desktop/e2e/mock-backend-setup.spec.ts new file mode 100644 index 000000000000..1f068f20203a --- /dev/null +++ b/apps/desktop/e2e/mock-backend-setup.spec.ts @@ -0,0 +1,87 @@ +/** + * E2E tests asserting the mock backend gets the app past the setup/onboarding + * screen. + * + * The mock backend fixture writes a config.yaml with a pre-configured mock + * provider pointing at a mock inference server. When the app boots, the + * runtime readiness check should detect the working provider and dismiss the + * onboarding overlay — landing straight on the chat UI without ever showing + * the "Let's get you setup with Hermes Agent" screen. + * + * If these tests fail, the mock backend config isn't getting the app past + * onboarding — the chat interaction tests (chat.spec.ts) will also fail + * because the composer is blocked by the setup overlay. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('mock backend gets past setup screen', () => { + test('onboarding overlay is not shown', async () => { + const page = fixture!.page + + // The onboarding overlay renders "Let's get you setup with Hermes Agent" + // when the runtime check fails to find a working provider. With the mock + // backend configured, the runtime check should pass and the overlay + // returns null — this text should NOT be present in the DOM. + await page.waitForFunction( + () => { + const text = document.body.textContent ?? '' + + return !text.includes("Let's get you setup") + }, + undefined, + { timeout: 30_000 }, + ) + }) + + test('chat composer is visible', async () => { + const page = fixture!.page + + // The composer (contenteditable div) should be visible and not blocked + // by the onboarding overlay. If the first test passed, the overlay is + // gone and the composer is the primary interactive surface. + const composer = page.locator('[contenteditable="true"]').first() + await expect(composer).toBeVisible() + }) + + test('can type into the composer', async () => { + const page = fixture!.page + + // If the setup overlay is truly gone, the composer accepts input. + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type('hello mock backend', { delay: 20 }) + + // Verify the typed text appears in the DOM. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('hello mock backend'), + undefined, + { timeout: 10_000 }, + ) + }) + + test('screenshot shows chat UI without setup screen', async () => { + await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app }) + }) +}) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts new file mode 100644 index 000000000000..680cba30e7af --- /dev/null +++ b/apps/desktop/e2e/mock-server.ts @@ -0,0 +1,203 @@ +/** + * Minimal OpenAI-compatible mock inference server for E2E tests. + * + * Implements just enough of the /v1/* surface for `hermes serve` to resolve a + * provider, list models, and stream a canned chat completion back to the + * desktop app — without any real LLM. + * + * Endpoints: + * GET /v1/models → { data: [{ id, ... }] } + * POST /v1/chat/completions → streaming (SSE) or non-streaming response + * + * The canned response is a short, deterministic assistant message. Tool-call + * requests are not simulated — the E2E tests only need the chat surface to + * prove the full boot → gateway → inference → renderer chain works. + */ + +import http from 'node:http' + +/** A canned assistant reply used for every chat completion request. */ +const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.' + +/** + * Start the mock server on an ephemeral port. + * + * @returns a handle with `port`, `url`, and `close()`. + */ +export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise }> { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + // CORS headers — the Electron renderer doesn't need them, but they + // don't hurt and make the server usable from a browser context too. + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Headers', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + + if (req.method === 'OPTIONS') { + res.writeHead(204) + res.end() + return + } + + // GET /v1/models — return a single fake model. + if (req.method === 'GET' && req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + object: 'list', + data: [ + { + id: 'mock-model', + object: 'model', + created: 0, + owned_by: 'mock', + }, + ], + }), + ) + return + } + + // POST /v1/chat/completions — return a canned response. + if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) { + let body = '' + + req.on('data', (chunk: Buffer) => { + body += chunk.toString() + }) + + req.on('end', () => { + let parsed: any = {} + + try { + parsed = JSON.parse(body) + } catch { + // malformed JSON — treat as non-streaming with defaults + } + + const stream = parsed.stream === true + const model = parsed.model || 'mock-model' + + if (stream) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + // Send the content in a few chunks to simulate streaming. + const words = CANNED_REPLY.split(' ') + let i = 0 + + const sendChunk = () => { + if (i >= words.length) { + // Final chunk with finish_reason + res.write( + `data: ${JSON.stringify({ + id: 'mock-completion', + object: 'chat.completion.chunk', + created: 0, + model, + choices: [ + { + index: 0, + delta: {}, + finish_reason: 'stop', + }, + ], + })}\n\n`, + ) + res.write('data: [DONE]\n\n') + res.end() + return + } + + const word = i === 0 ? words[i] : ' ' + words[i] + res.write( + `data: ${JSON.stringify({ + id: 'mock-completion', + object: 'chat.completion.chunk', + created: 0, + model, + choices: [ + { + index: 0, + delta: { content: word }, + finish_reason: null, + }, + ], + })}\n\n`, + ) + i++ + // Small delay between chunks to simulate real streaming. + setTimeout(sendChunk, 20) + } + + sendChunk() + } else { + // Non-streaming response + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + id: 'mock-completion', + object: 'chat.completion', + created: 0, + model, + choices: [ + { + index: 0, + message: { role: 'assistant', content: CANNED_REPLY }, + finish_reason: 'stop', + }, + ], + usage: { + prompt_tokens: 10, + completion_tokens: 20, + total_tokens: 30, + }, + }), + ) + } + }) + + req.on('error', () => { + res.writeHead(400) + res.end('Bad request') + }) + return + } + + // Fallback — 404 for anything else + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Not found' })) + }) + + server.on('error', reject) + + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr === null || typeof addr === 'string') { + reject(new Error('Failed to get server address')) + return + } + + const port = addr.port + const url = `http://127.0.0.1:${port}` + + resolve({ + port, + url, + close: () => + new Promise((resolveClose, rejectClose) => { + server.close((err) => { + if (err) { + rejectClose(err) + } else { + resolveClose() + } + }) + }), + }) + }) + }) +} diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts new file mode 100644 index 000000000000..952178a3e063 --- /dev/null +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -0,0 +1,76 @@ +/** + * E2E onboarding tests — verify the provider picker appears when no + * inference provider is configured. + * + * Launches the app with an empty config.yaml (no providers). The renderer + * should detect the unconfigured state and show the DesktopOnboardingOverlay + * with provider options / API key form. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test } from '@playwright/test' + +import { + type NoProviderFixture, + setupNoProvider, + waitForOnboarding, +} from './fixtures' +import { expectVisualSnapshot } from './visual-snapshot' + +let fixture: NoProviderFixture | null = null + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test.describe('onboarding with no provider configured', () => { + test('onboarding overlay appears on first boot', async () => { + fixture = await setupNoProvider() + + // The app should boot (hermes serve starts fine even without a provider), + // but the renderer should show the onboarding overlay because no + // provider is configured. + await waitForOnboarding(fixture.page, 90_000) + }) + + test('onboarding shows provider options or API key form', async () => { + if (!fixture) { + test.skip(true, 'Previous test failed — no app running') + + return + } + + const page = fixture.page + + // The onboarding overlay should contain provider-related text. + // It might show OAuth providers, an API key form, or a "choose later" + // link. Verify at least one of these is visible. + const rootText = await page.evaluate(() => { + const root = document.getElementById('root') + + return root?.textContent ?? '' + }) + + const hasProviderText = + rootText.includes('provider') || + rootText.includes('Provider') || + rootText.includes('API key') || + rootText.includes('Sign in') || + rootText.includes('OpenRouter') || + rootText.includes('OpenAI') + + expect(hasProviderText).toBe(true) + }) + + test('screenshot of onboarding overlay', async () => { + if (!fixture) { + test.skip(true, 'Previous test failed — no app running') + + return + } + + await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app }) + }) +}) diff --git a/apps/desktop/e2e/visual-snapshot.ts b/apps/desktop/e2e/visual-snapshot.ts new file mode 100644 index 000000000000..df1bdc9d7e4e --- /dev/null +++ b/apps/desktop/e2e/visual-snapshot.ts @@ -0,0 +1,150 @@ +/** + * Visual snapshot helper — wraps `toHaveScreenshot` so visual diffs are + * reported without failing the test suite. + * + * On CI, the JSON reporter + post-test script parse the results and post a + * summary to the GitHub Actions step output, and diff images are uploaded + * as artifacts. This keeps visual regressions visible without gating PRs + * on pixel-perfect matches. + * + * The actual screenshot is always written to the test output dir so CI + * artifacts include every screenshot — not just the ones that diffed. + * When it differs, this helper also writes expected and diff images: + * -actual.png, -expected.png, -diff.png + */ +import fs from 'node:fs' +import path from 'node:path' + +import { type ElectronApplication, type Page, test } from '@playwright/test' + +/** Fixed window dimensions for visual regression screenshots. */ +export const VISUAL_WINDOW_WIDTH = 1220 +export const VISUAL_WINDOW_HEIGHT = 800 + +export interface VisualSnapshotOptions { + /** Snapshot name — defaults to the test title. */ + name?: string + /** Full page screenshot vs. viewport-only (default). */ + fullPage?: boolean + /** Timeout in ms. */ + timeout?: number + /** The Electron app handle — used to size and decode screenshots. */ + app: ElectronApplication +} + +/** + * Force the Electron window to a fixed size so screenshots are comparable + * across runs and CI environments. Window managers (Hyprland, etc.) may + * auto-tile or resize windows after launch; calling this right before the + * screenshot ensures the viewport is always the expected size. + */ +async function forceFixedSize(app: ElectronApplication): Promise { + await app.evaluate(({ BrowserWindow }, { width, height }) => { + const win = BrowserWindow.getAllWindows()[0] + + if (win) { + win.unmaximize() + // setMinimumSize must be ≤ the target, otherwise setSize is clamped. + win.setMinimumSize(width, height) + win.setSize(width, height, false) + win.setBounds({ x: 0, y: 0, width, height }) + } + }, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT }) +} + +/** + * Take a screenshot and compare it against the baseline. + * + * If the baseline doesn't exist yet (first run), Playwright creates it. + * If it differs, the test logs a soft warning but does NOT fail — the diff + * images are still generated for CI to surface. + */ +export async function expectVisualSnapshot( + page: Page, + options: VisualSnapshotOptions, +): Promise { + const { name, fullPage = false, timeout = 30_000, app } = options + + // Force the window to a fixed size right before the screenshot so it's + // always comparable, regardless of WM resizing during the test. + await forceFixedSize(app) + // Give the renderer a moment to relayout after the resize. + await page.waitForTimeout(500) + + // Playwright appends a platform suffix (e.g. "-linux") and requires + // a .png extension on the name argument. Auto-append it if missing. + const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined + + const info = test.info() + const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout }) + const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`) + const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '') + + if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') { + fs.mkdirSync(path.dirname(baselinePath), { recursive: true }) + fs.writeFileSync(baselinePath, actual) + // Also write to the output dir so CI artifacts include the screenshot. + fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual) + console.log(`[visual-baseline] updated ${baselinePath}`) + return + } + + if (!fs.existsSync(baselinePath)) { + fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual) + console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`) + return + } + + const expected = fs.readFileSync(baselinePath) + const comparison = await app.evaluate( + ({ nativeImage }, images) => { + const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64')) + const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64')) + const actualSize = actualImage.getSize() + const expectedSize = expectedImage.getSize() + + if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) { + return { mismatchRatio: 1, diff: images.actual } + } + + const actualPixels = actualImage.toBitmap() + const expectedPixels = expectedImage.toBitmap() + const diffPixels = Buffer.alloc(actualPixels.length) + let mismatched = 0 + + for (let i = 0; i < actualPixels.length; i += 4) { + const different = + Math.abs(actualPixels[i] - expectedPixels[i]) > 51 || + Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 || + Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 || + Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51 + + if (different) { + mismatched++ + diffPixels[i + 2] = 255 + } + diffPixels[i + 3] = 255 + } + + return { + mismatchRatio: mismatched / (actualPixels.length / 4), + diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'), + } + }, + { actual: actual.toString('base64'), expected: expected.toString('base64') }, + ) + + // Always write the actual screenshot to the output dir so CI artifacts + // include every screenshot — not just the ones that diffed. + fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual) + + if (comparison.mismatchRatio <= 0.01) { + return + } + + fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected) + fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64')) + console.log( + `[visual-diff] ${name ?? '(unnamed)'} — ${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`, + ) +} diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 912340fb3686..4361678cb8fe 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -23,6 +23,9 @@ import { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + gatewayTicketFailure, + gatewayWsUrlIpcResult, + isGatewayAuthRejection, localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, @@ -491,12 +494,14 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => { assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9') }) -test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => { +test('resolveTestWsUrl (oauth, auth rejected) requests sign-in and does not skip WS validation', async () => { + const cause = Object.assign(new Error('ticket mint failed'), { statusCode: 401 }) + await assert.rejects( () => resolveTestWsUrl('https://gw.example.com', 'oauth', null, { mintTicket: async () => { - throw new Error('401 ticket mint failed') + throw cause } }), (err: any) => { @@ -512,6 +517,66 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio ) }) +test('resolveTestWsUrl (oauth, transport failure) remains a retryable connection error', async () => { + const cause = new Error('socket timed out') + + await assert.rejects( + () => + resolveTestWsUrl('https://gw.example.com', 'oauth', null, { + mintTicket: async () => { + throw cause + } + }), + (err: any) => { + assert.match(err.message, /could not mint a WebSocket ticket/i) + assert.equal(err.needsOauthLogin, undefined) + assert.equal(err.cause, cause) + + return true + } + ) +}) + +test('gateway ticket failures classify only explicit auth rejection statuses as reauth', () => { + assert.equal(isGatewayAuthRejection({ statusCode: 401 }), true) + assert.equal(isGatewayAuthRejection({ statusCode: 403 }), true) + assert.equal(isGatewayAuthRejection({ needsOauthLogin: true }), true) + assert.equal(isGatewayAuthRejection({ statusCode: 500 }), false) + assert.equal(isGatewayAuthRejection(new Error('network timeout')), false) + + const serverFailure = gatewayTicketFailure(new Error('network timeout'), 'sign in', 'retry connection') as any + assert.equal(serverFailure.message, 'retry connection') + assert.equal(serverFailure.needsOauthLogin, undefined) +}) + +test('gateway WS URL IPC result serializes success and the auth-vs-transport matrix', async () => { + assert.deepEqual(await gatewayWsUrlIpcResult(async () => 'wss://gateway.example.com/api/ws?ticket=fresh'), { + ok: true, + wsUrl: 'wss://gateway.example.com/api/ws?ticket=fresh' + }) + + for (const statusCode of [401, 403]) { + const error = Object.assign(new Error(`${statusCode}: rejected`), { statusCode }) + + assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), { + error: `${statusCode}: rejected`, + needsOauthLogin: true, + ok: false + }) + } + + for (const error of [ + Object.assign(new Error('500: unavailable'), { statusCode: 500 }), + new Error('Timed out connecting to Hermes backend after 8000ms'), + Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) + ]) { + assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), { + error: error.message, + ok: false + }) + } +}) + test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => { await assert.rejects( () => resolveTestWsUrl('https://gw.example.com', 'oauth', null), diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index b5164f64dd9d..f7b1ac2e0d42 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -88,6 +88,43 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) { return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}` } +/** True only when a gateway explicitly rejected the current OAuth session. */ +function isGatewayAuthRejection(error) { + if (error && typeof error === 'object' && (error as any).needsOauthLogin === true) { + return true + } + + const statusCode = Number(error && typeof error === 'object' ? (error as any).statusCode : NaN) + + return statusCode === 401 || statusCode === 403 +} + +function gatewayTicketFailure(error, authMessage, transportMessage) { + const needsOauthLogin = isGatewayAuthRejection(error) + const err = new Error(needsOauthLogin ? authMessage : transportMessage) + + if (needsOauthLogin) { + ;(err as any).needsOauthLogin = true + } + + err.cause = error + + return err +} + +/** Serialize a fresh-WS-URL attempt across Electron's IPC boundary. */ +async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise) { + try { + return { ok: true as const, wsUrl: await resolveWsUrl() } + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + ...(isGatewayAuthRejection(error) ? { needsOauthLogin: true as const } : {}), + ok: false as const + } + } +} + /** * Build the WS URL the renderer would connect with, so the connection test can * exercise the same transport the app actually uses. @@ -102,12 +139,10 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) { * - oauth, mint ok → ws(s)://…/api/ws?ticket=… * - oauth, mint fails → THROWS (NOT a skip) * - * The oauth-mint-failure throw is the important case: the real boot path - * (resolveRemoteBackend in main.ts) treats a mint failure as a hard - * "session expired" auth error and refuses to connect. Swallowing it here - * would re-introduce the exact false-positive this test exists to catch — - * HTTP /api/status passes, the test reports "reachable", then the renderer - * can't authenticate /api/ws and boot dies with "Could not connect". + * The oauth-mint-failure throw is the important case: swallowing it here would + * re-introduce the exact false-positive this test exists to catch. An explicit + * 401/403 asks for sign-in; transport and server failures remain connectivity + * errors so a temporary outage is not mislabeled as an expired session. * * @param {string} baseUrl * @param {'token'|'oauth'} authMode @@ -128,14 +163,12 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) { try { ticket = await mintTicket(baseUrl) } catch (error) { - const err = new Error( - 'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' + - '(it may have expired). Open Settings → Gateway and sign in again.' + throw gatewayTicketFailure( + error, + 'Reached the gateway over HTTP, but the OAuth session was rejected while minting a WebSocket ticket. ' + + 'Open Settings → Gateway and sign in again.', + 'Reached the gateway over HTTP, but could not mint a WebSocket ticket. Check the remote gateway connection and try again.' ) - - ;(err as any).needsOauthLogin = true - err.cause = error - throw err } return buildGatewayWsUrlWithTicket(baseUrl, ticket) @@ -458,7 +491,10 @@ export { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + gatewayTicketFailure, + gatewayWsUrlIpcResult, hostLabelFromBaseUrl, + isGatewayAuthRejection, localProfileEntry, modeIsRemoteLike, normalizeRemoteBaseUrl, diff --git a/apps/desktop/electron/event-dedupe.test.ts b/apps/desktop/electron/event-dedupe.test.ts new file mode 100644 index 000000000000..3c0f905587e3 --- /dev/null +++ b/apps/desktop/electron/event-dedupe.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createEventDeduper } from './event-dedupe' + +test('collapses the same key inside the window (two windows, one event)', () => { + const isDup = createEventDeduper(1000) + + assert.equal(isDup('input:s1', 0), false, 'first window claims') + assert.equal(isDup('input:s1', 5), true, 'second window is deduped') +}) + +test('distinct keys are independent', () => { + const isDup = createEventDeduper(1000) + + assert.equal(isDup('input:s1', 0), false) + assert.equal(isDup('approval:s1', 0), false, 'different kind') + assert.equal(isDup('input:s2', 0), false, 'different session') +}) + +test('re-fires once the window elapses', () => { + const isDup = createEventDeduper(1000) + + assert.equal(isDup('turnDone:s1', 0), false) + assert.equal(isDup('turnDone:s1', 999), true, 'still within window') + assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again') +}) + +test('prunes stale keys so the map cannot grow unbounded', () => { + const isDup = createEventDeduper(1000) + + for (let i = 0; i < 100; i += 1) { + // Each far-apart key is pruned before the next, so none linger as duplicates. + assert.equal(isDup(`turnDone:s${i}`, i * 2000), false) + } +}) diff --git a/apps/desktop/electron/event-dedupe.ts b/apps/desktop/electron/event-dedupe.ts new file mode 100644 index 000000000000..a2d12a8f2cba --- /dev/null +++ b/apps/desktop/electron/event-dedupe.ts @@ -0,0 +1,32 @@ +// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end +// sound, spoken replies). Every desktop window is its own renderer process, so N +// open windows each independently react to the same backend event. The main +// process is the one place they all share and it handles IPC serially, so it's +// the race-free owner: the first window to claim a key within the interval wins; +// peers see it's taken and stay quiet. Pure + injectable clock, so it's +// unit-testable without Electron. + +const DEDUPE_INTERVAL_MS = 1000 + +// Returns true when `key` was already claimed within the interval (caller drops +// this one). Self-evicting: stale keys are pruned on every call, so the map +// can't grow unbounded. +export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) { + const lastSeenAt = new Map() + + return function isDuplicate(key: string, now = Date.now()): boolean { + for (const [k, at] of lastSeenAt) { + if (now - at >= intervalMs) { + lastSeenAt.delete(k) + } + } + + if (lastSeenAt.has(key)) { + return true + } + + lastSeenAt.set(key, now) + + return false + } +} diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index a15b3adccafe..5ea4c9e16cbc 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -20,6 +20,7 @@ import { nativeTheme, Notification, powerMonitor, + powerSaveBlocker, protocol, safeStorage, screen, @@ -47,6 +48,8 @@ import { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + gatewayTicketFailure, + gatewayWsUrlIpcResult, hostLabelFromBaseUrl, localProfileEntry, modeIsRemoteLike, @@ -74,6 +77,7 @@ import { uninstallArgsForMode } from './desktop-uninstall' import { installEmbedReferer } from './embed-referer' +import { createEventDeduper } from './event-dedupe' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' import { scanGitRepos } from './git-repo-scan' @@ -113,12 +117,15 @@ import { import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import * as remoteLifecycle from './remote-lifecycle' +import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, + instanceWindowBounds, SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH } from './session-windows' @@ -567,6 +574,7 @@ const DESKTOP_LOG_BACKUP_COUNT = 3 const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4 const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}` const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1' +const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || '' const BOOT_FAKE_STEP_MS = (() => { const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10) @@ -955,9 +963,8 @@ function registerMediaProtocol() { let mainWindow = null const backendConnectionState = createBackendConnectionState, any>() -// Consecutive indeterminate liveness-probe failures for the cached remote -// backend; teardown fires only on proof (refused) or a full streak. -let revalidateTimeoutStreak = 0 +const remoteLiveness = new RemoteLivenessTracker() +const remoteRevalidation = new RemoteRevalidationCoordinator() // True while connection-config:apply soft-rehomes the primary — suppresses the // backend-exit toast so an intentional kill doesn't look like a crash. let softRehomeInProgress = false @@ -4656,9 +4663,9 @@ function getNativeOverlayWidth() { return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC }) } -function getWindowState() { +function getWindowState(win = mainWindow) { return { - isFullscreen: Boolean(mainWindow?.isFullScreen?.()), + isFullscreen: Boolean(win?.isFullScreen?.()), nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition() } @@ -4759,18 +4766,21 @@ function sendOpenUpdatesRequested() { mainWindow.focus() } -function sendWindowStateChanged(nextIsFullscreen?: boolean) { - if (!mainWindow || mainWindow.isDestroyed()) { +// Push titlebar/fullscreen chrome state to a window's renderer. Defaults to the +// primary, but any full chat window (primary or a secondary "instance" peer) +// passes itself so its own fullscreen toggle drives its own traffic-light inset. +function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) { + if (!target || target.isDestroyed()) { return } - const { webContents } = mainWindow + const { webContents } = target if (!webContents || webContents.isDestroyed()) { return } - const state = getWindowState() + const state = getWindowState(target) if (typeof nextIsFullscreen === 'boolean') { state.isFullscreen = nextIsFullscreen @@ -4808,6 +4818,11 @@ function buildApplicationMenu() { template.push({ label: 'File', submenu: [ + // No accelerator: ⌘⇧N is a rebindable renderer keybind (session.newWindow); + // a menu accelerator would fight the rebind panel and (on macOS) be + // swallowed before the renderer sees it. Here purely for discoverability. + { click: () => createInstanceWindow(), label: 'New Window' }, + { type: 'separator' }, IS_MAC ? { // NO accelerator: on macOS a registered ⌘W is consumed by the OS @@ -6462,13 +6477,11 @@ async function buildRemoteConnection( try { ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { - const err = new Error( - 'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.' - ) as any - - err.needsOauthLogin = true - err.cause = error - throw err + throw gatewayTicketFailure( + error, + 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.', + 'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.' + ) } return { @@ -7151,10 +7164,7 @@ function stopBackendChild(child) { // switch / crash recovery), which still resets boot progress + reloads. function resetHermesConnection({ soft = false } = {}) { backendStartFailure = null - // A new connection generation must not inherit an old backend's timeout - // streak — one inherited strike would revert its first slow probe to - // single-timeout teardown aggression. - revalidateTimeoutStreak = 0 + remoteLiveness.clear() const hermesProcess = backendConnectionState.invalidate() stopBackendChild(hermesProcess) @@ -7546,6 +7556,16 @@ async function startHermes() { throw backendStartFailure } + // E2E: simulate a boot failure without breaking the real backend. The boot + // progresses a few steps, then fails with the given error message. + if (BOOT_FAKE_ERROR) { + await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8) + const error = new Error(BOOT_FAKE_ERROR) as any + error.isBootstrapFailure = true + bootstrapFailure = error + throw error + } + const existingConnectionPromise = backendConnectionState.getPromise() if (existingConnectionPromise) { @@ -7866,11 +7886,7 @@ function focusWindow(win) { win.focus() } -function spawnSecondaryWindow({ - sessionId, - watch, - newSession -}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) { +function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?: boolean } = {}) { const icon = getAppIconPath() const win = new BrowserWindow({ @@ -7915,8 +7931,7 @@ function spawnSecondaryWindow({ buildSessionWindowUrl(sessionId, { devServer: DEV_SERVER, rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), - watch, - newSession + watch }) ) @@ -7928,11 +7943,82 @@ function createSessionWindow(sessionId, { watch = false } = {}) { return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch })) } -// Open a fresh compact window on the new-session draft (#/). Not registry-keyed: -// like ⌘N in a browser, every press opens a new window — and a draft window that -// later converts to a real session must not get refocused as if it were blank. -function createNewSessionWindow() { - return spawnSecondaryWindow({ newSession: true }) +// Additional full "instance" windows — peers of the primary that render the +// COMPLETE app (sidebar, routing, its own draft) against the shared backend, so +// a user can run multiple GUI windows at once (⌘⇧N / the "New Window" palette +// command). Unlike the compact session windows they carry no `?win` flag. The +// primary mainWindow stays the notification / deep-link / pet-overlay anchor and +// is NOT tracked here. The set holds a strong reference so an open peer isn't +// garbage-collected, and drops it on close. +const instanceWindows = new Set() + +// Cascade a new instance off whichever window spawned it so it doesn't land +// exactly on top of its source. Falls back to the persisted primary geometry +// when there's no live source window (e.g. all windows closed on macOS). The +// pure cascade math lives in session-windows.ts (instanceWindowBounds). +function nextInstanceBounds() { + const source = BrowserWindow.getFocusedWindow() || mainWindow + const fallback = computeWindowOptions(readWindowState(), screen.getAllDisplays()) + const base = source && !source.isDestroyed() ? source.getBounds() : null + + return instanceWindowBounds(base, fallback) +} + +// Open a new full-chrome instance window. Mirrors createWindow()'s window +// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a +// streamed answer never stalls in the background) but is a peer, not the +// primary: it never overwrites the mainWindow global, doesn't start the backend +// (the renderer's getConnection() joins the already-running one), and loads the +// plain renderer URL so the full app renders. +function createInstanceWindow() { + const icon = getAppIconPath() + + const win = new BrowserWindow({ + ...nextInstanceBounds(), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, + title: 'Hermes', + titleBarStyle: 'hidden', + titleBarOverlay: getTitleBarOverlayOptions(), + trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + vibrancy: IS_MAC ? 'sidebar' : undefined, + opacity: windowOpacity(), + icon, + show: false, + backgroundColor: getWindowBackgroundColor(), + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) + }) + + instanceWindows.add(win) + + if (IS_MAC) { + win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + } + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) { + win.show() + } + }) + + // Per-window fullscreen chrome: send this window its own titlebar inset so its + // traffic lights hide/show independently of the primary. + win.on('enter-full-screen', () => sendWindowStateChanged(true, win)) + win.on('leave-full-screen', () => sendWindowStateChanged(false, win)) + + wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) + + win.on('closed', () => { + instanceWindows.delete(win) + }) + + if (DEV_SERVER) { + win.loadURL(DEV_SERVER) + } else { + win.loadURL(pathToFileURL(resolveRendererIndex()).toString()) + } + + return win } // The pet overlay: a single transparent, frameless, always-on-top window that @@ -8120,7 +8206,9 @@ function createWindow() { if (!nativeThemeListenerInstalled) { nativeThemeListenerInstalled = true nativeTheme.on('updated', () => { - applyTitleBarOverlay(mainWindow) + for (const win of BrowserWindow.getAllWindows()) { + applyTitleBarOverlay(win) + } }) } } @@ -8158,6 +8246,14 @@ function createWindow() { } }) + // Under Playright testing, instantly show the window. + // `ready-to-show` doesn't fire in some testing envs. + if (process.env.TEST_WORKER_INDEX !== undefined) { + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { + mainWindow.show() + } + } + mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true)) mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false)) @@ -8308,64 +8404,43 @@ ipcMain.handle('hermes:connection:revalidate', async () => { return { ok: true, rebuilt: false } } - let conn = null + // Main and every session pop-out have their own renderer reconnect loop but + // share this primary connection. Coalesce simultaneous requests so one outage + // produces one failure observation rather than exhausting the whole streak. + return remoteRevalidation.run(connectionPromise, async () => { + const result = await revalidateRemoteConnection({ + connectionPromise, + currentConnectionPromise: () => backendConnectionState.getPromise(), + log: rememberLog, + probe: fetchPublicJson, + resetConnection: resetHermesConnection, + tracker: remoteLiveness + }) - try { - conn = await connectionPromise - } catch { - // The cached boot already rejected (its own catch clears the promise); - // nothing to revalidate — the next getConnection() builds fresh. - return { ok: true, rebuilt: false } - } + // A rebuilt SSH connection must also tear down its tunnel/master before the + // renderer re-dials (which only happens after this handler resolves), so the + // fresh bootstrap can't reattach to a dying transport. + if (result.rebuilt) { + const conn = await connectionPromise.catch(() => null) - if (!conn || conn.mode !== 'remote' || !conn.baseUrl) { - return { ok: true, rebuilt: false } - } - - const base = conn.baseUrl.replace(/\/+$/, '') - - try { - await fetchPublicJson(`${base}/api/status`, { timeoutMs: 10_000 }) - revalidateTimeoutStreak = 0 - - return { ok: true, rebuilt: false } - } catch (error: any) { - // A timeout is indeterminate (slow VM, idle tunnel re-establishing) — not - // proof of death. Tear down only on proof (connection refused) or after a - // bounded streak of timeouts, so one slow answer can't destroy a healthy - // transport and cancel in-flight bootstraps. - const refused = /ECONNREFUSED/i.test(String(error?.code || error?.cause?.code || error?.message || '')) - - if (!refused) { - revalidateTimeoutStreak += 1 - - if (revalidateTimeoutStreak < 3) { - rememberLog(`Remote liveness probe indeterminate (${revalidateTimeoutStreak}/3); keeping connection.`) - - return { ok: true, rebuilt: false } + if (conn?.remoteKind === 'ssh') { + const profile = primaryProfileKey() + await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile)) + await teardownSshConnection(profile) } } - revalidateTimeoutStreak = 0 - rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') - - if (conn.remoteKind === 'ssh') { - const profile = primaryProfileKey() - await sshBootstrapCoordinator.cancelAndWait(sshScopeKey(profile)) - await teardownSshConnection(profile) - } - - resetHermesConnection() - - return { ok: true, rebuilt: true } - } + return result + }) }) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) return { ok: true } }) -ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) +ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => { + return gatewayWsUrlIpcResult(() => freshGatewayWsUrl(profile)) +}) ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { if (typeof sessionId !== 'string' || !sessionId.trim()) { return { ok: false, error: 'invalid-session-id' } @@ -8375,8 +8450,8 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { return { ok: true } }) -ipcMain.handle('hermes:window:openNewSession', async () => { - createNewSessionWindow() +ipcMain.handle('hermes:window:openInstance', async () => { + createInstanceWindow() return { ok: true } }) @@ -9032,11 +9107,28 @@ ipcMain.handle('hermes:api', async (_event, request) => { }) }) +// One deduper per cross-window cue — the choke point every window shares. Main +// handles IPC serially, so the first window to claim a key wins with no race. +const isDuplicateNotification = createEventDeduper() +const claimedAmbientCue = createEventDeduper() + +// A window asks "do I own this ambient cue (turn-end sound / spoken reply)?". +// The first caller within the window gets true; peers get false and stay quiet. +ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? ''))) + ipcMain.handle('hermes:notify', (_event, payload) => { if (!Notification.isSupported()) { return false } + // Multiple full windows each run their own renderer throttle, so the same + // kind+session can arrive here twice. Collapse it at this single choke point. + // Return true (not false): a notification for the event IS being shown by the + // first caller, so the settings "send test" success probe stays honest. + if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) { + return true + } + // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] @@ -9206,7 +9298,13 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => { background: payload.background, foreground: payload.foreground } - applyTitleBarOverlay(mainWindow) + + // Repaint the native (Windows/Linux) titlebar overlay on every open chat + // window, not just the primary — instance peers and session windows share the + // one app theme. applyTitleBarOverlay no-ops on the frameless pet overlay. + for (const win of BrowserWindow.getAllWindows()) { + applyTitleBarOverlay(win) + } }) // Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). @@ -9238,6 +9336,33 @@ ipcMain.on('hermes:translucency', (_event, payload) => { } }) +// Keep-awake: hold the machine awake for long/overnight runs. Main owns the one +// blocker and its persisted state so a cold launch restores it (applied on +// ready — powerSaveBlocker needs the app ready). The renderer toggles it from +// Settings → Advanced over IPC. See store/keep-awake. +const KEEP_AWAKE_CONFIG_PATH = path.join(app.getPath('userData'), 'keep-awake.json') +const keepAwake = createKeepAwake(powerSaveBlocker) + +function readPersistedKeepAwake() { + try { + return JSON.parse(fs.readFileSync(KEEP_AWAKE_CONFIG_PATH, 'utf8')).on === true + } catch { + return false + } +} + +ipcMain.on('hermes:keep-awake', (_event, on) => { + const enabled = Boolean(on) + keepAwake.set(enabled) + + try { + fs.mkdirSync(path.dirname(KEEP_AWAKE_CONFIG_PATH), { recursive: true }) + fs.writeFileSync(KEEP_AWAKE_CONFIG_PATH, JSON.stringify({ on: enabled }, null, 2), 'utf8') + } catch (error) { + rememberLog(`[keep-awake] write failed: ${error.message}`) + } +}) + ipcMain.handle('hermes:openExternal', (_event, url) => { if (!openExternalUrl(url)) { throw new Error('Invalid external URL') @@ -10253,6 +10378,7 @@ app.whenReady().then(() => { ensureWslWindowsFonts() configureSpellChecker() registerPowerResumeListeners() + keepAwake.set(readPersistedKeepAwake()) createWindow() // Win/Linux cold start: the launching hermes:// URL is in our own argv. diff --git a/apps/desktop/electron/power-save.test.ts b/apps/desktop/electron/power-save.test.ts new file mode 100644 index 000000000000..97725e0ed019 --- /dev/null +++ b/apps/desktop/electron/power-save.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createKeepAwake, type PowerSaveBlockerLike } from './power-save' + +function fakeBlocker() { + let next = 1 + const started = new Set() + + const blocker: PowerSaveBlockerLike = { + isStarted: id => started.has(id), + start: vi.fn(() => { + const id = next++ + started.add(id) + + return id + }), + stop: vi.fn(id => void started.delete(id)) + } + + return { blocker, started } +} + +describe('createKeepAwake', () => { + it('starts once, is idempotent, and stops', () => { + const { blocker } = fakeBlocker() + const keepAwake = createKeepAwake(blocker) + + expect(keepAwake.isActive()).toBe(false) + expect(keepAwake.set(true)).toBe(true) + keepAwake.set(true) // idempotent — no second blocker + expect(blocker.start).toHaveBeenCalledTimes(1) + expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension') + + expect(keepAwake.set(false)).toBe(false) + keepAwake.set(false) + expect(blocker.stop).toHaveBeenCalledTimes(1) + }) + + it('re-arms after the OS dropped the blocker', () => { + const { blocker, started } = fakeBlocker() + const keepAwake = createKeepAwake(blocker) + + keepAwake.set(true) + started.clear() // system released it out from under us + expect(keepAwake.isActive()).toBe(false) + + keepAwake.set(true) + expect(blocker.start).toHaveBeenCalledTimes(2) + expect(keepAwake.isActive()).toBe(true) + }) + + it('honors a custom blocker type', () => { + const { blocker } = fakeBlocker() + createKeepAwake(blocker, 'prevent-display-sleep').set(true) + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + }) +}) diff --git a/apps/desktop/electron/power-save.ts b/apps/desktop/electron/power-save.ts new file mode 100644 index 000000000000..0939de99d80c --- /dev/null +++ b/apps/desktop/electron/power-save.ts @@ -0,0 +1,50 @@ +/** + * Keep-awake — hold a single machine-global power-save blocker. + * + * `prevent-app-suspension` stops the system from sleeping (long overnight + * agent runs keep going) while still letting the display dim. The renderer + * owns the preference (persisted in localStorage) and mirrors it here over + * IPC; the main process owns the one native blocker, same authority split as + * translucency/zoom. Electron auto-releases the blocker on quit. + */ + +export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep' + +/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */ +export interface PowerSaveBlockerLike { + start(type: KeepAwakeType): number + stop(id: number): void + isStarted(id: number): boolean +} + +export interface KeepAwake { + /** Turn the blocker on/off (idempotent). Returns the resulting state. */ + set(on: boolean): boolean + isActive(): boolean +} + +export function createKeepAwake( + blocker: PowerSaveBlockerLike, + type: KeepAwakeType = 'prevent-app-suspension' +): KeepAwake { + let id: null | number = null + + const isActive = () => id !== null && blocker.isStarted(id) + + return { + isActive, + set(on) { + if (on && !isActive()) { + id = blocker.start(type) + } else if (!on && id !== null) { + if (blocker.isStarted(id)) { + blocker.stop(id) + } + + id = null + } + + return isActive() + } + } +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 5e77433665a7..7652a7688dd2 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -6,7 +6,8 @@ contextBridge.exposeInMainWorld('hermesDesktop', { touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), - openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'), + openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'), + claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key), petOverlay: { // Main renderer → main process: window lifecycle + drag. `request` is // `{ bounds, screen }`; resolves with the screen bounds it actually used. @@ -81,6 +82,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload), setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode), setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload), + setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on), setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url), diff --git a/apps/desktop/electron/remote-liveness.test.ts b/apps/desktop/electron/remote-liveness.test.ts new file mode 100644 index 000000000000..6c52e11f69f9 --- /dev/null +++ b/apps/desktop/electron/remote-liveness.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + REMOTE_LIVENESS_FAILURE_LIMIT, + REMOTE_LIVENESS_FAILURE_WINDOW_MS, + REMOTE_LIVENESS_TIMEOUT_MS, + RemoteLivenessTracker, + RemoteRevalidationCoordinator, + revalidateRemoteConnection +} from './remote-liveness' + +describe('RemoteLivenessTracker', () => { + it('requires consecutive failures before resetting a connection', () => { + const tracker = new RemoteLivenessTracker() + + for (let failures = 1; failures < REMOTE_LIVENESS_FAILURE_LIMIT; failures += 1) { + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures, shouldReset: false }) + } + + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ + failures: REMOTE_LIVENESS_FAILURE_LIMIT, + shouldReset: true + }) + }) + + it('clears a failure streak after a successful probe', () => { + const tracker = new RemoteLivenessTracker() + + tracker.recordFailure('https://gateway.example.com') + tracker.recordFailure('https://gateway.example.com') + tracker.recordSuccess('https://gateway.example.com') + + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false }) + }) + + it('tracks different gateways independently', () => { + const tracker = new RemoteLivenessTracker(2) + + expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false }) + expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false }) + expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 2, shouldReset: true }) + expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: true }) + }) + + it('clears only the successful gateway streak', () => { + const tracker = new RemoteLivenessTracker(3) + + tracker.recordFailure('https://one.example.com') + tracker.recordFailure('https://two.example.com') + tracker.recordSuccess('https://one.example.com') + + expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false }) + expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: false }) + }) + + it('does not accumulate isolated failures across separate reconnect episodes', () => { + let now = 0 + const tracker = new RemoteLivenessTracker(3, REMOTE_LIVENESS_FAILURE_WINDOW_MS, () => now) + + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false }) + now += REMOTE_LIVENESS_FAILURE_WINDOW_MS + 1 + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false }) + }) + + it('clears all failure streaks when the connection state resets', () => { + const tracker = new RemoteLivenessTracker(3) + + tracker.recordFailure('https://one.example.com') + tracker.recordFailure('https://two.example.com') + tracker.clear() + + expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false }) + expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false }) + }) + + it('starts a fresh streak after the reset threshold is consumed', () => { + const tracker = new RemoteLivenessTracker(1) + + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true }) + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true }) + }) + + it('rejects invalid failure limits', () => { + expect(() => new RemoteLivenessTracker(0)).toThrow(/positive integer/i) + expect(() => new RemoteLivenessTracker(1.5)).toThrow(/positive integer/i) + expect(() => new RemoteLivenessTracker(1, 0)).toThrow(/window must be positive/i) + }) +}) + +describe('RemoteRevalidationCoordinator', () => { + it('coalesces simultaneous probes for the same cached connection', async () => { + const coordinator = new RemoteRevalidationCoordinator() + const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' }) + let resolveProbe: (value: string) => void = () => undefined + + const probe = vi.fn( + () => + new Promise(resolve => { + resolveProbe = resolve + }) + ) + + const first = coordinator.run(connection, probe) + const second = coordinator.run(connection, probe) + const third = coordinator.run(connection, probe) + + await Promise.resolve() + + expect(second).toBe(first) + expect(third).toBe(first) + expect(probe).toHaveBeenCalledOnce() + + resolveProbe('healthy') + await expect(Promise.all([first, second, third])).resolves.toEqual(['healthy', 'healthy', 'healthy']) + }) + + it('runs a fresh probe after the prior one settles', async () => { + const coordinator = new RemoteRevalidationCoordinator() + const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' }) + const probe = vi.fn().mockResolvedValue('healthy') + + await coordinator.run(connection, probe) + await coordinator.run(connection, probe) + + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('does not coalesce different cached connections', async () => { + const coordinator = new RemoteRevalidationCoordinator() + const probe = vi.fn().mockResolvedValue('healthy') + + await Promise.all([coordinator.run(Promise.resolve('one'), probe), coordinator.run(Promise.resolve('two'), probe)]) + + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('cleans up a rejected probe so it can be retried', async () => { + const coordinator = new RemoteRevalidationCoordinator() + const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' }) + const probe = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce('healthy') + + await expect(coordinator.run(connection, probe)).rejects.toThrow('offline') + await expect(coordinator.run(connection, probe)).resolves.toBe('healthy') + expect(probe).toHaveBeenCalledTimes(2) + }) +}) + +describe('revalidateRemoteConnection', () => { + function harness(overrides: Record = {}) { + const connection = { baseUrl: 'https://gateway.example.com/', mode: 'remote' } + const connectionPromise = Promise.resolve(connection) + const current = { promise: connectionPromise as null | Promise } + const log = vi.fn() + const probe = vi.fn().mockResolvedValue({ ok: true }) + const resetConnection = vi.fn() + const tracker = new RemoteLivenessTracker() + + return { + connectionPromise, + current, + log, + options: { + connectionPromise, + currentConnectionPromise: () => current.promise, + log, + probe, + resetConnection, + tracker, + ...overrides + }, + probe, + resetConnection, + tracker + } + } + + it('probes the normalized status URL with the production timeout', async () => { + const test = harness() + + await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false }) + expect(test.probe).toHaveBeenCalledWith('https://gateway.example.com/api/status', { + timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS + }) + expect(test.resetConnection).not.toHaveBeenCalled() + }) + + it('keeps failures one and two, then resets on the third failure', async () => { + const probe = vi.fn().mockRejectedValue(new Error('offline')) + const test = harness({ probe }) + + await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false }) + await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false }) + await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: true }) + + expect(probe).toHaveBeenCalledTimes(3) + expect(test.resetConnection).toHaveBeenCalledOnce() + expect(test.log).toHaveBeenNthCalledWith(1, expect.stringContaining('(1/3)')) + expect(test.log).toHaveBeenNthCalledWith(2, expect.stringContaining('(2/3)')) + expect(test.log).toHaveBeenLastCalledWith(expect.stringContaining('dropping stale connection')) + }) + + it('ignores a late failed probe after the cached connection is replaced', async () => { + let rejectProbe: (error: Error) => void = () => undefined + + const probe = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectProbe = reject + }) + ) + + const test = harness({ probe }) + const pending = revalidateRemoteConnection(test.options) + + await Promise.resolve() + test.current.promise = Promise.resolve({ baseUrl: 'https://new.example.com', mode: 'remote' }) + rejectProbe(new Error('old connection failed')) + + await expect(pending).resolves.toEqual({ ok: true, rebuilt: false }) + expect(test.resetConnection).not.toHaveBeenCalled() + expect(test.log).not.toHaveBeenCalled() + expect(test.tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false }) + }) + + it('does not probe a local, rejected, or already replaced connection', async () => { + const replaced = harness() + + replaced.current.promise = null + await expect(revalidateRemoteConnection(replaced.options)).resolves.toEqual({ ok: true, rebuilt: false }) + expect(replaced.probe).not.toHaveBeenCalled() + + const localConnection = { baseUrl: 'http://127.0.0.1:3000', mode: 'local' } + const localPromise = Promise.resolve(localConnection) + + const local = harness({ + connectionPromise: localPromise, + currentConnectionPromise: () => localPromise + }) + + await expect(revalidateRemoteConnection(local.options)).resolves.toEqual({ ok: true, rebuilt: false }) + expect(local.probe).not.toHaveBeenCalled() + + const rejectedPromise = Promise.reject(new Error('boot failed')) + + const rejected = harness({ + connectionPromise: rejectedPromise, + currentConnectionPromise: () => rejectedPromise + }) + + await expect(revalidateRemoteConnection(rejected.options)).resolves.toEqual({ ok: true, rebuilt: false }) + expect(rejected.probe).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/electron/remote-liveness.ts b/apps/desktop/electron/remote-liveness.ts new file mode 100644 index 000000000000..eedc16682e7d --- /dev/null +++ b/apps/desktop/electron/remote-liveness.ts @@ -0,0 +1,182 @@ +export const REMOTE_LIVENESS_TIMEOUT_MS = 10_000 +export const REMOTE_LIVENESS_FAILURE_LIMIT = 3 +// Even at the capped retry path, consecutive liveness observations are at most +// about 48s apart (ticket mint + socket open + backoff + the next status probe). +// One minute keeps a continuous outage together without carrying old failures. +export const REMOTE_LIVENESS_FAILURE_WINDOW_MS = 60_000 + +export interface RemoteLivenessFailure { + failures: number + shouldReset: boolean +} + +interface RemoteConnectionDescriptor { + baseUrl?: null | string + mode?: null | string +} + +export interface RevalidateRemoteConnectionOptions { + connectionPromise: Promise + currentConnectionPromise: () => null | Promise + log: (message: string) => void + probe: (url: string, options: { timeoutMs: number }) => Promise + resetConnection: () => void + tracker: RemoteLivenessTracker +} + +export interface RemoteRevalidationResult { + ok: true + rebuilt: boolean +} + +/** + * Coalesces revalidation work for one cached connection promise. + * + * Every Desktop BrowserWindow owns a renderer gateway loop. When several + * windows observe the same disconnect they can all ask the Electron main + * process to revalidate the shared primary connection at once. Those calls + * must count as one probe, not several consecutive failures. + */ +export class RemoteRevalidationCoordinator { + readonly #inflightByConnection = new WeakMap>() + + run(connection: object, task: () => Promise): Promise { + const existing = this.#inflightByConnection.get(connection) as Promise | undefined + + if (existing) { + return existing + } + + const pending = Promise.resolve().then(task) + + const clear = () => { + if (this.#inflightByConnection.get(connection) === pending) { + this.#inflightByConnection.delete(connection) + } + } + + this.#inflightByConnection.set(connection, pending) + // Clean up on both outcomes without creating an unhandled rejected branch. + void pending.then(clear, clear) + + return pending + } +} + +/** + * Tracks consecutive remote liveness failures independently per gateway. + * A successful probe clears the streak, and reaching the limit consumes it so + * a rebuilt connection starts from a clean state. + */ +export class RemoteLivenessTracker { + readonly #failureLimit: number + readonly #failureWindowMs: number + readonly #failuresByBaseUrl = new Map() + readonly #now: () => number + + constructor( + failureLimit = REMOTE_LIVENESS_FAILURE_LIMIT, + failureWindowMs = REMOTE_LIVENESS_FAILURE_WINDOW_MS, + now: () => number = Date.now + ) { + if (!Number.isInteger(failureLimit) || failureLimit < 1) { + throw new Error('Remote liveness failure limit must be a positive integer.') + } + + if (!Number.isFinite(failureWindowMs) || failureWindowMs < 1) { + throw new Error('Remote liveness failure window must be positive.') + } + + this.#failureLimit = failureLimit + this.#failureWindowMs = failureWindowMs + this.#now = now + } + + recordSuccess(baseUrl: string): void { + this.#failuresByBaseUrl.delete(baseUrl) + } + + recordFailure(baseUrl: string): RemoteLivenessFailure { + const now = this.#now() + const previous = this.#failuresByBaseUrl.get(baseUrl) + const withinFailureWindow = previous && now - previous.lastFailureAt <= this.#failureWindowMs + const failures = (withinFailureWindow ? previous.failures : 0) + 1 + const shouldReset = failures >= this.#failureLimit + + if (shouldReset) { + this.#failuresByBaseUrl.delete(baseUrl) + } else { + this.#failuresByBaseUrl.set(baseUrl, { failures, lastFailureAt: now }) + } + + return { failures, shouldReset } + } + + clear(): void { + this.#failuresByBaseUrl.clear() + } +} + +/** + * Probe the cached primary remote connection and apply the failure policy. + * The caller owns single-flight coordination; identity checks here ensure an + * old async result cannot mutate or reset a replacement connection. + */ +export async function revalidateRemoteConnection({ + connectionPromise, + currentConnectionPromise, + log, + probe, + resetConnection, + tracker +}: RevalidateRemoteConnectionOptions): Promise { + let connection: TConnection + + try { + connection = await connectionPromise + } catch { + // The cached boot already rejected; its own recovery path will clear it. + return { ok: true, rebuilt: false } + } + + if (currentConnectionPromise() !== connectionPromise) { + return { ok: true, rebuilt: false } + } + + if (connection.mode !== 'remote' || !connection.baseUrl) { + return { ok: true, rebuilt: false } + } + + const baseUrl = connection.baseUrl.replace(/\/+$/, '') + + try { + await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS }) + + if (currentConnectionPromise() !== connectionPromise) { + return { ok: true, rebuilt: false } + } + + tracker.recordSuccess(baseUrl) + + return { ok: true, rebuilt: false } + } catch { + if (currentConnectionPromise() !== connectionPromise) { + return { ok: true, rebuilt: false } + } + + const failure = tracker.recordFailure(baseUrl) + + if (!failure.shouldReset) { + log( + `Cached remote Hermes backend failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping connection for retry.` + ) + + return { ok: true, rebuilt: false } + } + + log('Cached remote Hermes backend failed liveness probe; dropping stale connection.') + resetConnection() + + return { ok: true, rebuilt: true } + } +} diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index fcfca8680730..5167593bfbf8 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -2,7 +2,12 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows' +import { + buildSessionWindowUrl, + chatWindowWebPreferences, + createSessionWindowRegistry, + instanceWindowBounds +} from './session-windows' // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // test fire the 'closed' event, mirroring the slice of the Electron API the @@ -83,10 +88,16 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc') }) -test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => { - const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true }) +test('instanceWindowBounds cascades a new window off its source bounds', () => { + const bounds = instanceWindowBounds({ x: 100, y: 120, width: 1400, height: 900 }, { width: 1, height: 1 }) - assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/') + assert.deepEqual(bounds, { width: 1400, height: 900, x: 132, y: 152 }) +}) + +test('instanceWindowBounds falls back to the persisted geometry with no source window', () => { + const fallback = { width: 1280, height: 800 } + + assert.equal(instanceWindowBounds(null, fallback), fallback) }) test('registry opens one window per session and focuses on re-open', () => { diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index af55608b0f4e..48597ee9e0ea 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -38,13 +38,12 @@ function chatWindowWebPreferences(preloadPath: string) { // flag MUST sit in the query string BEFORE the '#': anything after the '#' is // treated as the route by HashRouter and would break routeSessionId(). The // renderer reads the flag from window.location.search to suppress the install / -// onboarding overlays and the global session sidebar. `new=1` marks the compact -// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's -// session): the renderer resumes it lazily so the gateway never builds an agent -// just to stream into it. -function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) { - const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` - const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` +// onboarding overlays and the global session sidebar. `watch=1` marks a +// spectator window (e.g. a running subagent's session): the renderer resumes it +// lazily so the gateway never builds an agent just to stream into it. +function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch }: any = {}) { + const query = `?win=secondary${watch ? '&watch=1' : ''}` + const route = `#/${encodeURIComponent(sessionId)}` if (devServer) { const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer @@ -55,6 +54,28 @@ function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}` } +// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app +// peer, not a compact chat. Cascade each one off its source window's bounds so a +// new window doesn't land exactly on top of the one it was spawned from. Pure so +// it's unit-testable; the Electron glue (reading the focused window's bounds, +// constructing the BrowserWindow) stays in main.ts. `base` is the source +// window's current bounds, or null when there's no live source window — then the +// persisted primary geometry (`fallback`) is used as-is. +const INSTANCE_CASCADE_OFFSET = 32 + +function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) { + if (!base) { + return fallback + } + + return { + width: base.width, + height: base.height, + x: base.x + INSTANCE_CASCADE_OFFSET, + y: base.y + INSTANCE_CASCADE_OFFSET + } +} + // A small registry keyed by sessionId that guarantees one window per chat: // opening a session that already has a live window focuses it instead of // spawning a duplicate, and a window removes itself from the registry when it @@ -119,6 +140,7 @@ export { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, + instanceWindowBounds, SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH } diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1cde03bf64d1..743269c10476 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,6 +13,7 @@ "scripts": { "dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"", "dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev", + "dev:mock": "node scripts/dev-mock.mjs", "dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174", "dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .", @@ -40,7 +41,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", - "typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit", + "typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'", @@ -49,7 +50,10 @@ "test:desktop:platforms": "vitest run --project electron", "test": "vitest run", "preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174", - "check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build" + "check": "npm run typecheck && npm run test && npm run test:desktop:all", + "test:e2e": "playwright test e2e/", + "test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list", + "test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots" }, "dependencies": { "@assistant-ui/react": "^0.14.23", @@ -124,6 +128,7 @@ "devDependencies": { "@electron/rebuild": "^4.0.6", "@eslint/js": "^9.39.4", + "@playwright/test": "=1.58.2", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", "@types/d3-force": "^3.0.10", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 000000000000..dc06c59fe786 --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,63 @@ +import './e2e/fix-electron-tracing' + +import { defineConfig, type ReporterDescription } from '@playwright/test' + +/** + * Visual regression testing config. + * + * Screenshots are compared against baselines. On `main`, baselines are + * generated with `--update-snapshots` and cached. On PRs, the cached + * baselines are restored and screenshots are compared — but tests DON'T + * fail on visual diffs (see `expectVisualSnapshot` in visual-snapshot.ts). + * Instead, diffs are surfaced in the CI step summary and uploaded as + * artifacts for human review. + * + * To update baselines after an intentional UI change: + * npx playwright test --update-snapshots + */ +const reporters: ReporterDescription[] = [ + ['list'], + ['html', { open: 'never', outputFolder: 'playwright-report' }], +] + +if (process.env.CI) { + reporters.push(['json', { outputFile: 'playwright-report/results.json' }]) +} + +export default defineConfig({ + /* Test files live under e2e/ so they never collide with the vitest suite + * under src/ or the node:test files under electron/. */ + testDir: './e2e', + /* The desktop app can take a while to bootstrap on cold CI runners — 90 s + * per test gives us headroom without masking real hangs. */ + timeout: 90_000, + retries: process.env.CI ? 1 : 0, + /* Each test gets its own worker so the Electron process is fully isolated. */ + fullyParallel: false, + reporter: reporters, + use: { + screenshot: 'on', + trace: { mode: 'on', screenshots: true, snapshots: true, sources: true }, + // Emulate prefers-reduced-motion: reduce so all CSS transitions and + // animations resolve instantly. This prevents boot/connecting overlays + // from being mid-fade when a screenshot fires, and skips JS-driven exit + // choreography in components that check matchMedia (onboarding, connecting + // overlay, DecodeText). Without this, screenshots capture the loading bar + // or overlay at a transient opacity because the text-content check fires + // before the visual transition finishes. + contextOptions: { + reducedMotion: 'reduce', + }, + }, + expect: { + toHaveScreenshot: { + // 1% of pixels may differ — absorbs sub-pixel font rendering variance + // between local and CI environments. + maxDiffPixelRatio: 0.01, + animations: 'disabled', + caret: 'hide', + // Per-channel threshold for "close enough" — anti-aliasing differences. + threshold: 0.2, + }, + }, +}) diff --git a/apps/desktop/scripts/dev-mock.mjs b/apps/desktop/scripts/dev-mock.mjs new file mode 100644 index 000000000000..7b523d88b3c3 --- /dev/null +++ b/apps/desktop/scripts/dev-mock.mjs @@ -0,0 +1,237 @@ +#!/usr/bin/env node +/** + * Launch the desktop app with a mock inference provider — no real API + * keys needed. Starts a local OpenAI-compatible server that returns a + * canned reply, writes an isolated config.yaml + .env, and launches the + * built Electron app against them. + * + * This reuses the same mock-server and config format as the E2E fixtures + * (apps/desktop/e2e/mock-server.ts + fixtures.ts), so local dev and CI + * test the same chain. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + * + * Usage: + * node scripts/dev-mock.mjs + * npm run dev:mock + * + * The mock server listens on an ephemeral port and replies to every + * chat completion with: + * "Hello from the mock inference server! The full boot chain is working." + */ + +import http from 'node:http' +import fs from 'node:fs' +import os from 'node:os' +import path from 'node:path' +import { spawn, spawnSync } from 'node:child_process' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') + +// ── Canned reply ─────────────────────────────────────────────────────── + +const CANNED_REPLY = + 'Hello from the mock inference server! The full boot chain is working.' + +// ── Mock server (mirrors e2e/mock-server.ts) ─────────────────────────── + +function startMockServer() { + return new Promise((resolve, reject) => { + const server = http.createServer((req, res) => { + res.setHeader('Access-Control-Allow-Origin', '*') + res.setHeader('Access-Control-Allow-Headers', '*') + res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS') + + if (req.method === 'OPTIONS') { + res.writeHead(204) + res.end() + return + } + + if (req.method === 'GET' && req.url === '/v1/models') { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + object: 'list', + data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'mock' }], + }), + ) + return + } + + if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) { + let body = '' + req.on('data', (chunk) => { body += chunk.toString() }) + req.on('end', () => { + let parsed = {} + try { parsed = JSON.parse(body) } catch { /* non-streaming */ } + + const stream = parsed.stream === true + const model = parsed.model || 'mock-model' + + if (stream) { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + const words = CANNED_REPLY.split(' ') + let i = 0 + const sendChunk = () => { + if (i >= words.length) { + res.write( + `data: ${JSON.stringify({ + id: 'mock-completion', object: 'chat.completion.chunk', + created: 0, model, + choices: [{ index: 0, delta: {}, finish_reason: 'stop' }], + })}\n\n`, + ) + res.write('data: [DONE]\n\n') + res.end() + return + } + const word = i === 0 ? words[i] : ' ' + words[i] + res.write( + `data: ${JSON.stringify({ + id: 'mock-completion', object: 'chat.completion.chunk', + created: 0, model, + choices: [{ index: 0, delta: { content: word }, finish_reason: null }], + })}\n\n`, + ) + i++ + setTimeout(sendChunk, 20) + } + sendChunk() + } else { + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + id: 'mock-completion', object: 'chat.completion', + created: 0, model, + choices: [{ + index: 0, + message: { role: 'assistant', content: CANNED_REPLY }, + finish_reason: 'stop', + }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }), + ) + } + }) + req.on('error', () => { res.writeHead(400); res.end('Bad request') }) + return + } + + res.writeHead(404, { 'Content-Type': 'application/json' }) + res.end(JSON.stringify({ error: 'Not found' })) + }) + + server.on('error', reject) + server.listen(0, '127.0.0.1', () => { + const addr = server.address() + if (addr === null || typeof addr === 'string') { + reject(new Error('Failed to get server address')) + return + } + resolve({ port: addr.port, url: `http://127.0.0.1:${addr.port}`, close: () => server.close() }) + }) + }) +} + +// ── Config + env writing (mirrors e2e/fixtures.ts) ───────────────────── + +function createSandbox() { + const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`)) + const hermesHome = path.join(root, 'hermes-home') + const userDataDir = path.join(root, 'electron-user-data') + fs.mkdirSync(hermesHome, { recursive: true }) + fs.mkdirSync(userDataDir, { recursive: true }) + return { root, hermesHome, userDataDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) } +} + +function writeMockConfig(hermesHome, mockUrl) { + fs.writeFileSync( + path.join(hermesHome, 'config.yaml'), + `# Auto-generated by dev-mock.mjs +model: + default: mock-model + provider: mock +providers: + mock: + api: ${mockUrl}/v1 + name: Mock + api_mode: chat_completions + key_env: MOCK_API_KEY + models: + mock-model: {} + context_length: 4096 +`, + 'utf8', + ) + fs.writeFileSync(path.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8') +} + +// ── Electron launch ──────────────────────────────────────────────────── + +function findElectron() { + const local = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron') + if (fs.existsSync(local)) return local + const r = spawnSync('which', ['electron'], { encoding: 'utf8' }) + if (r.status === 0 && r.stdout.trim()) return r.stdout.trim() + throw new Error('Electron binary not found. Run "npm install" from the repo root.') +} + +function assertDistBuilt() { + const electronMain = path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs') + const indexHtml = path.join(DESKTOP_ROOT, 'dist', 'index.html') + if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) { + throw new Error( + `Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` + + `Missing: ${electronMain}`, + ) + } +} + +// ── Main ─────────────────────────────────────────────────────────────── + +async function main() { + assertDistBuilt() + + console.log('Starting mock inference server...') + const mock = await startMockServer() + console.log(` Mock server: ${mock.url}`) + + const sandbox = createSandbox() + writeMockConfig(sandbox.hermesHome, mock.url) + console.log(` HERMES_HOME: ${sandbox.hermesHome}`) + + const electronBin = findElectron() + + const env = { + ...process.env, + HERMES_HOME: sandbox.hermesHome, + HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir, + HERMES_DESKTOP_IGNORE_EXISTING: '1', + HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT, + HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`, + } + + console.log('Launching Electron...') + const child = spawn(electronBin, [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], { + env, + cwd: DESKTOP_ROOT, + stdio: 'inherit', + }) + + child.on('exit', (code) => { + mock.close() + sandbox.cleanup() + process.exit(code ?? 0) + }) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts index c3268bc9cbd3..949b8f1020c2 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef } from 'react' import { playSpeechText } from '@/lib/voice-playback' +import { ownsAmbientCue } from '@/store/ambient' import { notifyError } from '@/store/notifications' import { $messages } from '@/store/session' import { $voicePlayback } from '@/store/voice-playback' @@ -65,9 +66,16 @@ export function useAutoSpeakReplies({ } markSpoken() - void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error => - notifyError(error, failureLabel) - ) + // Only one window voices a given reply when the same chat is open in + // several (reply.id is the shared backend message id). markSpoken already + // ran in every window, so peers just stay quiet. + void ownsAmbientCue(`speak:${reply.id}`).then(owns => { + if (owns) { + void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error => + notifyError(error, failureLabel) + ) + } + }) } // Re-check on a reply completing ($messages) and on the prior clip ending diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ce0e43d7d1cc..5d8d4ab0aadd 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -734,7 +734,7 @@ export function ChatBar({ autoCapitalize="off" autoCorrect="off" className={cn( - 'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', + 'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60', '**:data-ref-text:cursor-default', stacked && 'pl-3', diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 2587202c96a2..71491b87496d 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -360,4 +360,13 @@ export function normalizeComposerEditorDom(editor: HTMLElement) { editor.removeChild(last) } } + + // ContentEditable elements with no children can visually collapse to + // near-zero height in some browsers (especially Chromium), causing the + // composer to appear as a tiny dot/pixel. Ensure there's always at least + // one
so the element maintains intrinsic height. The CSS min-height + // is a belt; the
is suspenders — together they prevent the shrink. + if (editor.childNodes.length === 0) { + editor.appendChild(document.createElement('br')) + } } diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b47f0dfa9907..675d17b4d572 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -2,7 +2,7 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type * as React from 'react' -import { Suspense, useCallback, useMemo } from 'react' +import { Suspense, useCallback, useEffect, useMemo } from 'react' import { useLocation } from 'react-router-dom' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' @@ -20,6 +20,8 @@ import type { ChatMessage } from '@/lib/chat-messages' import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' +import { migrateSessionDraft } from '@/store/composer' +import { migrateQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' @@ -32,6 +34,7 @@ import { $introSeed, $resumeExhaustedSessionId, $sessions, + resolveComposerSessionKey, sessionMatchesStoredId, sessionPinId } from '@/store/session' @@ -276,7 +279,27 @@ export function ChatView({ const messagesEmpty = useStore(view.$messagesEmpty) const lastVisibleIsUser = useStore(view.$lastVisibleIsUser) const selectedSessionId = useStore(view.$storedId) + const sessions = useStore($sessions) const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) + + // Durable composer/queue scope (lineage root) so auto-compression tip rotation + // does not wipe an in-progress draft or orphan /queue entries. + const queueSessionKey = useMemo( + () => resolveComposerSessionKey(selectedSessionId, sessions), + [selectedSessionId, sessions] + ) + + // When the tip row arrives after compression, migrate any tip-keyed stash onto + // the durable lineage key before the composer remounts onto that key. + useEffect(() => { + if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) { + return + } + + migrateSessionDraft(selectedSessionId, queueSessionKey) + migrateQueuedPrompts(selectedSessionId, queueSessionKey) + }, [queueSessionKey, selectedSessionId]) + // A tile IS its session — no route involved, never "mismatched". const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId const isRoutedSessionView = Boolean(routedSessionId) @@ -524,7 +547,7 @@ export function ChatView({ onSteer={onSteer} onSubmit={onSubmit} onTranscribeAudio={onTranscribeAudio} - queueSessionKey={selectedSessionId} + queueSessionKey={queueSessionKey} sessionId={activeSessionId} state={chatBarState} /> diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 05a4d804cb8c..e0a669bb300d 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -13,6 +13,7 @@ import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { Activity, + AppWindow, Archive, BarChart3, ChevronLeft, @@ -59,6 +60,7 @@ import { openPetGenerate } from '@/store/pet-generate' import { requestStartWorkSession } from '@/store/projects' import { runGatewayRestart } from '@/store/system-actions' import { applyBackendUpdate } from '@/store/updates' +import { canOpenNewWindow, openNewWindow } from '@/store/windows' import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' import { isUserTheme, resolveTheme } from '@/themes/user-themes' @@ -413,6 +415,18 @@ export function CommandPalette() { label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) }, + ...(canOpenNewWindow() + ? [ + { + action: 'session.newWindow', + icon: AppWindow, + id: 'nav-new-window', + keywords: ['window', 'instance', 'open', 'new'], + label: t.keybinds.actions['session.newWindow'], + run: () => void openNewWindow() + } + ] + : []), { action: 'view.showTerminal', icon: Terminal, diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index 63e424dd570e..ee47db73642a 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -159,9 +159,10 @@ export function useGatewayBoot({ // with a short TTL, so the ticket baked into the cached conn.wsUrl is // dead on every reconnect after the initial boot — reusing it surfaces // as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl - // mints a fresh ticket (or throws a reauth error in OAuth mode rather - // than connecting with a stale one). For local/token gateways the URL - // carries a long-lived token and the re-mint is a cheap no-op. + // mints a fresh ticket rather than connecting with a stale one. An + // explicit auth rejection asks for sign-in; transport failures stay in + // this reconnect loop. For local/token gateways the URL carries a + // long-lived token and the re-mint is a cheap no-op. const wsUrl = await resolveGatewayWsUrl(desktop, conn) await gateway.connect(wsUrl) @@ -459,9 +460,9 @@ export function useGatewayBoot({ publish(conn) // Mint a fresh WS URL right before connecting. For OAuth gateways the // ticket is single-use with a short TTL, so the ticket baked into - // conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on - // failure, throws a reauth error rather than connecting with a dead - // ticket (which would surface as an opaque "connection closed"). + // conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it rather than + // connecting with a dead ticket. Auth rejection asks for sign-in; + // connectivity failures remain retryable. const wsUrl = await resolveGatewayWsUrl(desktop, conn) await gateway.connect(wsUrl) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index d6c9ab0e029b..72bd08c98b51 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -69,9 +69,10 @@ export function useGatewayRequest() { setConnection(conn) // Re-mint the WS URL before reconnecting. OAuth tickets are single-use // and short-lived, so the cached conn.wsUrl ticket is dead here; - // resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than - // connecting with a stale ticket. Stash it so requestGateway can show - // the actionable "sign in again" message. + // resolveGatewayWsUrl() never connects with a stale ticket. An explicit + // auth rejection becomes a reauth error; transport failures remain + // retryable. Stash only the former so requestGateway can show the + // actionable "sign in again" message. const wsUrl = await resolveGatewayWsUrl(desktop, conn) await existing.connect(wsUrl) diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 34a6bfa1e1c6..4df1edb202c4 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -40,7 +40,7 @@ import { switcherActive, switcherJustClosed } from '@/store/session-switcher' -import { openNewSessionInNewWindow } from '@/store/windows' +import { openNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus' @@ -145,7 +145,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) }, 'session.newTab': () => deps.openNewSessionTab(), - 'session.newWindow': () => void openNewSessionInNewWindow(), + 'session.newWindow': () => void openNewWindow(), // ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus // falls through to the recent-session switcher. 'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)), diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 09ba6bdb52ad..9d2ded2e5f6c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -483,7 +483,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { flushQueuedDeltas(sessionId) - playCompletionSound() + // Keyed by session so only one window beeps when several are open. + playCompletionSound(sessionId) const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered) completeAssistantMessage(sessionId, finalText, payload?.response_previewed) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 35ebd968e06b..f2409dd060a5 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -5,6 +5,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' +import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' import { @@ -198,6 +199,89 @@ describe('active stored-session id rotation routing', () => { expect($activeSessionStoredIdRotation.get()).toBeNull() }) + it('keeps draft on the previous tip when the new tip row is not loaded yet', async () => { + const tipBefore = 'tip-root' + const tipAfter = 'tip-new-unloaded' + const runtimeSessionId = 'runtime-gap' + const activeSessionIdRef: MutableRefObject = { current: runtimeSessionId } + const selectedStoredSessionIdRef: MutableRefObject = { current: tipBefore } + const navigate = vi.fn() + + setSessions([]) + stashSessionDraft(tipBefore, 'typed during gap', []) + setSelectedStoredSessionId(tipBefore) + setActiveSessionId(runtimeSessionId) + + render( + tipBefore} + navigate={navigate} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + act(() => { + setActiveSessionStoredIdRotation({ + nextStoredSessionId: tipAfter, + previousStoredSessionId: tipBefore, + runtimeSessionId + }) + }) + + await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter)) + expect(takeSessionDraft(tipBefore).text).toBe('typed during gap') + expect(takeSessionDraft(tipAfter).text).toBe('') + + clearSessionDraft(tipBefore) + clearSessionDraft(tipAfter) + setActiveSessionId(null) + }) + + it('parks an in-progress composer draft on the lineage root across tip rotation', async () => { + // Desktop draft must stay on the durable composer key (lineage root), not + // move onto the fresh tip — ChatBar scopes drafts via resolveComposerSessionKey. + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + const runtimeSessionId = 'runtime-desktop-thinking' + const activeSessionIdRef: MutableRefObject = { current: runtimeSessionId } + const selectedStoredSessionIdRef: MutableRefObject = { current: tipBefore } + const navigate = vi.fn() + const typedWhileThinking = 'follow up I am still typing during thinking' + + setSessions([storedSession({ id: tipAfter, message_count: 2, _lineage_root_id: tipBefore })]) + stashSessionDraft(tipBefore, typedWhileThinking, []) + setSelectedStoredSessionId(tipBefore) + setActiveSessionId(runtimeSessionId) + + render( + tipBefore} + navigate={navigate} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + act(() => { + setActiveSessionStoredIdRotation({ + nextStoredSessionId: tipAfter, + previousStoredSessionId: tipBefore, + runtimeSessionId + }) + }) + + await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter)) + // Durable key remains the lineage root — same scope ChatBar will keep using. + expect(takeSessionDraft(tipBefore).text).toBe(typedWhileThinking) + expect(takeSessionDraft(tipAfter).text).toBe('') + + clearSessionDraft(tipBefore) + clearSessionDraft(tipAfter) + setActiveSessionId(null) + setSessions([]) + }) + it('does not overwrite a newer route intent before its resume effect has synchronized selection', async () => { const activeSessionIdRef: MutableRefObject = { current: 'runtime-A' } const selectedStoredSessionIdRef: MutableRefObject = { current: 'stored-A' } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index f789402db297..545cc3cda2bd 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -8,7 +8,8 @@ import { useI18n } from '@/i18n' import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { isMissingRpcMethod } from '@/lib/gateway-rpc' import { setSessionYolo } from '@/lib/yolo-session' -import { clearQueuedPrompts } from '@/store/composer-queue' +import { migrateSessionDraft } from '@/store/composer' +import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -25,6 +26,7 @@ import { $sessions, $yoloActive, type NewChatWorkspaceTarget, + resolveComposerSessionKey, sessionPinId, setActiveSessionId, setActiveSessionStoredIdRotation, @@ -234,14 +236,35 @@ export function useSessionActions({ return } - setSelectedStoredSessionId(storedIdRotation.nextStoredSessionId) - selectedStoredSessionIdRef.current = storedIdRotation.nextStoredSessionId + // Park unsent draft/queue on the durable lineage key (not the new tip). + // ChatBar scopes composer state on resolveComposerSessionKey(); migrating + // onto the tip while the composer is still bound to the root can lose newer + // live editor text on a brief remount. If the new tip row is not in + // $sessions yet, resolveComposerSessionKey falls back to the tip id — prefer + // the previous id (usually the lineage root) in that gap. + const previousId = storedIdRotation.previousStoredSessionId + const nextId = storedIdRotation.nextStoredSessionId + const sessions = $sessions.get() + const resolvedNext = resolveComposerSessionKey(nextId, sessions) + + const durableKey = + resolvedNext && resolvedNext !== nextId + ? resolvedNext + : (resolveComposerSessionKey(previousId, sessions) ?? previousId) + + migrateSessionDraft(previousId, durableKey) + migrateSessionDraft(nextId, durableKey) + migrateQueuedPrompts(previousId, durableKey) + migrateQueuedPrompts(nextId, durableKey) + + setSelectedStoredSessionId(nextId) + selectedStoredSessionIdRef.current = nextId // A route overlay/page has no routed session id, but the underlying selected // chat still needs to follow the continuation. Update that selection in // place without navigating out of the surface the user deliberately opened. - if (routedStoredSessionId === storedIdRotation.previousStoredSessionId) { - navigate(sessionRoute(storedIdRotation.nextStoredSessionId), { replace: true }) + if (routedStoredSessionId === previousId) { + navigate(sessionRoute(nextId), { replace: true }) } }, [activeSessionIdRef, getRoutedStoredSessionId, navigate, selectedStoredSessionIdRef, storedIdRotation]) diff --git a/apps/desktop/src/app/settings/billing/errors.ts b/apps/desktop/src/app/settings/billing/errors.ts index f368a9e0abc1..0357bf424421 100644 --- a/apps/desktop/src/app/settings/billing/errors.ts +++ b/apps/desktop/src/app/settings/billing/errors.ts @@ -32,19 +32,19 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat case 'insufficient_scope': return { action: { type: 'step_up' }, - message: 'This needs terminal billing enabled. Start a top-up to enable it, then retry.', - title: 'Terminal billing needs approval' + message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.', + title: 'Remote Spending needs approval' } case 'remote_spending_revoked': { const who = refusal.actor === 'admin' - ? 'An admin turned off terminal billing for this terminal.' - : 'You turned off terminal billing for this terminal.' + ? 'An admin stopped remote spending for this terminal.' + : 'You stopped remote spending for this terminal.' return { action: portalAction(refusal.portalUrl), message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`, - title: 'Terminal billing was turned off' + title: 'Remote spending was stopped' } } @@ -60,8 +60,9 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat case 'remote_spending_disabled': return { action: portalAction(refusal.portalUrl), - message: 'Terminal billing is off for this account — an admin must enable it on the portal.', - title: 'Terminal billing is off' + message: + "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.", + title: 'Remote spending is off' } case 'role_required': diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 27b702d99d34..29b8f95e82ab 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -74,7 +74,9 @@ describe('BillingSettings', () => { expect(screen.getByText('Ultra · $200/mo')).toBeTruthy() expect(screen.getByText('Visa •••• 3206')).toBeTruthy() expect( - screen.getByText('Terminal billing is off for this account — an admin must enable it on the portal.') + screen.getByText( + "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page." + ) ).toBeTruthy() expect(screen.queryByRole('button', { name: '$100' })).toBeNull() expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy() @@ -197,10 +199,8 @@ describe('BillingSettings', () => { }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) - expect(await screen.findByText('Terminal billing needs approval:')).toBeTruthy() - expect( - screen.getByText('This needs terminal billing enabled. Start a top-up to enable it, then retry.') - ).toBeTruthy() + expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy() + expect(screen.getByText('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')).toBeTruthy() expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy() }) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 94ffda3b7c64..a56404757545 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun {row.pill && {row.pill.label}} {row.secondaryPill && {row.secondaryPill}} {row.chips?.map(chip => ( - ))} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index cd7c74507a98..90569e166da9 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -65,7 +65,7 @@ describe('deriveBillingView', () => { const buyCredits = view.accountRows.find(row => row.id === 'buy_credits') expect(buyCredits?.description).toBe( - 'Terminal billing is off for this account — an admin must enable it on the portal.' + "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page." ) expect(buyCredits?.chips).toBeUndefined() expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({ @@ -184,6 +184,103 @@ describe('deriveBillingView', () => { }) }) + it('free with catalog: tier chips render inline and open the portal', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + context: 'personal', + current: null, + tiers: [ + { + dollars_per_month_display: '$0', + is_current: false, + is_enabled: true, + monthly_credits: '0', + name: 'Free', + tier_id: 'free', + tier_order: 0 + }, + { + dollars_per_month_display: '$40', + is_current: false, + is_enabled: true, + monthly_credits: '3000', + name: 'Ultra', + tier_id: 'ultra', + tier_order: 2 + }, + { + dollars_per_month_display: '$20', + is_current: false, + is_enabled: true, + monthly_credits: '1000', + name: 'Plus', + tier_id: 'plus', + tier_order: 1 + } + ] + }) + ) + + const subscription = view.accountRows.find(row => row.id === 'subscription') + + expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.') + expect(subscription?.chips).toEqual([ + { disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: subscription?.action?.url }, + { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url } + ]) + }) + + it('subscriber who can change plans: current tier marked inert, others open the portal', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + context: 'personal', + tiers: [ + { + dollars_per_month_display: '$20', + is_current: true, + is_enabled: true, + monthly_credits: '1000', + name: 'Plus', + tier_id: 'plus', + tier_order: 1 + }, + { + dollars_per_month_display: '$40', + is_current: false, + is_enabled: true, + monthly_credits: '3000', + name: 'Ultra', + tier_id: 'ultra', + tier_order: 2 + } + ] + }) + ) + + const subscription = view.accountRows.find(row => row.id === 'subscription') + + expect(subscription?.chips).toEqual([ + { disabled: true, label: '✓ Plus · $20/mo · $1,000 credits/mo' }, + { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url } + ]) + }) + + it('members and team contexts get no tier chips', () => { + const member = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' }) + ) + + const team = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState)) + + expect(member.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined() + expect(team.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined() + }) + it('clamps overdrawn subscription credits to $0 and names the overage', () => { const view = deriveBillingView( okBilling(todayBillingState), diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 33ee75e77fb9..6fda6cc730f4 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -41,6 +41,8 @@ export interface BillingRowActionView { export interface BillingChipView { disabled: boolean label: string + /** When set, clicking the chip opens this URL externally. */ + url?: string } export interface BillingAccountRowView { @@ -272,6 +274,37 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView } } +/** + * Tier catalog as chips for accounts that can change plans; the current plan is + * inert, every other opens the portal where the change/start happens. + */ +function subscriptionTierChips( + subscription: null | SubscriptionStateResponse, + manageUrl: string +): BillingChipView[] | undefined { + // Teams have no personal subscription to sell into. + if (!subscription?.can_change_plan || subscription.context === 'team') { + return undefined + } + + const tiers = (subscription.tiers ?? []) + .filter(tier => tier.is_enabled && tier.tier_order > 0) + .sort((a, b) => a.tier_order - b.tier_order) + + if (tiers.length === 0) { + return undefined + } + + return tiers.map(tier => { + // Monthly credits are dollars; NAS sends a bare decimal string. + const credits = Number((tier.monthly_credits ?? '').replace(/,/g, '')) + const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : '' + const label = `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}` + + return tier.is_current ? { disabled: true, label: `✓ ${label}` } : { disabled: false, label, url: manageUrl } + }) +} + function subscriptionRow( billing: BillingStateResponse, subscription: null | SubscriptionStateResponse, @@ -283,13 +316,18 @@ function subscriptionRow( const value = current?.tier_name ?? fallbackPlan const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at) const unavailable = subscriptionResult && !subscriptionResult.ok + const chips = subscriptionTierChips(subscription, manageUrl) return { action: { label: 'Adjust plan ↗', url: manageUrl }, caption: unavailable ? 'Subscription details are unavailable; opening the portal is still available.' : `Renews ${renewal}`, - description: 'Review your plan and change it from the billing portal.', + chips, + description: + !current && chips + ? 'Paid models need a subscription — pick a plan to start it on the portal.' + : 'Review your plan and change it from the billing portal.', id: 'subscription', secondaryPill: 'opens portal', title: 'Subscription', @@ -458,7 +496,7 @@ function deriveUsageRows( value: clamp01(usedFraction) } : undefined, - caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly terminal billing spend', + caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending', id: 'monthly_cap', title: 'Monthly spend cap', value diff --git a/apps/desktop/src/app/settings/billing/use-step-up.ts b/apps/desktop/src/app/settings/billing/use-step-up.ts index 7658dc917567..2966f602a41a 100644 --- a/apps/desktop/src/app/settings/billing/use-step-up.ts +++ b/apps/desktop/src/app/settings/billing/use-step-up.ts @@ -121,7 +121,7 @@ export function useStepUpFlow() { if (!result.data.granted) { setMessage({ kind: 'error', - text: 'Verification finished without granting billing management access.', + text: 'Verification finished without allowing Remote Spending for this terminal.', title: 'Verification was not approved' }) @@ -134,7 +134,7 @@ export function useStepUpFlow() { ]) setMessage({ kind: 'success', - text: 'Billing management access was verified.', + text: 'Remote Spending is allowed for this terminal.', title: 'Verification complete' }) }, [api, gateway, queryClient, unsubscribe]) diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 2674fa191039..50708ef4b88a 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -1,3 +1,4 @@ +import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type { ChangeEvent } from 'react' import { useEffect, useMemo, useRef, useState } from 'react' @@ -6,6 +7,7 @@ import { useSearchParams } from 'react-router-dom' import { Button } from '@/components/ui/button' import { getElevenLabsVoices, getHermesConfigSchema, saveHermesConfig } from '@/hermes' import { useI18n } from '@/i18n' +import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { notify, notifyError } from '@/store/notifications' import type { ConfigFieldSchema, HermesConfigRecord } from '@/types/hermes' @@ -18,7 +20,7 @@ import { enumOptionsFor, getNested, isExternalMemoryProvider, sectionFieldEntrie import { MemoryConnect } from './memory/connect' import { ProviderConfigPanel } from './memory/provider-config-panel' import { ModelSettings, ModelSettingsSkeleton } from './model-settings' -import { EmptyState, LoadingState, SettingsContent } from './primitives' +import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives' // On the Voice page, only surface the sub-fields of the *selected* TTS/STT // provider — otherwise every provider's options render at once (the "totally @@ -53,6 +55,7 @@ export function ConfigSettings({ }) { const { t } = useI18n() const c = t.settings.config + const keepAwake = useStore($keepAwake) // The editable draft is local (debounced autosave watches it), but it's seeded // from — and saved back through — the shared config cache, so edits are visible // in the MCP/model surfaces and reopening the page doesn't reload-flash. @@ -269,6 +272,11 @@ export function ConfigSettings({ )} + {/* Device-local desktop pref (not config.yaml) — lives here since keeping + the machine awake is a power-user knob. */} + {activeSectionId === 'advanced' && ( + + )} {visibleFields.length === 0 ? ( ) : ( diff --git a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx index ab7b29da9884..aa0c5ffd8661 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' +import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes' @@ -7,7 +8,7 @@ import { SlidersHorizontal } from '@/lib/icons' import { notifyError } from '@/store/notifications' import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes' -import { ListRow, LoadingState, Pill } from '../primitives' +import { ListRow, Pill } from '../primitives' import { FieldControl, FieldTitle } from './field-control' import { ProviderConfigModal } from './provider-config-modal' @@ -95,7 +96,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) { ) } - return + return } const inlineFields = config.fields.filter(field => field.inline) diff --git a/apps/desktop/src/app/settings/notifications-settings.tsx b/apps/desktop/src/app/settings/notifications-settings.tsx index efb0bf662f6e..b2810f691fed 100644 --- a/apps/desktop/src/app/settings/notifications-settings.tsx +++ b/apps/desktop/src/app/settings/notifications-settings.tsx @@ -3,7 +3,6 @@ import type { ReactNode } from 'react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound' import { triggerHaptic } from '@/lib/haptics' @@ -20,7 +19,7 @@ import { import { notify } from '@/store/notifications' import { CONTROL_TEXT } from './constants' -import { ListRow, SectionHeading, SettingsContent } from './primitives' +import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)' @@ -28,32 +27,6 @@ function Caption({ children, className }: { children: ReactNode; className?: str return

{children}

} -function ToggleRow(props: { - checked: boolean - description: string - disabled?: boolean - label: string - onChange: (on: boolean) => void -}) { - return ( - { - triggerHaptic('selection') - props.onChange(on) - }} - /> - } - description={props.description} - title={props.label} - /> - ) -} - export function NotificationsSettings() { const { t } = useI18n() const prefs = useStore($nativeNotifyPrefs) diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 2d9d1906ef5f..4e55a2f9f4b9 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from 'react' import { PageLoader } from '@/components/page-loader' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { triggerHaptic } from '@/lib/haptics' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -108,8 +110,50 @@ export function ListRow({ ) } +// A labelled on/off row — the canonical device-pref switch (haptic baked in). +export function ToggleRow({ + checked, + description, + disabled, + label, + onChange +}: { + checked: boolean + description?: string + disabled?: boolean + label: string + onChange: (on: boolean) => void +}) { + return ( + { + triggerHaptic('selection') + onChange(on) + }} + /> + } + description={description} + title={label} + /> + ) +} + +// The settings panels render this as the sole child of the top-padded +// OverlayMain (pt = titlebar + 1rem, no bottom pad — see settings/index.tsx). +// Cancel that top pad so the loader centers in the whole card, not just the +// band beneath it. Inline loaders (mid-panel) should use directly. export function LoadingState({ label }: { label: string }) { - return + return ( + + ) } // Canonical implementation lives in components/ui; re-exported so the many diff --git a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx index b4920e1ec483..f6edaee0d7e3 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx @@ -36,7 +36,7 @@ describe('ResponseLoadingIndicator timer', () => { const sessionA = renderIndicator() act(() => vi.advanceTimersByTime(5_000)) - expect(screen.getByText('5s')).toBeTruthy() + expect(screen.getAllByText((_, node) => node?.textContent === '5s').length).toBeGreaterThan(0) sessionA.unmount() $activeSessionId.set('session-b') @@ -44,13 +44,13 @@ describe('ResponseLoadingIndicator timer', () => { const sessionB = renderIndicator() act(() => vi.advanceTimersByTime(3_000)) - expect(screen.getByText('3s')).toBeTruthy() + expect(screen.getAllByText((_, node) => node?.textContent === '3s').length).toBeGreaterThan(0) sessionB.unmount() $activeSessionId.set('session-a') $turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime()) renderIndicator() - expect(screen.getByText('8s')).toBeTruthy() + expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0) }) }) diff --git a/apps/desktop/src/components/chat/activity-timer-text.tsx b/apps/desktop/src/components/chat/activity-timer-text.tsx index aa439eb247d5..d9ca1c292e29 100644 --- a/apps/desktop/src/components/chat/activity-timer-text.tsx +++ b/apps/desktop/src/components/chat/activity-timer-text.tsx @@ -1,6 +1,7 @@ import { cn } from '@/lib/utils' import { formatElapsed } from './activity-timer' +import { StableText } from './stable-text' interface ActivityTimerTextProps { seconds: number @@ -9,16 +10,16 @@ interface ActivityTimerTextProps { export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) { return ( - {formatElapsed(seconds)} - + ) } diff --git a/apps/desktop/src/components/chat/stable-text.tsx b/apps/desktop/src/components/chat/stable-text.tsx new file mode 100644 index 000000000000..1e7e03815cd6 --- /dev/null +++ b/apps/desktop/src/components/chat/stable-text.tsx @@ -0,0 +1,23 @@ +import { cn } from '@/lib/utils' + +interface StableTextProps { + children: string + className?: string +} + +/** + * Renders text as a row of 1ch-wide cells so individual characters can't + * shift the layout as they change (e.g. digits in a ticking timer). + * Works with any proportional font — no need for font-mono. + */ +export function StableText({ children, className }: StableTextProps) { + return ( + + {children.split('').map((char, i) => ( + + {char} + + ))} + + ) +} diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index 79bc9935804c..0268755d5632 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -35,11 +35,20 @@ function forcedPreview(): boolean { } } +function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) +} + export function GatewayConnectingOverlay() { const gatewayState = useStore($gatewayState) const boot = useStore($desktopBoot) const gatewaySwitching = useStore($gatewaySwitching) const [previewing] = useState(forcedPreview) + const reduce = prefersReducedMotion() + // Under reduced motion, skip the multi-phase exit choreography (text-out → + // hold → overlay fade) and jump straight to gone so the overlay unmounts + // the instant the gateway opens. E2E screenshots rely on this to avoid + // catching the overlay mid-fade. const [phase, setPhase] = useState('live') // Once cold boot has completed once, never resurrect the fullscreen overlay // — soft gateway switches keep the shell and reskeleton the sidebar instead. @@ -81,9 +90,13 @@ export function GatewayConnectingOverlay() { } if (gatewayState === 'open' && shownRef.current) { - setPhase('text-out') + // Under reduced motion, skip the multi-phase exit choreography + // (text-out → hold → overlay fade) and jump straight to gone so the + // overlay unmounts the instant the gateway opens. E2E screenshots + // rely on this to avoid catching the overlay mid-fade. + setPhase(reduce ? 'gone' : 'text-out') } - }, [phase, previewing, gatewayState]) + }, [phase, previewing, gatewayState, reduce]) // Advance the exit choreography: text-out -> overlay-out -> gone. useEffect(() => { diff --git a/apps/desktop/src/components/ui/decode-text.tsx b/apps/desktop/src/components/ui/decode-text.tsx index e5bc470ab076..243de60f6205 100644 --- a/apps/desktop/src/components/ui/decode-text.tsx +++ b/apps/desktop/src/components/ui/decode-text.tsx @@ -23,6 +23,10 @@ export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*' const TICK_MS = 45 const HOLD_TICKS = 16 +function prefersReducedMotion(): boolean { + return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches) +} + function scrambled(tail: string, resolvedCount: number): string { return Array.from(tail, (ch, i) => ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0] @@ -62,6 +66,15 @@ export function DecodeText({ return } + // Under reduced motion, skip the scramble interval and render the fully + // resolved text immediately. The cursor blink (CSS animation) is also + // killed by the blanket reduced-motion CSS rule. + if (prefersReducedMotion()) { + setTail(tailText) + + return + } + let resolved = 0 let hold = 0 diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index f239ff9fc117..4d17d3fe0935 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1,3 +1,5 @@ +import type { GatewayWsUrlResult } from '@hermes/shared' + import type { PetOverlayBounds, PetOverlayControl, @@ -24,15 +26,21 @@ declare global { // Keepalive: mark a pool profile backend as recently used so the idle // reaper spares it while its chat is active. touchBackend: (profile?: string | null) => Promise<{ ok: boolean }> - getGatewayWsUrl: (profile?: null | string) => Promise + getGatewayWsUrl: (profile?: null | string) => Promise // Open (or focus) a standalone OS window for a single chat session so // the user can work with multiple chats side by side. Returns ok:false // with an error code when the sessionId is empty/invalid. `watch` opens // a spectator window (lazy resume — no agent build) for live-streaming // a running subagent's session. openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> - // Open (or focus) a compact secondary window on the new-session draft. - openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }> + // Open a new full-chrome app window — a peer instance of the primary that + // renders the complete app against the shared backend, so the user can run + // multiple GUI windows at once. + openWindow: () => Promise<{ ok: boolean; error?: string }> + // Claim a one-shot cross-window ambient cue (turn-end sound / spoken + // reply). Resolves true for the first window to claim a key, false for + // peers — so N open windows don't all fire the same cue. + claimAmbientCue: (key: string) => Promise // The pop-out pet overlay: a transparent always-on-top window hosting only // the mascot. The main renderer drives it (open/close/drag + state push); // the overlay sends control messages back (pop-in, composer submit). @@ -90,6 +98,7 @@ declare global { setTitleBarTheme?: (payload: HermesTitleBarTheme) => void setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void setTranslucency?: (payload: { intensity: number }) => void + setKeepAwake?: (on: boolean) => void setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise openPreviewInBrowser?: (url: string) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 1aaf1befa745..0e24a46f5212 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -225,7 +225,7 @@ export const en: Translations = { 'nav.agents': 'Open agents', 'session.new': 'New session', 'session.newTab': 'New session tab', - 'session.newWindow': 'New session in window', + 'session.newWindow': 'New window', 'session.next': 'Next session', 'session.prev': 'Previous session', 'session.slot.1': 'Switch to recent session 1', @@ -523,7 +523,9 @@ export const en: Translations = { failedLoad: 'Settings failed to load', autosaveFailed: 'Autosave failed', imported: 'Config imported', - invalidJson: 'Invalid config JSON' + invalidJson: 'Invalid config JSON', + keepAwakeTitle: 'Keep computer awake', + keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.' }, credentials: { pasteKey: 'Paste key', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index 018a60854665..9a7aa831f61c 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -621,7 +621,9 @@ export const ja = defineLocale({ failedLoad: '設定の読み込みに失敗しました', autosaveFailed: '自動保存に失敗しました', imported: '設定をインポートしました', - invalidJson: '設定 JSON が無効です' + invalidJson: '設定 JSON が無効です', + keepAwakeTitle: 'コンピューターをスリープさせない', + keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。' }, credentials: { pasteKey: 'キーを貼り付け', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 6ebb4447e92d..2b937f625549 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -435,6 +435,8 @@ export interface Translations { autosaveFailed: string imported: string invalidJson: string + keepAwakeTitle: string + keepAwakeDesc: string } credentials: { pasteKey: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 14bf7171a6de..ed2b59d1f228 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -609,7 +609,9 @@ export const zhHant = defineLocale({ failedLoad: '設定載入失敗', autosaveFailed: '自動儲存失敗', imported: '設定已匯入', - invalidJson: '設定 JSON 無效' + invalidJson: '設定 JSON 無效', + keepAwakeTitle: '保持電腦喚醒', + keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。' }, credentials: { pasteKey: '貼上金鑰', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index f79945409f53..5e13fd496311 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -220,7 +220,7 @@ export const zh: Translations = { 'nav.agents': '打开智能体', 'session.new': '新建会话', 'session.newTab': '新建会话标签', - 'session.newWindow': '在新窗口中新建会话', + 'session.newWindow': '新建窗口', 'session.next': '下一个会话', 'session.prev': '上一个会话', 'session.slot.1': '切换到最近会话 1', @@ -720,7 +720,9 @@ export const zh: Translations = { failedLoad: '设置加载失败', autosaveFailed: '自动保存失败', imported: '配置已导入', - invalidJson: '配置 JSON 无效' + invalidJson: '配置 JSON 无效', + keepAwakeTitle: '保持电脑唤醒', + keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。' }, credentials: { pasteKey: '粘贴密钥', diff --git a/apps/desktop/src/lib/completion-sound.ts b/apps/desktop/src/lib/completion-sound.ts index 4457d912b78d..1557c58e419e 100644 --- a/apps/desktop/src/lib/completion-sound.ts +++ b/apps/desktop/src/lib/completion-sound.ts @@ -1,6 +1,7 @@ // Completion sound bank for agent turn-end cues. // Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1. +import { ownsAmbientCue } from '@/store/ambient' import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound' import { $hapticsMuted } from '@/store/haptics' @@ -452,13 +453,22 @@ export function previewCompletionSound(variantId?: number) { playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get())) } -// Plays the selected completion cue on any `message.complete`. -export function playCompletionSound() { +// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey +// (the session id) so only one window beeps when several are open — the mute +// check runs first, so a muted window never claims the cue out from under an +// audible peer. +export function playCompletionSound(dedupeKey?: string) { if ($hapticsMuted.get()) { return } - playVariant($completionSoundVariantId.get()) + const play = () => playVariant($completionSoundVariantId.get()) + + if (!dedupeKey) { + return play() + } + + void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => owns && play()) } interface AirPuffSpec { diff --git a/apps/desktop/src/lib/gateway-ws-url.test.ts b/apps/desktop/src/lib/gateway-ws-url.test.ts index e8b09765923f..48c7c7416d95 100644 --- a/apps/desktop/src/lib/gateway-ws-url.test.ts +++ b/apps/desktop/src/lib/gateway-ws-url.test.ts @@ -12,23 +12,56 @@ describe('resolveGatewayWsUrl', () => { expect(getGatewayWsUrl).toHaveBeenCalledOnce() }) - it('throws a reauth error instead of falling back to the stale cached ticket', async () => { - const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('401 cookie expired')) + it('uses the structured URL returned across the Electron IPC boundary', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?ticket=fresh' }) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh') + }) + + it('throws a reauth error when the main process reports an auth rejection', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ + error: '401 cookie expired', + needsOauthLogin: true, + ok: false + }) + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf( GatewayReauthRequiredError ) }) - it('preserves the underlying mint failure as the cause', async () => { - const cause = new Error('401 cookie expired') - const getGatewayWsUrl = vi.fn().mockRejectedValue(cause) + it('preserves the main-process auth failure as the cause', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ + error: '401 cookie expired', + needsOauthLogin: true, + ok: false + }) + const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e) expect(error).toBeInstanceOf(GatewayReauthRequiredError) - expect((error as GatewayReauthRequiredError).cause).toBe(cause) + expect((error as GatewayReauthRequiredError).cause).toMatchObject({ message: '401 cookie expired' }) }) - it('throws a reauth error when the preload cannot mint (no method)', async () => { - await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError) + it('keeps a transport failure retryable instead of demanding sign-in', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ error: 'gateway timed out', ok: false }) + const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e) + + expect(error).toMatchObject({ message: 'gateway timed out' }) + expect(isGatewayReauthRequired(error)).toBe(false) + }) + + it('rethrows an unexpected transport rejection unchanged', async () => { + const cause = new Error('socket closed') + const getGatewayWsUrl = vi.fn().mockRejectedValue(cause) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBe(cause) + }) + + it('reports a missing preload method as an app capability error, not reauth', async () => { + const error = await resolveGatewayWsUrl({}, oauthConn).catch(e => e) + + expect(error).toMatchObject({ message: expect.stringMatching(/cannot refresh OAuth WebSocket tickets/i) }) + expect(isGatewayReauthRequired(error)).toBe(false) }) it('never returns the stale cached ticket on failure', async () => { @@ -45,6 +78,12 @@ describe('resolveGatewayWsUrl', () => { await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh') }) + it('uses a structured refreshed token URL when available', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?token=fresh' }) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh') + }) + it('falls back to the cached URL when minting fails (token is long-lived)', async () => { const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient')) await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl) diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 0260ce82c6c7..fae9e8d76c09 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -2,6 +2,7 @@ import { IconActivity as Activity, IconAlertCircle as AlertCircle, IconAlertTriangle as AlertTriangle, + IconAppWindow as AppWindow, IconArchive as Archive, IconArchiveOff as ArchiveOff, IconArrowUp as ArrowUp, @@ -120,6 +121,7 @@ export { Activity, AlertCircle, AlertTriangle, + AppWindow, Archive, ArchiveOff, ArrowUp, diff --git a/apps/desktop/src/lib/statusbar.ts b/apps/desktop/src/lib/statusbar.tsx similarity index 92% rename from apps/desktop/src/lib/statusbar.ts rename to apps/desktop/src/lib/statusbar.tsx index b06f9cc0d60e..0c0d22599c58 100644 --- a/apps/desktop/src/lib/statusbar.ts +++ b/apps/desktop/src/lib/statusbar.tsx @@ -1,5 +1,6 @@ import { useEffect, useState } from 'react' +import { StableText } from '@/components/chat/stable-text' import { compactNumber } from '@/lib/format' import type { UsageStats } from '@/types/hermes' @@ -72,5 +73,9 @@ export function LiveDuration({ since }: { since: number | null | undefined }) { return () => window.clearInterval(timer) }, [since]) - return since ? formatDuration(now - since) : null + if (!since) { + return null + } + + return {formatDuration(now - since)} } diff --git a/apps/desktop/src/store/ambient.ts b/apps/desktop/src/store/ambient.ts new file mode 100644 index 000000000000..0c7a8ee31d6d --- /dev/null +++ b/apps/desktop/src/store/ambient.ts @@ -0,0 +1,18 @@ +// One window owns each cross-window ambient cue (turn-end sound, spoken reply) +// so N open full windows don't all fire it for the same backend event. The main +// process is the race-free owner (see electron/event-dedupe.ts). Off Electron — +// or when the bridge/claim fails — every window emits, preserving the +// single-window behavior rather than going silent. +export async function ownsAmbientCue(key: string): Promise { + const claim = window.hermesDesktop?.claimAmbientCue + + if (!claim) { + return true + } + + try { + return await claim(key) + } catch { + return true + } +} diff --git a/apps/desktop/src/store/boot.ts b/apps/desktop/src/store/boot.ts index f25be5089db4..5300bdfc7d9a 100644 --- a/apps/desktop/src/store/boot.ts +++ b/apps/desktop/src/store/boot.ts @@ -33,12 +33,16 @@ export function applyDesktopBootProgress(progress: DesktopBootProgress) { const nextProgress = clampProgress(progress.progress) const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress + // Don't let a late progress event (error: null) clobber a previously-set + // boot failure — failDesktopBoot is terminal for this boot cycle. + const error = progress.error ?? (current.running ? null : current.error) + $desktopBoot.set({ ...current, ...progress, - error: progress.error ?? null, + error, progress: mergedProgress, - visible: progress.running || mergedProgress < 100 || Boolean(progress.error) + visible: progress.running || mergedProgress < 100 || Boolean(error) }) } diff --git a/apps/desktop/src/store/coding-status.test.ts b/apps/desktop/src/store/coding-status.test.ts index d83e31a4849e..7d1915095f07 100644 --- a/apps/desktop/src/store/coding-status.test.ts +++ b/apps/desktop/src/store/coding-status.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { HermesRepoStatus } from '@/global' import { $repoStatus, $repoStatusLoading, refreshRepoStatus } from './coding-status' -import { $currentCwd } from './session' +import { $currentCwd, $selectedStoredSessionId } from './session' const sampleStatus: HermesRepoStatus = { branch: 'feature/login', @@ -30,6 +30,7 @@ describe('refreshRepoStatus', () => { vi.useFakeTimers() $repoStatus.set(null) $currentCwd.set('') + $selectedStoredSessionId.set(null) delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) @@ -117,4 +118,27 @@ describe('refreshRepoStatus', () => { expect($repoStatus.get()).toEqual(sampleStatus) expect($repoStatusLoading.get()).toBe(false) }) + + it('refreshes when the stored session id changes even if the cwd is unchanged', async () => { + const probe = vi.fn(async () => sampleStatus) + stubProbe(probe) + + $currentCwd.set('/repo') + $selectedStoredSessionId.set('session-a') + // The cwd subscription fires on the set above; drain the debounced refresh. + vi.advanceTimersByTime(200) + await vi.runAllTicks() + + probe.mockClear() + + // Switch to a different session in the SAME repo dir. The cwd atom value is + // identical, so its subscription would not re-fire — but the stored-session + // id did change, which must still trigger a probe so the branch label + // tracks the new session's checked-out branch. + $selectedStoredSessionId.set('session-b') + vi.advanceTimersByTime(200) + await vi.runAllTicks() + + expect(probe).toHaveBeenCalledWith('/repo') + }) }) diff --git a/apps/desktop/src/store/coding-status.ts b/apps/desktop/src/store/coding-status.ts index 84f9c9b2aea5..0ed7eed5626d 100644 --- a/apps/desktop/src/store/coding-status.ts +++ b/apps/desktop/src/store/coding-status.ts @@ -4,7 +4,7 @@ import type { HermesGitWorktree, HermesRepoStatus } from '@/global' import { desktopGit } from '@/lib/desktop-git' import { $worktreeRefreshToken } from './projects' -import { $busy, $currentCwd } from './session' +import { $busy, $currentCwd, $selectedStoredSessionId } from './session' import { $workspaceChangeTick } from './workspace-events' // Live working-tree status for the active session's cwd — the data backbone of @@ -172,6 +172,13 @@ function scheduleRepoStatusRefresh(cwd?: null | string): void { // The active session's cwd changed (session switch / new chat) → re-probe. $currentCwd.subscribe(cwd => scheduleRepoStatusRefresh(cwd)) +// Switching sessions can land on the same cwd but a different checked-out +// branch (the agent ran `git checkout` in another session's terminal). The cwd +// subscription above won't fire when the path is identical, so the branch label +// would stay stale until a window focus or turn-settle triggers a refresh. +// Treat the stored-session id as a structural edge in its own right. +$selectedStoredSessionId.subscribe(() => scheduleRepoStatusRefresh()) + // A worktree add/remove (desktop op, or the agent's out-of-band git in a settled // turn / a window refocus — both already bump this token) → re-probe. $worktreeRefreshToken.subscribe(() => scheduleRepoStatusRefresh()) diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index b57242052db5..1b3a174ba9f4 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -5,6 +5,7 @@ import { addComposerAttachment, clearSessionDraft, type ComposerAttachment, + migrateSessionDraft, removeComposerAttachment, SESSION_DRAFTS_STORAGE_KEY, stashSessionDraft, @@ -106,4 +107,29 @@ describe('session drafts', () => { expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf') }) + + it('migrates a tip-keyed draft onto the post-compression tip', () => { + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + + stashSessionDraft(tipBefore, 'half typed while thinking', []) + + expect(migrateSessionDraft(tipBefore, tipAfter)).toBe(true) + expect(takeSessionDraft(tipAfter).text).toBe('half typed while thinking') + expect(takeSessionDraft(tipBefore).text).toBe('') + + clearSessionDraft(tipAfter) + }) + + it('does not overwrite a non-empty destination draft during migration', () => { + stashSessionDraft('from', 'old tip draft', []) + stashSessionDraft('to', 'already typed on new tip', []) + + expect(migrateSessionDraft('from', 'to')).toBe(false) + expect(takeSessionDraft('to').text).toBe('already typed on new tip') + expect(takeSessionDraft('from').text).toBe('old tip draft') + + clearSessionDraft('from') + clearSessionDraft('to') + }) }) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 156517c33053..b984fe8bfc3b 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -171,6 +171,41 @@ export function takeSessionDraft(scope: string | null | undefined): SessionDraft export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', []) +/** + * Move a stashed composer draft from one session key onto another. + * + * Auto-compression rotates the live stored tip id (root → continuation) while + * the user may still be typing. Drafts keyed on the obsolete tip would otherwise + * vanish from the composer when selection follows the new tip. No-op unless both + * keys resolve, differ, and the source has content. Does not overwrite a + * non-empty destination draft. + */ +export function migrateSessionDraft(fromKey: string | null | undefined, toKey: string | null | undefined): boolean { + const from = draftKey(fromKey) + const to = draftKey(toKey) + + if (!fromKey || !toKey || from === to) { + return false + } + + const source = draftsBySession.get(from) + + if (!source || (!source.text.trim() && source.attachments.length === 0)) { + return false + } + + const dest = draftsBySession.get(to) + + if (dest && (dest.text.trim() || dest.attachments.length > 0)) { + return false + } + + stashSessionDraft(toKey, source.text, source.attachments) + clearSessionDraft(fromKey) + + return true +} + export function setComposerDraft(value: string) { $composerDraft.set(value) } diff --git a/apps/desktop/src/store/keep-awake.test.ts b/apps/desktop/src/store/keep-awake.test.ts new file mode 100644 index 000000000000..4807e436ae4c --- /dev/null +++ b/apps/desktop/src/store/keep-awake.test.ts @@ -0,0 +1,33 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { storedBoolean } from '@/lib/storage' + +import { $keepAwake, setKeepAwake } from './keep-awake' + +const KEY = 'hermes.desktop.keepAwake.v1' +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const initialHermesDesktop = desktopWindow.hermesDesktop +const setKeepAwakeBridge = vi.fn() + +beforeEach(() => { + desktopWindow.hermesDesktop = { setKeepAwake: setKeepAwakeBridge } as unknown as Window['hermesDesktop'] + setKeepAwake(false) + setKeepAwakeBridge.mockClear() +}) + +afterEach(() => { + desktopWindow.hermesDesktop = initialHermesDesktop +}) + +describe('keep-awake store', () => { + it('persists the pref and mirrors it to the main process', () => { + setKeepAwake(true) + expect($keepAwake.get()).toBe(true) + expect(storedBoolean(KEY, false)).toBe(true) + expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(true) + + setKeepAwake(false) + expect(storedBoolean(KEY, true)).toBe(false) + expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(false) + }) +}) diff --git a/apps/desktop/src/store/keep-awake.ts b/apps/desktop/src/store/keep-awake.ts new file mode 100644 index 000000000000..f2d2022391f3 --- /dev/null +++ b/apps/desktop/src/store/keep-awake.ts @@ -0,0 +1,29 @@ +/** + * Keep-awake — stop the machine sleeping during long, unattended runs. + * + * A device-local preference (each computer keeps its own), off by default. This + * atom backs the Settings → Advanced toggle and mirrors changes to the main + * process, which owns the real power-save blocker AND its own persisted copy — + * so a cold launch restores the blocker without the renderer visiting Settings + * (see electron/main.ts + electron/power-save.ts). Linux/web without the bridge + * just no-op. + */ + +import { atom } from 'nanostores' + +import { persistBoolean, storedBoolean } from '@/lib/storage' + +const KEY = 'hermes.desktop.keepAwake.v1' + +export const $keepAwake = atom(typeof window === 'undefined' ? false : storedBoolean(KEY, false)) + +export function setKeepAwake(on: boolean): void { + $keepAwake.set(on) +} + +if (typeof window !== 'undefined') { + $keepAwake.subscribe(on => { + persistBoolean(KEY, on) + window.hermesDesktop?.setKeepAwake?.(on) + }) +} diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index a3c816c762f6..e4b9b08b66d0 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -12,6 +12,7 @@ import { $unreadFinishedSessionIds, applyConfiguredDefaultProjectDir, mergeSessionPage, + resolveComposerSessionKey, sessionPinId, setCurrentCwd, setSelectedStoredSessionId, @@ -85,6 +86,21 @@ describe('sessionPinId', () => { }) }) +describe('resolveComposerSessionKey', () => { + it('keeps the lineage root across compression tip rotation', () => { + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + const sessions = [session({ id: tipAfter, _lineage_root_id: tipBefore })] + + expect(resolveComposerSessionKey(tipBefore, [session({ id: tipBefore })])).toBe(tipBefore) + expect(resolveComposerSessionKey(tipAfter, sessions)).toBe(tipBefore) + }) + + it('falls back to the live id when the tip row is not loaded yet', () => { + expect(resolveComposerSessionKey('tip-new', [])).toBe('tip-new') + }) +}) + describe('mergeSessionPage', () => { it('returns the server page untouched when there is nothing to keep', () => { const previous = [session({ id: 'a' }), session({ id: 'b' })] diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index dbfd7dbc7995..3c8ea0b714e5 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -1,3 +1,4 @@ +import type { ConnectionState } from '@hermes/shared' import { atom, computed } from 'nanostores' import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading' @@ -143,6 +144,27 @@ export const sessionMatchesStoredId = ( storedSessionId: string ): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId +/** + * Stable composer + `/queue` scope for a selected stored session. + * + * Same durability rule as {@link sessionPinId}: prefer the lineage root so + * auto-compression tip rotation does not remount the composer onto an empty + * draft/queue key mid-keystroke. Falls back to the live id when the row is + * not in the in-memory list yet. + */ +export function resolveComposerSessionKey( + selectedSessionId: string | null | undefined, + sessions: readonly Pick[] +): string | null { + if (!selectedSessionId) { + return null + } + + const row = sessions.find(session => sessionMatchesStoredId(session, selectedSessionId)) + + return row ? sessionPinId(row) : selectedSessionId +} + /** Merge a fresh server session page into the in-memory list, keeping any * row the server omitted that we still want visible — both still-"working" * sessions and pinned sessions. @@ -213,7 +235,7 @@ export function mergeSessionPage( } export const $connection = atom(null) -export const $gatewayState = atom('idle') +export const $gatewayState = atom('idle') export const $sessions = atom([]) export const $sessionsTotal = atom(0) // Cron-job sessions (source === 'cron') are fetched as their own list so the @@ -319,7 +341,7 @@ export const $modelPickerOpen = atom(false) export const $sessionPickerOpen = atom(false) export const setConnection = (next: Updater) => updateAtom($connection, next) -export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) +export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) export const setSessions = (next: Updater) => updateAtom($sessions, next) export const setSessionsTotal = (next: Updater) => updateAtom($sessionsTotal, next) export const setCronSessions = (next: Updater) => updateAtom($cronSessions, next) diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts index 28ae3cc39c9f..e6c3f4974a56 100644 --- a/apps/desktop/src/store/windows.test.ts +++ b/apps/desktop/src/store/windows.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows' +import { canOpenNewWindow, canOpenSessionWindow, openNewWindow, openSessionInNewWindow } from './windows' const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } const initialHermesDesktop = desktopWindow.hermesDesktop @@ -13,11 +13,11 @@ vi.mock('./notifications', () => ({ function installBridge( openSessionWindow?: Window['hermesDesktop']['openSessionWindow'], - openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow'] + openWindow?: Window['hermesDesktop']['openWindow'] ) { desktopWindow.hermesDesktop = { ...(openSessionWindow ? { openSessionWindow } : {}), - ...(openNewSessionWindow ? { openNewSessionWindow } : {}) + ...(openWindow ? { openWindow } : {}) } as unknown as Window['hermesDesktop'] } @@ -106,37 +106,54 @@ describe('openSessionInNewWindow', () => { }) }) -describe('openNewSessionInNewWindow', () => { +describe('canOpenNewWindow', () => { + it('is false when the desktop bridge is absent', () => { + delete desktopWindow.hermesDesktop + expect(canOpenNewWindow()).toBe(false) + }) + + it('is false when the bridge lacks openWindow', () => { + installBridge(vi.fn().mockResolvedValue({ ok: true })) + expect(canOpenNewWindow()).toBe(false) + }) + + it('is true when the bridge exposes openWindow', () => { + installBridge(undefined, vi.fn().mockResolvedValue({ ok: true })) + expect(canOpenNewWindow()).toBe(true) + }) +}) + +describe('openNewWindow', () => { it('no-ops gracefully when the bridge is absent (web fallback)', async () => { delete desktopWindow.hermesDesktop - await openNewSessionInNewWindow() + await openNewWindow() expect(notifyError).not.toHaveBeenCalled() }) - it('no-ops when openNewSessionWindow is missing', async () => { + it('no-ops when openWindow is missing', async () => { installBridge(vi.fn().mockResolvedValue({ ok: true })) - await openNewSessionInNewWindow() + await openNewWindow() expect(notifyError).not.toHaveBeenCalled() }) it('invokes the bridge', async () => { - const openNew = vi.fn().mockResolvedValue({ ok: true }) - installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew) + const openWindow = vi.fn().mockResolvedValue({ ok: true }) + installBridge(undefined, openWindow) - await openNewSessionInNewWindow() + await openNewWindow() - expect(openNew).toHaveBeenCalledTimes(1) + expect(openWindow).toHaveBeenCalledTimes(1) expect(notifyError).not.toHaveBeenCalled() }) it('notifies on an ok:false result', async () => { - installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' })) + installBridge(undefined, vi.fn().mockResolvedValue({ ok: false, error: 'nope' })) - await openNewSessionInNewWindow() + await openNewWindow() expect(notifyError).toHaveBeenCalledTimes(1) }) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index a881aa4794c6..349b29d12ba0 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -6,7 +6,6 @@ import { notifyError } from './notifications' // never from the router. A "secondary" window renders a single chat without the // global session sidebar or the install / onboarding overlays. const SECONDARY_WINDOW_FLAG = 'secondary' -const NEW_SESSION_WINDOW_FLAG = '1' let secondaryWindowCache: boolean | null = null @@ -28,26 +27,6 @@ export function isSecondaryWindow(): boolean { return result } -let newSessionWindowCache: boolean | null = null - -export function isNewSessionWindow(): boolean { - if (newSessionWindowCache !== null) { - return newSessionWindowCache - } - - let result = false - - try { - result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG - } catch { - result = false - } - - newSessionWindowCache = result - - return result -} - let watchWindowCache: boolean | null = null // A "watch" window spectates a session that is being driven elsewhere (a @@ -78,11 +57,16 @@ export function canOpenSessionWindow(): boolean { return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function' } +// True when the shell can open a full peer app window (⌘⇧N / "New Window"). +export function canOpenNewWindow(): boolean { + return typeof window !== 'undefined' && typeof window.hermesDesktop?.openWindow === 'function' +} + type WindowOpenResult = { ok: boolean; error?: string } | undefined // Run a window-open bridge call, surfacing any failure as a toast. Shared by the -// session pop-out and the new-session pop-out. -async function openWindow(call: () => Promise, failMessage: string): Promise { +// session pop-out and the new-window opener. +async function runWindowOpen(call: () => Promise, failMessage: string): Promise { try { const result = await call() @@ -102,14 +86,18 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: return } - await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window') + await runWindowOpen( + () => window.hermesDesktop.openSessionWindow(sessionId, opts), + 'Could not open chat in a new window' + ) } -// Open a fresh compact window on the new-session draft. -export async function openNewSessionInNewWindow(): Promise { - if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') { +// Open a new full-chrome app window — a peer instance of the primary that +// renders the complete app against the shared backend. No-ops outside Electron. +export async function openNewWindow(): Promise { + if (!canOpenNewWindow()) { return } - await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window') + await runWindowOpen(() => window.hermesDesktop.openWindow(), 'Could not open a new window') } diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index d1e45ae6ed4b..86a87dbf0ea2 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -5,6 +5,22 @@ @import '@vscode/codicons/dist/codicon.css'; @custom-variant dark (&:is(.dark *)); +/* Blanket reduced-motion override: kill ALL CSS animations and transitions + when the user (or the E2E test harness via prefers-reduced-motion) requests + it. Per-component @media rules below handle specific cases; this catches + everything else so overlays and loading bars resolve instantly instead of + being caught mid-fade by a screenshot. */ +@media (prefers-reduced-motion: reduce) { + *, + *::before, + *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} + /* Sidebar sections: tall viewports give each its own scroller; compact ones (this variant) flatten everything into one shared scroll. See ChatSidebar. */ @custom-variant compact (@media (max-height: 768px)); diff --git a/apps/desktop/tsconfig.e2e.json b/apps/desktop/tsconfig.e2e.json new file mode 100644 index 000000000000..e0beb8961941 --- /dev/null +++ b/apps/desktop/tsconfig.e2e.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "@playwright/test"], + "composite": true + }, + "include": ["e2e", "playwright.config.ts"], + "exclude": ["src", "electron"] +} diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 8af5d47ffb29..a00bad75fb0a 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -23,5 +23,6 @@ } }, "include": ["src", "../shared/src"], + "exclude": ["e2e", "electron", "playwright.config.ts"], "references": [{ "path": "./tsconfig.electron.json" }] } diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index de633561862a..dd2f38ff3193 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -8,7 +8,11 @@ const reactUi: TestProjectConfiguration = { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.test.{ts,tsx}'], - globals: true + globals: true, + // The first test in each file pays jsdom env init + full module transform, + // which can exceed vitest's 5000ms default under CI/load. 15s gives the + // cold start headroom without masking genuinely hung tests. + testTimeout: 15_000 } } diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts index d06e24bcba50..33004746c81b 100644 --- a/apps/shared/src/billing-types.ts +++ b/apps/shared/src/billing-types.ts @@ -1,12 +1,12 @@ /** - * Shared terminal-billing wire contracts. + * Shared Remote Spending wire contracts. * * These shapes round-trip between the Python tui_gateway and TypeScript clients * such as the TUI and desktop app. Keep rendering state, client logic, and the * gateway event union out of this runtime-free module. */ -// ── Terminal billing (Phase 2b) ────────────────────────────────────── +// ── Remote Spending (Phase 2b) ─────────────────────────────────────── /** One serialized usage bar (mirrors server `_serialize_usage_bar`). */ export interface UsageBarData { diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index d2a21999182a..9fd163efa007 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -47,6 +47,7 @@ export { type GatewayAuthMode, GatewayReauthRequiredError, type GatewayWsConnection, + type GatewayWsUrlResult, type HermesWebSocketUrlOptions, isGatewayReauthRequired, resolveGatewayWsUrl, diff --git a/apps/shared/src/websocket-url.ts b/apps/shared/src/websocket-url.ts index 9384f30652de..24592c4642b0 100644 --- a/apps/shared/src/websocket-url.ts +++ b/apps/shared/src/websocket-url.ts @@ -12,9 +12,14 @@ export interface ResolveGatewayWsUrlDeps { * OAuth-gated gateways use single-use tickets, so callers should mint * immediately before opening the socket. */ - getGatewayWsUrl?: (profile?: null | string) => Promise + getGatewayWsUrl?: (profile?: null | string) => Promise } +export type GatewayWsUrlResult = + | string + | { ok: true; wsUrl: string } + | { error: string; needsOauthLogin?: boolean; ok: false } + export class GatewayReauthRequiredError extends Error { readonly needsOauthLogin = true @@ -37,27 +42,52 @@ export async function resolveGatewayWsUrl(deps: ResolveGatewayWsUrlDeps, conn: G if (conn.authMode === 'oauth') { if (!mint) { - throw new GatewayReauthRequiredError( - 'Your remote gateway session needs to be refreshed. Open Settings -> Gateway and click "Sign in" again.' - ) + throw new Error('This Desktop build cannot refresh OAuth WebSocket tickets. Update Hermes Desktop and try again.') } try { - return await mint(profile) + const result = await mint(profile) + + if (typeof result === 'string') { + return result + } + + if (result.ok) { + return result.wsUrl + } + + if (result.needsOauthLogin) { + throw new GatewayReauthRequiredError( + 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', + { cause: new Error(result.error) } + ) + } + + throw new Error(result.error || 'Could not refresh the remote gateway WebSocket ticket.') } catch (error) { - throw new GatewayReauthRequiredError( - 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', - { cause: error } - ) + if (isGatewayReauthRequired(error)) { + throw error instanceof GatewayReauthRequiredError + ? error + : new GatewayReauthRequiredError( + 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', + { cause: error } + ) + } + + throw error } } if (mint) { const fresh = await mint(profile).catch(() => null) - if (fresh) { + if (typeof fresh === 'string') { return fresh } + + if (fresh?.ok) { + return fresh.wsUrl + } } return conn.wsUrl diff --git a/cli.py b/cli.py index 20929befe882..287c2de25837 100644 --- a/cli.py +++ b/cli.py @@ -3271,27 +3271,25 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None: def _terminal_may_leak_cpr() -> bool: - """Detect terminals where CPR (ESC[6n) replies are likely to leak. + """Whether classic CLI should suppress prompt_toolkit CPR (ESC[6n) queries. - The CPR leak in #13870 is environment-specific: it shows up over SSH + - cloudflared/mux tunnels and slow PTYs, where the terminal's - ``ESC[;R`` reply round-trips slowly enough to race past the input - parser and land in the display as raw ``20;1R`` text (and the pending-CPR - future can stall the renderer, freezing the prompt). On a local terminal the - reply returns instantly and cleanly, so CPR works fine and there is nothing - to fix — we leave prompt_toolkit's default behavior untouched there. + Delayed CPR replies (``ESC[;R`` / visible ``^[[;R``) + leak into the status line and can freeze input when the reply is slow + (#13870 on SSH/slow PTYs). The same race hits local POSIX TTYs under + heavy subagent / status-line load — see ``tests/cli/test_cpr_local_leak.py``. - We only suppress CPR on a remote/tunneled link (SSH env vars) or when the - user has explicitly opted out via prompt_toolkit's own ``PROMPT_TOOLKIT_NO_CPR`` - escape hatch. Keeping this narrow (not the broader WSL/Ghostty/Windows set - that ``_preserve_ctrl_enter_newline`` keys on) means the only behavior change - lands exactly where the bug reproduces. + Policy: + - ``PROMPT_TOOLKIT_NO_CPR=1`` → always suppress + - native Windows (``win32``) → keep prompt_toolkit's default for now + (no native-Windows Application coverage yet); still honor NO_CPR + - all other platforms → suppress (CPR is only a layout hint; heuristic + height is enough). SSH env is no longer required to trigger this. """ if os.environ.get("PROMPT_TOOLKIT_NO_CPR", "") == "1": return True - if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")): - return True - return False + if sys.platform == "win32": + return False + return True def _build_cpr_disabled_output(stdout): @@ -3299,23 +3297,14 @@ def _build_cpr_disabled_output(stdout): prompt_toolkit's renderer sends ``ESC[6n`` (Device Status Report) to learn the cursor row before painting in non-fullscreen mode; the terminal replies - ``ESC[;R``. Over SSH + cloudflared/mux tunnels and some slow PTYs - these replies race past the input parser and land in the display as raw text - like ``20;1R21;1R``, and the pending-CPR future can stall the renderer so the - prompt appears frozen after the agent's final answer (see #13870). + ``ESC[;R``. When that reply is delayed it races into the display + as raw ``^[[39;1R`` and can stall the renderer's pending-CPR future + (#13870; also local POSIX under heavy subagent load). - Constructing the output with ``enable_cpr=False`` makes the renderer mark CPR - ``NOT_SUPPORTED`` up front, so ``ESC[6n`` is never sent and no CPR response - can leak. This is the root-cause counterpart to the input-side scrubbing in - ``_strip_leaked_terminal_responses`` — that cleans leaks after the fact; this - stops them at the source. The UI is otherwise identical (prompt_toolkit uses - its heuristic available-height fallback, which it already relies on whenever a - terminal doesn't answer CPR). - - This is only invoked on terminals flagged by ``_terminal_may_leak_cpr()`` — - CPR is a layout hint, not a speed optimization, and it works fine locally, so - we leave the upstream default in place on local terminals and only suppress it - where the leak actually reproduces. + Constructing the output with ``enable_cpr=False`` marks CPR + ``NOT_SUPPORTED`` so ``ESC[6n`` is never sent. prompt_toolkit then uses its + heuristic available-height fallback. Input-side + ``_strip_leaked_terminal_responses`` remains belt-and-suspenders. Note: ``Vt100_Output.from_pty()`` does NOT expose ``enable_cpr`` in prompt_toolkit 3.x, so we reproduce its ``get_size`` setup and call the @@ -3341,6 +3330,18 @@ def _build_cpr_disabled_output(stdout): return None +def _select_classic_cli_pt_output(stdout): + """Select prompt_toolkit Output for classic-CLI Application construction. + + Returns a CPR-disabled ``Vt100_Output`` when ``_terminal_may_leak_cpr()`` + is true, otherwise ``None`` so Application keeps prompt_toolkit's default + output (Windows preserve-default path). + """ + if not _terminal_may_leak_cpr(): + return None + return _build_cpr_disabled_output(stdout) + + def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]: """Strip leaked terminal control-response sequences from user input. @@ -6093,15 +6094,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}") return False try: - existing_text = getattr(target_buffer, "text", "") - expanded_text = self._expand_paste_references(existing_text) - if expanded_text != existing_text and hasattr(target_buffer, "text"): - self._skip_paste_collapse = True - target_buffer.text = expanded_text - if hasattr(target_buffer, "cursor_position"): - target_buffer.cursor_position = len(expanded_text) - # Set skip flag (again) so the text-change event fired when the - # editor closes does not re-collapse the returned content. + # Inline pastes so the editor (and the draft it submits) sees real + # content; skip flag unconditionally so the editor-close text-change + # doesn't re-collapse it, even when there was nothing to inline. + self._inline_pastes(target_buffer) self._skip_paste_collapse = True # Open the editor, then submit the saved draft on a clean exit — # matching the TUI's Ctrl+G (openEditor), which sends the buffer @@ -6173,6 +6169,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if app is not None: app.invalidate() + def _inline_pastes(self, buffer) -> None: + """Replace collapsed-paste placeholders in ``buffer`` with real content. + + A big paste shows as a compact ``[Pasted text #N -> file]`` placeholder, + but history recall and the external editor need the actual text — a bare + reference is useless once the file is gone or on another machine. Inlining + before ``reset(append_to_history=True)`` also lets prompt_toolkit persist + the content through its normal path. Sets ``_skip_paste_collapse`` so the + ensuing text-change doesn't re-collapse it. + """ + try: + existing = getattr(buffer, "text", "") + expanded = self._expand_paste_references(existing) + if expanded != existing and hasattr(buffer, "text"): + self._skip_paste_collapse = True + buffer.text = expanded + if hasattr(buffer, "cursor_position"): + buffer.cursor_position = len(expanded) + except Exception: + logger.debug("Failed to inline paste placeholders", exc_info=True) + def _reset_input_buffer(self, buffer) -> None: """Clear an input buffer after a programmatic submit (best-effort).""" try: @@ -13369,6 +13386,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): pass else: self._pending_input.put(payload) + # History stores real pasted content, not the placeholder, so + # up-arrow recall restores the actual text. + self._inline_pastes(event.app.current_buffer) event.app.current_buffer.reset(append_to_history=True) _bind_prompt_submit_keys(kb, handle_enter) @@ -13579,15 +13599,33 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state ) + def _recall_without_recollapse(buf, move): + """Run a history-navigation move, suppressing paste-collapse. + + Recalled history can hold the full text of a paste that was + collapsed to a placeholder at submit time. Loading it back into the + buffer looks exactly like a fresh large paste to ``_on_text_changed`` + and would be re-collapsed. Set the skip flag around the move; if the + move didn't change the text (plain cursor movement), clear the flag + so a later real paste still collapses. + """ + before = buf.text + self._skip_paste_collapse = True + move() + if buf.text == before: + self._skip_paste_collapse = False + @kb.add('up', filter=_normal_input) def history_up(event): """Up arrow: browse history when on first line, else move cursor up.""" - event.app.current_buffer.auto_up(count=event.arg) + buf = event.app.current_buffer + _recall_without_recollapse(buf, lambda: buf.auto_up(count=event.arg)) @kb.add('down', filter=_normal_input) def history_down(event): """Down arrow: browse history when on last line, else move cursor down.""" - event.app.current_buffer.auto_down(count=event.arg) + buf = event.app.current_buffer + _recall_without_recollapse(buf, lambda: buf.auto_down(count=event.arg)) @kb.add('c-l') def handle_ctrl_l(event): @@ -14822,22 +14860,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): } style = PTStyle.from_dict(self._build_tui_style_dict()) - # Disable CPR (Cursor Position Report) at the source so prompt_toolkit - # never sends ESC[6n cursor-position queries — but only on terminals - # where the reply is likely to leak. Over SSH/cloudflared tunnels and - # slow PTYs the CPR replies (ESC[;R) leak into the display as - # raw "20;1R21;1R" text and can stall the renderer's pending-CPR future, - # freezing the prompt after the agent's final answer (#13870). CPR is a - # layout hint, not a speed optimization, and it works fine locally, so we - # leave prompt_toolkit's default untouched on local terminals and only - # suppress it where the bug reproduces. None (local, or build failure) - # falls back to the default output; the input-side scrubbing in - # _strip_leaked_terminal_responses still guards against any leaks. - _cpr_disabled_output = ( - _build_cpr_disabled_output(sys.stdout) - if _terminal_may_leak_cpr() - else None - ) + # Select CPR-disabled output when _terminal_may_leak_cpr() says so + # (POSIX local + SSH; Windows keeps PT default — see helper docs). + # None falls back to prompt_toolkit's default output; input scrubbing + # in _strip_leaked_terminal_responses still guards residual leaks. + _cpr_disabled_output = _select_classic_cli_pt_output(sys.stdout) # Create the application app = Application( diff --git a/contributors/emails/agent@agents-Mac-mini.local b/contributors/emails/agent@agents-Mac-mini.local new file mode 100644 index 000000000000..850e0a4eb079 --- /dev/null +++ b/contributors/emails/agent@agents-Mac-mini.local @@ -0,0 +1 @@ +momomojo diff --git a/contributors/emails/ajzrva@gmail.com b/contributors/emails/ajzrva@gmail.com new file mode 100644 index 000000000000..2c2097b84dd5 --- /dev/null +++ b/contributors/emails/ajzrva@gmail.com @@ -0,0 +1 @@ +ajzrva-sys diff --git a/contributors/emails/almurat@Almurats-MacBook-Pro.local b/contributors/emails/almurat@Almurats-MacBook-Pro.local new file mode 100644 index 000000000000..fbd1de9e41e6 --- /dev/null +++ b/contributors/emails/almurat@Almurats-MacBook-Pro.local @@ -0,0 +1 @@ +Almurat123 diff --git a/contributors/emails/geoffreybutler94@gmail.com b/contributors/emails/geoffreybutler94@gmail.com new file mode 100644 index 000000000000..2a3d8a47a618 --- /dev/null +++ b/contributors/emails/geoffreybutler94@gmail.com @@ -0,0 +1 @@ +geoffreybutler94 diff --git a/contributors/emails/gigakun@agentmail.to b/contributors/emails/gigakun@agentmail.to new file mode 100644 index 000000000000..a82f4d1faa32 --- /dev/null +++ b/contributors/emails/gigakun@agentmail.to @@ -0,0 +1 @@ +gigakun3030 diff --git a/contributors/emails/git@hode.co.uk b/contributors/emails/git@hode.co.uk new file mode 100644 index 000000000000..52ce26c3c88d --- /dev/null +++ b/contributors/emails/git@hode.co.uk @@ -0,0 +1 @@ +okisdev diff --git a/contributors/emails/hermesagent424@gmail.com b/contributors/emails/hermesagent424@gmail.com new file mode 100644 index 000000000000..c76c38becfcf --- /dev/null +++ b/contributors/emails/hermesagent424@gmail.com @@ -0,0 +1 @@ +DatTheMaster diff --git a/contributors/emails/jakub.wolniewicz@gmail.com b/contributors/emails/jakub.wolniewicz@gmail.com new file mode 100644 index 000000000000..88e457102f73 --- /dev/null +++ b/contributors/emails/jakub.wolniewicz@gmail.com @@ -0,0 +1 @@ +frizikk diff --git a/contributors/emails/jfduarte09@gmail.com b/contributors/emails/jfduarte09@gmail.com new file mode 100644 index 000000000000..38ef1ae32831 --- /dev/null +++ b/contributors/emails/jfduarte09@gmail.com @@ -0,0 +1 @@ +F4TB0Yz diff --git a/contributors/emails/kuangmi@deeparchi.com b/contributors/emails/kuangmi@deeparchi.com new file mode 100644 index 000000000000..7f3575558dd2 --- /dev/null +++ b/contributors/emails/kuangmi@deeparchi.com @@ -0,0 +1 @@ +kuangmi-bit diff --git a/contributors/emails/marcolivier@gmail.com b/contributors/emails/marcolivier@gmail.com new file mode 100644 index 000000000000..65046935be60 --- /dev/null +++ b/contributors/emails/marcolivier@gmail.com @@ -0,0 +1 @@ +marcolivierlavoie diff --git a/contributors/emails/markvlcek@gmail.com b/contributors/emails/markvlcek@gmail.com new file mode 100644 index 000000000000..71874600fd3c --- /dev/null +++ b/contributors/emails/markvlcek@gmail.com @@ -0,0 +1 @@ +MarkVLK diff --git a/contributors/emails/ryan.kelln@gmail.com b/contributors/emails/ryan.kelln@gmail.com new file mode 100644 index 000000000000..e0d10922e2b1 --- /dev/null +++ b/contributors/emails/ryan.kelln@gmail.com @@ -0,0 +1 @@ +RKelln diff --git a/contributors/emails/siage@139.com b/contributors/emails/siage@139.com new file mode 100644 index 000000000000..f269b94ab5e1 --- /dev/null +++ b/contributors/emails/siage@139.com @@ -0,0 +1 @@ +sjiangtao2024 diff --git a/contributors/emails/turgut.kural@gmail.com b/contributors/emails/turgut.kural@gmail.com new file mode 100644 index 000000000000..7ad451ee5b05 --- /dev/null +++ b/contributors/emails/turgut.kural@gmail.com @@ -0,0 +1 @@ +TurgutKural diff --git a/docs/billing-lifecycle.md b/docs/billing-lifecycle.md index 53151684214b..8562c90e8368 100644 --- a/docs/billing-lifecycle.md +++ b/docs/billing-lifecycle.md @@ -30,7 +30,7 @@ Source: `ui-tui/src/components/billingOverlay.tsx` (`OverviewScreen`, | `monthly_cap` present, `limit_usd != null` | `{spent_display} of {limit_display} used this month` (+ ` (default ceiling)` iff `is_default_ceiling`). | | `monthly_cap` absent or `limit_usd == null` | `No monthly cap visible (managed on the portal).` | | Role without billing capability (`!is_admin`, menu collapses) | Note: `Billing actions need someone with billing permissions (owner, admin, or finance admin).` Menu collapses to `Manage on portal` / `Cancel`. | -| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Terminal billing is off for this org — manage it on the portal.` Same collapsed menu. | +| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page.` Same collapsed menu. | Note: `full = s.is_admin && s.cli_billing_enabled` gates the **org-level** switch, not the per-terminal `billing:manage` scope — that's discovered @@ -44,10 +44,10 @@ Source: `renderBillingError` in `ui-tui/src/app/slash/commands/topup.ts:37-149`. | `error` code | Copy | Portal URL | `retry_after` | |---|---|:-:|:-:| -| `insufficient_scope` | `This needs terminal billing enabled. Start a top-up to enable it, then retry.` | if present | — | -| `remote_spending_revoked` (CF-4) | `{An admin turned off terminal billing for this terminal. \| You turned off terminal billing for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — | +| `insufficient_scope` | `This needs Remote Spending allowed. Start a top-up to allow it, then retry.` | if present | — | +| `remote_spending_revoked` (CF-4) | `{An admin stopped remote spending for this terminal. \| You stopped remote spending for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — | | `session_revoked` | `Your session was logged out. Run /portal to log in again.` Also clears `billing` overlay state. | if present | — | -| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Terminal billing is off for this account — an admin must enable it on the portal.` | if present | — | +| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.` | if present | — | | `role_required` | `Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.` | if present | — | | `consent_required` | `This action needs a one-time card confirmation and consent step on the portal before it can proceed.` | if present | — | | `org_access_denied` | `This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.` | if present | — | @@ -136,14 +136,15 @@ just because NAS hasn't caught up yet. | `error` | Copy | |---|---| | `session_revoked` | `Your session expired — run /portal to log in again, then retry the change.` | -| `remote_spending_revoked` | `{message}` or `Terminal spending was turned off for this session — reconnect from the portal, then retry.` | +| `remote_spending_revoked` | `{message}` or `Remote spending was stopped for this terminal — reconnect from the portal, then retry.` | | `rate_limited` | `Too many attempts — wait a moment, then try again.` | -| other/unknown | `{message}` or `Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.` | +| other/unknown | `{message}` or `Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal.` | A **repeat** scope denial during a post-grant replay never re-enters the step-up screen (it's already mounted there — re-patching would freeze it); -`allowStepUp=false` instead surfaces a terminal result: `Terminal billing -still isn't enabled for this org — enable it on the portal, then retry.` +`allowStepUp=false` instead surfaces a terminal result: `Remote Spending still +isn’t active for this terminal — the authorization didn’t take. Retry, or make +this change on the portal.` ## Text-mode (CLI) parity diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 65d5b324cc45..973ba5507379 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1777,6 +1777,7 @@ class APIServerAdapter(BasePlatformAdapter): """ from run_agent import AIAgent from gateway.run import ( + _checkpoint_agent_kwargs, _current_max_iterations, _resolve_runtime_agent_kwargs, _resolve_gateway_model, @@ -1858,6 +1859,7 @@ class APIServerAdapter(BasePlatformAdapter): agent = AIAgent( model=model, **runtime_kwargs, + **_checkpoint_agent_kwargs(user_config), max_iterations=max_iterations, quiet_mode=True, verbose_logging=False, diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 34cc51522a0b..cf0646d1b239 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -234,16 +234,23 @@ class RelayAdapter(BasePlatformAdapter): outbound (the agent's reply) can re-assert it for the connector's egress tenant resolution. Never raises — scope tracking must not break inbound. - Two cases, matching the connector's two tenant-resolution paths: - - SCOPED message: remember chat_id -> scope_id. The connector resolves - the tenant from metadata.scope_id (routing table). - - DM (no scope): remember chat_id -> the authentic author user_id. - A DM carries no scope discriminator, so the connector instead resolves - the tenant from the recipient's author binding (resolveByUser); it - needs the user_id on the OUTBOUND action to do that. Without this, a - DM reply has no resolvable discriminator and the connector's egress - guard declines it as "target not routed to an onboarded tenant". - See gateway-gateway routedEgressGuard.ts / the tenant resolvers. + Two discriminators, captured independently (a scoped message has BOTH): + - scope_id: for a scoped (guild/channel) message. The connector's + primary path resolves the tenant from metadata.scope_id (routing + table). + - user_id: the authentic author id, captured for EVERY message (DM + and scoped alike). The connector resolves the tenant from the + recipient's author binding (resolveByUser) when a route lookup + misses. This is the sole discriminator for a DM (no scope), AND the + author-first FALLBACK for a scoped reply whose guild has no route + row — a managed agent joins guilds dynamically, so a provision-time + guild route is not guaranteed. Re-attaching user_id on scoped + replies too lets the connector's guild-route-miss fallback resolve + the tenant the same way inbound already does (SharedSocketRouter + targets()). Without a resolvable discriminator the connector's + egress guard declines the reply as 'target not routed to an + onboarded tenant'. See gateway-gateway routedEgressGuard.ts / + discordTenant.ts (makeDiscordTenantOf). """ try: src = getattr(event, "source", None) @@ -263,28 +270,36 @@ class RelayAdapter(BasePlatformAdapter): platform_value = getattr(platform, "value", platform) if platform_value and platform_value != "relay": self._platform_by_chat[str(chat)] = str(platform_value) - scope = getattr(src, "scope_id", None) - if scope: - self._scope_by_chat[str(chat)] = str(scope) - return - # DM: no scope. Remember the authentic author id for outbound - # author-binding resolution (the user we're replying to in this DM). + # Author id for outbound author-binding resolution. Captured for BOTH + # DM and scoped messages: it's the sole discriminator for a DM and + # the guild-route-miss fallback for a scoped reply. (Formerly captured + # for DMs only, which left managed-agent guild replies with no + # resolvable tenant when the guild had no route row.) user_id = getattr(src, "user_id", None) if user_id: self._dm_user_by_chat[str(chat)] = str(user_id) + scope = getattr(src, "scope_id", None) + if scope: + self._scope_by_chat[str(chat)] = str(scope) except Exception: # noqa: BLE001 - scope tracking must never break inbound pass def _with_scope(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]: - """Ensure the outbound metadata carries the discriminator the connector's - egress guard needs to resolve the owning tenant. Two cases: + """Ensure the outbound metadata carries the discriminator(s) the connector's + egress guard needs to resolve the owning tenant. - - SCOPED reply: re-attach metadata.scope_id (routing-table resolution). - - DM reply: there is no scope, so re-attach metadata.user_id — the - authentic author id we saw inbound — which the connector resolves to - the tenant via the recipient's author binding (resolveByUser). Without - one of these, egress is declined as 'target not routed to an onboarded - tenant'. See gateway-gateway routedEgressGuard.ts / the tenant resolvers. + - scope_id: re-attached for a scoped reply (guild/channel) → + routing-table resolution (the primary path). + - user_id: the authentic author id we saw inbound, re-attached for + EVERY reply we know it for. It is the sole discriminator for a DM + (no scope), AND the author-first FALLBACK the connector uses when a + scoped reply's guild has no route row (a managed agent joins guilds + dynamically — the guild route may not be provisioned). Carrying both + on a scoped reply is harmless: the connector tries scope_id first and + only falls back to user_id on a route miss. Without a resolvable + discriminator egress is declined as 'target not routed to an + onboarded tenant'. See gateway-gateway routedEgressGuard.ts / + discordTenant.ts. No-op when the relevant value is already present or unknown for this chat. """ @@ -293,13 +308,15 @@ class RelayAdapter(BasePlatformAdapter): scope = self._scope_by_chat.get(str(chat_id)) if scope: meta["scope_id"] = scope - # DM author-binding discriminator. Only meaningful when there's no scope - # (a scoped reply resolves by scope_id); harmless to carry otherwise, but - # we only set it when this chat is a known DM and the field is absent. - if not meta.get("scope_id") and not meta.get("user_id"): - dm_user = self._dm_user_by_chat.get(str(chat_id)) - if dm_user: - meta["user_id"] = dm_user + # Author-binding discriminator. Attached whenever we know the author for + # this chat and it isn't already set — for DMs (the sole discriminator) + # AND scoped replies (the connector's guild-route-miss fallback). It is + # only consulted by the connector when the scope/route lookup misses, so + # carrying it alongside scope_id never overrides routing-table resolution. + if not meta.get("user_id"): + author = self._dm_user_by_chat.get(str(chat_id)) + if author: + meta["user_id"] = author return meta def _platform_is_fronted(self, platform: str) -> bool: diff --git a/gateway/run.py b/gateway/run.py index f59e006f6249..9184c22b4125 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2592,6 +2592,35 @@ def _load_gateway_config() -> dict: return raw +def _checkpoint_agent_kwargs(config: dict | None) -> dict: + """Translate gateway checkpoint config into ``AIAgent`` constructor args. + + The gateway reads raw YAML instead of ``load_config()``, so checkpoint + defaults must be supplied here. Keep legacy ``checkpoints: true`` configs + working while giving every gateway-created agent the same limits. + """ + cp_cfg = config.get("checkpoints", {}) if isinstance(config, dict) else {} + if isinstance(cp_cfg, bool): + cp_cfg = {"enabled": cp_cfg} + elif not isinstance(cp_cfg, dict): + cp_cfg = {} + + from hermes_cli.config import DEFAULT_CONFIG + defaults = DEFAULT_CONFIG["checkpoints"] + return { + "checkpoints_enabled": cp_cfg.get("enabled", defaults["enabled"]), + "checkpoint_max_snapshots": cp_cfg.get( + "max_snapshots", defaults["max_snapshots"], + ), + "checkpoint_max_total_size_mb": cp_cfg.get( + "max_total_size_mb", defaults["max_total_size_mb"], + ), + "checkpoint_max_file_size_mb": cp_cfg.get( + "max_file_size_mb", defaults["max_file_size_mb"], + ), + } + + def _load_gateway_runtime_config() -> dict: """Load gateway config for runtime reads, expanding supported ``${VAR}`` refs. @@ -14777,6 +14806,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew agent = AIAgent( model=turn_route["model"], **turn_route["runtime"], + **_checkpoint_agent_kwargs(user_config), max_iterations=max_iterations, quiet_mode=True, verbose_logging=False, @@ -17273,6 +17303,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ("compression", "protect_last_n"), ("agent", "disabled_toolsets"), ("memory", "provider"), + ("checkpoints", "enabled"), + ("checkpoints", "max_snapshots"), + ("checkpoints", "max_total_size_mb"), + ("checkpoints", "max_file_size_mb"), ) _HONCHO_CACHE_BUSTING_KEYS = ( @@ -17336,7 +17370,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew cfg = user_config if isinstance(user_config, dict) else {} for section, key in cls._CACHE_BUSTING_CONFIG_KEYS: section_val = cfg.get(section) - if isinstance(section_val, dict): + if section == "checkpoints" and isinstance(section_val, bool): + # Preserve legacy ``checkpoints: true`` behavior. A live + # toggle must still rebuild the cached agent. + out[f"{section}.{key}"] = section_val if key == "enabled" else None + elif isinstance(section_val, dict): out[f"{section}.{key}"] = section_val.get(key) else: out[f"{section}.{key}"] = None @@ -20303,6 +20341,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew agent = AIAgent( model=turn_route["model"], **turn_route["runtime"], + **_checkpoint_agent_kwargs(user_config), max_iterations=max_iterations, quiet_mode=True, verbose_logging=False, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 4aa75fa7985a..f041f45d8b5d 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2668,31 +2668,19 @@ class GatewaySlashCommandsMixin: async def _handle_rollback_command(self, event: MessageEvent) -> str: """Handle /rollback command — list or restore filesystem checkpoints.""" - from gateway.run import _hermes_home + from gateway.run import _checkpoint_agent_kwargs, _load_gateway_config from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list - # Read checkpoint config from config.yaml - cp_cfg = {} - try: - import yaml as _y - _cfg_path = _hermes_home / "config.yaml" - if _cfg_path.exists(): - with open(_cfg_path, encoding="utf-8") as _f: - _data = _y.safe_load(_f) or {} - cp_cfg = _data.get("checkpoints", {}) - if isinstance(cp_cfg, bool): - cp_cfg = {"enabled": cp_cfg} - except Exception: - pass + cp_kwargs = _checkpoint_agent_kwargs(_load_gateway_config()) - if not cp_cfg.get("enabled", False): + if not cp_kwargs["checkpoints_enabled"]: return t("gateway.rollback.not_enabled") mgr = CheckpointManager( enabled=True, - max_snapshots=cp_cfg.get("max_snapshots", 50), - max_total_size_mb=cp_cfg.get("max_total_size_mb", 500), - max_file_size_mb=cp_cfg.get("max_file_size_mb", 10), + max_snapshots=cp_kwargs["checkpoint_max_snapshots"], + max_total_size_mb=cp_kwargs["checkpoint_max_total_size_mb"], + max_file_size_mb=cp_kwargs["checkpoint_max_file_size_mb"], ) cwd = os.getenv("TERMINAL_CWD", str(Path.home())) @@ -4143,9 +4131,9 @@ class GatewaySlashCommandsMixin: """Handle /topup -- show the Nous balance and hand off to the portal. Renders the balance block + identity line + a tappable portal URL that - opens the billing page. Terminal billing is managed on the portal: the - terminal does NOT charge, confirm, or track payment here — everything - happens in the browser and the next /topup shows the new balance. The + opens the billing page. Remote spending is managed on the portal: this + messaging command does NOT charge, confirm, or track payment here — + everything happens in the browser and the next /topup shows the new balance. The tappable URL is the affordance and works on every platform (button-capable or plain text like SMS/email). Fetched off the event loop; fail-open. """ diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 3a6233f03249..a10935a68f05 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -14,8 +14,8 @@ Provides subcommands for: import os import sys -__version__ = "0.18.2" -__release_date__ = "2026.7.7.2" +__version__ = "0.19.0" +__release_date__ = "2026.7.20" def _ensure_utf8(): diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 13f7cf362f6b..7a6db664bc16 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -8154,7 +8154,7 @@ def step_up_nous_billing_scope( The lazy step-up (plan D-A): triggered when a billing endpoint returns ``403 insufficient_scope``. Runs a fresh device-connect with ``inference:invoke tool:invoke billing:manage`` on the scope. The user must be - an ADMIN/OWNER and tick "Allow terminal billing" in the portal for the minted + an ADMIN/OWNER and select "Allow Remote Spending" in the portal for the minted token to actually carry the scope; otherwise the server silently downscopes and this returns False. diff --git a/hermes_cli/cli_billing_mixin.py b/hermes_cli/cli_billing_mixin.py index 49b89fa77b93..bb9edd95267a 100644 --- a/hermes_cli/cli_billing_mixin.py +++ b/hermes_cli/cli_billing_mixin.py @@ -382,7 +382,7 @@ class CLIBillingMixin: if allow_stepup: self._subscription_handle_scope_required(state, retry=("preview", tier_id)) else: - print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") + print(" Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal.") return except BillingError as exc: self._subscription_render_error(state, exc) @@ -562,12 +562,12 @@ class CLIBillingMixin: if allow_stepup: self._subscription_handle_scope_required(state, retry=action, idempotency_key=key) else: - print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") + print(" Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal.") except BillingError as exc: self._subscription_render_error(state, exc) def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=None): - """insufficient_scope → grant terminal billing (step-up), then replay `retry`. + """insufficient_scope → allow remote spending (step-up), then replay `retry`. Mirrors _billing_handle_scope_required: the classic CLI calls step_up_nous_billing_scope directly (it opens the browser + blocks), then @@ -577,34 +577,34 @@ class CLIBillingMixin: print() print(" ! One-time setup") - _cprint(f" {_d('To change your plan from the terminal, enable terminal billing once. It opens your browser to authorize, then your change picks up right here.')}") + _cprint(f" {_d('To change your plan from the terminal, allow Remote Spending once. It opens your browser to authorize, then your change picks up right here.')}") if not getattr(self, "_app", None): - print(" Run `hermes portal` and enable terminal billing, then re-run /subscription.") + print(" Run `hermes portal` and allow Remote Spending, then re-run /subscription.") return confirm_choices = [ - ("yes", "Enable terminal billing", "open your browser to authorize"), + ("yes", "Allow Remote Spending", "open your browser to authorize"), ("no", "Not now", "cancel"), ] raw = self._prompt_text_input_modal( - title="Enable terminal billing", + title="Allow Remote Spending", detail="Opens your browser to authorize this terminal.", choices=confirm_choices, ) if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": - print(" No change made. Enable terminal billing when you're ready.") + print(" No change made. Allow Remote Spending when you're ready.") return - print(" Opening your browser to enable terminal billing…") + print(" Opening your browser to allow Remote Spending…") try: from hermes_cli.auth import step_up_nous_billing_scope granted = step_up_nous_billing_scope(open_browser=True) except Exception as exc: - print(f" Couldn't enable terminal billing: {exc}") + print(f" Couldn't allow Remote Spending: {exc}") return if not granted: - print(" Couldn't enable terminal billing — an org admin or owner has to approve it for this org.") + print(" Couldn't allow Remote Spending — an org admin or owner has to approve it for this org.") return - _cprint(f" {_DIM}✓ Terminal billing enabled.{_RST}") + _cprint(f" {_DIM}✓ Remote Spending allowed.{_RST}") # Bust the 30s token cache so the replay uses the freshly-scoped token. The # cache still holds the pre-grant unscoped token, and _request only busts it # on a 401 (not a 403 scope denial) — without this, the replay would 403 @@ -637,7 +637,7 @@ class CLIBillingMixin: msg = str(exc) or "Something went wrong." if code == "insufficient_scope": # Defensive: the flow routes scope to the step-up before reaching here. - _cprint(" 🟡 Terminal billing isn't enabled. Enable it, then retry.") + _cprint(" 🟡 Remote Spending isn't allowed yet. Allow it, then retry.") elif code in ("subscription_mutation_rejected", "preview_rejected"): _cprint(f" 🟡 {msg}") else: @@ -661,11 +661,11 @@ class CLIBillingMixin: _cprint(f" Portal: {_url}") # ------------------------------------------------------------------ - # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) + # /billing — Phase 2b Remote Spending (CLI surface, all 5 screens) # ------------------------------------------------------------------ def _show_billing(self, command: str = "/topup"): - """`/topup` — terminal billing for Nous (one interactive modal). + """`/topup` — Remote Spending for Nous (one interactive modal). ZERO sub-commands: any argument is ignored. Bare ``/topup`` always opens the Overview (Screen 1), whose numbered menu is the *only* way to @@ -710,7 +710,7 @@ class CLIBillingMixin: Dollars-only (no "credits") — mirrors the TUI /topup overlay: balance leads in the title, the shared plan + top-up bars render below, then the - reordered menu (Add funds first). No scope preflight — terminal billing + reordered menu (Add funds first). No scope preflight — remote spending is discovered reactively when a charge 403s insufficient_scope. """ from cli import _cprint, _b, _d @@ -762,8 +762,11 @@ class CLIBillingMixin: self._billing_portal_hint(state) return if not state.cli_billing_enabled: - _cprint(f" {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal to add funds here.") + _cprint(f" {_d('Remote spending is off for this org.')}") + self._billing_portal_hint( + state, + reason="A billing admin can turn it on from the portal's Hermes Agent page to add funds here.", + ) return # A missing card does NOT gate the whole overview — the org may already have @@ -778,7 +781,7 @@ class CLIBillingMixin: return # Add funds first, then settings, then the scopeless browser handoff. - # No "Enable terminal billing" item — that's discovered at pay time. + # No "Allow Remote Spending" item — that's discovered at pay time. # "Add funds" charges in-terminal against the org's portal-saved card # (server-held via POST /charge — no card ref leaves the client). A # missing card is NOT gated here: the buy flow reacts to the server's @@ -856,8 +859,11 @@ class CLIBillingMixin: return False if not state.cli_billing_enabled: print() - _cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal first.") + _cprint(f" 💳 {_d('Remote spending is off for this org.')}") + self._billing_portal_hint( + state, + reason="A billing admin can turn it on from the portal's Hermes Agent page before adding funds.", + ) return False return True @@ -1033,7 +1039,7 @@ class CLIBillingMixin: try: result = post_charge(amount_usd=amount, idempotency_key=key) except BillingScopeRequired: - # In-flight reauth: enable terminal billing, then resume THIS charge + # In-flight reauth: allow remote spending, then resume THIS charge # (press-Enter beat) — no command re-run. Reuses the same idem key. self._billing_handle_scope_required(state, amount=amount, idempotency_key=key) return @@ -1129,7 +1135,7 @@ class CLIBillingMixin: print(" 💳 No card on file — top up and manage billing on the portal.") elif code in ("cli_billing_disabled", "remote_spending_disabled") or \ getattr(exc, "code", None) == "remote_spending_disabled": - print(" Terminal billing is off for this account — an admin must enable it on the portal.") + print(" Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.") elif code == "role_required": print(" Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.") elif code == "idempotency_conflict": @@ -1146,8 +1152,8 @@ class CLIBillingMixin: print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.") elif code == "insufficient_scope": # Never leak the raw billing:manage scope (the post-grant replay can - # re-raise it if the grant raced) — the concept is "terminal billing". - print(" 🔴 Terminal billing needs approval — run /topup to enable it, then retry.") + # re-raise it if the grant raced) — the concept is "Remote Spending". + print(" 🔴 Remote Spending needs approval — run /topup to allow it, then retry.") else: print(f" 🔴 {exc}") if portal_url: @@ -1156,9 +1162,9 @@ class CLIBillingMixin: def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key=None): """403 insufficient_scope → in-flight reauth, then resume the held charge. - The buy path discovers terminal billing isn't enabled only when the - charge 403s — there is no preflight. We enable it in-flight ("Enable - terminal billing" → browser device-flow), then on return ask the user to + The buy path discovers remote spending isn't allowed only when the + charge 403s — there is no preflight. We allow it in-flight ("Allow + Remote Spending" → browser device-flow), then on return ask the user to press Enter to resume the held ``amount`` (reusing ``idempotency_key`` so the resumed charge collapses with the original). Never leaks the raw billing:manage scope. @@ -1170,33 +1176,33 @@ class CLIBillingMixin: amount_str = format_money(amount) if amount is not None else "your top-up" print() print(" ! One-time setup") - _cprint(f" {_d(f'To charge this terminal, enable terminal billing once. It opens your browser to authorize, then {amount_str} picks up right here.')}") + _cprint(f" {_d(f'To charge from this terminal, allow Remote Spending once. It opens your browser to authorize, then {amount_str} picks up right here.')}") if not getattr(self, "_app", None): - print(" Run `hermes portal` and enable terminal billing, then retry.") + print(" Run `hermes portal` and allow Remote Spending, then retry.") return confirm_choices = [ - ("yes", "Enable terminal billing", "open your browser to authorize"), + ("yes", "Allow Remote Spending", "open your browser to authorize"), ("no", "Not now", "cancel"), ] raw = self._prompt_text_input_modal( - title="Enable terminal billing", + title="Allow Remote Spending", detail="Opens your browser to authorize this terminal.", choices=confirm_choices, ) choice = self._normalize_slash_confirm_choice(raw, confirm_choices) if choice != "yes": - print(" No charge made. Run /topup when you want to enable terminal billing.") + print(" No charge made. Run /topup when you want to allow Remote Spending.") return - print(" Opening your browser to enable terminal billing…") + print(" Opening your browser to allow Remote Spending…") try: from hermes_cli.auth import step_up_nous_billing_scope granted = step_up_nous_billing_scope(open_browser=True) except Exception as exc: - print(f" Couldn't enable terminal billing: {exc}") + print(f" Couldn't allow Remote Spending: {exc}") return if not granted: - print(" Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.") + print(" Couldn't allow Remote Spending — an org admin or owner has to approve it. Your card was not charged.") return # Granted. The token now carries the scope, but the ORG kill-switch @@ -1206,7 +1212,7 @@ class CLIBillingMixin: fresh = build_billing_state() if not (fresh.logged_in and fresh.cli_billing_enabled): - print(" Terminal billing was enabled for this terminal, but it's still turned off for this org. Enable it in the portal, then run /topup again.") + print(" Remote Spending is allowed for this terminal, but it's still off for this org. A billing admin can turn it on from the portal's Hermes Agent page, then run /topup again.") self._billing_portal_hint(fresh) return @@ -1214,7 +1220,7 @@ class CLIBillingMixin: # file. If there's none, this is a half-done state: say so and route to the # portal to top up / manage billing, rather than a bare "✓ enabled" that reads as done. if fresh.card is None: - print(" ✓ Terminal billing enabled — but there's no card on file yet.") + print(" ✓ Remote Spending allowed — but there's no card on file yet.") _cprint(f" {_d('Top up and manage billing on the portal to continue.')}") self._billing_portal_hint(fresh) return @@ -1222,12 +1228,12 @@ class CLIBillingMixin: # Nothing to resume (scope-required hit outside a charge, e.g. auto-reload # config) → just tell the user it's ready. if amount is None: - print(" ✓ Terminal billing enabled. Run /topup to continue.") + print(" ✓ Remote Spending allowed. Run /topup to continue.") return # Press-Enter beat: the user is back from the browser; resume the held # purchase on an explicit confirm (reassuring, not silent). - print(" ✓ Terminal billing enabled.") + print(" ✓ Remote Spending allowed.") resume_choices = [ ("resume", f"Resume {format_money(amount)} top-up", "finish the held purchase"), ("cancel", "Cancel", "do not charge"), diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index a054fced7f8f..a86b1316e99d 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1720,7 +1720,6 @@ def list_authenticated_providers( results: List[dict] = [] seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545) - seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn) _current_provider_norm = str(current_provider or "").strip().lower() _current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower() @@ -1849,6 +1848,7 @@ def list_authenticated_providers( # --- 1. Check Hermes-mapped providers --- from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS + from hermes_cli.models import _PROVIDER_ALIASES as _CANON_ALIASES from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items(): # Skip vendor names that are merely aliases routing through an @@ -1866,10 +1866,30 @@ def list_authenticated_providers( and _alias_target in _AGG_PROVIDERS ): continue - # Skip aliases that map to the same models.dev provider (e.g. - # kimi-coding and kimi-coding-cn both → kimi-for-coding). - # The first one with valid credentials wins (#10526). - if mdev_id in seen_mdev_ids: + # Resolve the canonical provider profile name. Skip hermes_ids + # that are mere aliases resolving to a different canonical profile + # (e.g. "kimi" and "moonshot" both → "kimi-coding"). Only process + # entries whose hermes_id matches the canonical profile name so + # distinct profiles (e.g. kimi-coding, kimi-coding-cn) each get + # their own picker row. + _canonical = hermes_id + try: + from providers import get_provider_profile as _gpp + _prof = _gpp(hermes_id) + if _prof is not None: + _canonical = _prof.name + except Exception: + pass + if _canonical != hermes_id: + continue + + # Skip duplicates: another entry with the same slug was already + # emitted (e.g. two PROVIDER_TO_MODELS_DEV entries routing to the + # same hermes_id). Distinct canonical profiles that share a + # models.dev ID (e.g. kimi-coding and kimi-coding-cn → kimi-for-coding) + # are both allowed through since they have different slugs. + slug = hermes_id + if slug.lower() in seen_slugs: continue if hermes_id.lower() in _excluded or mdev_id.lower() in _excluded: continue @@ -1940,21 +1960,23 @@ def list_authenticated_providers( else: top = model_ids[:max_models] if max_models is not None else model_ids - slug = hermes_id pinfo = _mdev_pinfo(mdev_id) - display_name = pinfo.name if pinfo else mdev_id + display_name = pconfig.name if pconfig and pconfig.name else (pinfo.name if pinfo else mdev_id) results.append({ "slug": slug, "name": display_name, - "is_current": slug == current_provider or mdev_id == current_provider, + "is_current": ( + slug == current_provider + or hermes_id == current_provider + or mdev_id == current_provider + ), "is_user_defined": False, "models": top, "total_models": total, "source": "built-in", }) seen_slugs.add(slug.lower()) - seen_mdev_ids.add(mdev_id) _record_builtin_endpoint(slug) # --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) --- diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index a2c59a317f8d..0ad745dcb96b 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -1,4 +1,4 @@ -"""Nous Portal terminal-billing HTTP client (Phase 2b). +"""Nous Portal Remote Spending HTTP client (Phase 2b). Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal billing screens drive. Companion to ``hermes_cli/nous_account.py`` (which owns @@ -90,8 +90,8 @@ class BillingScopeRequired(BillingError): """``403 insufficient_scope`` — the held token lacks ``billing:manage``. The lazy step-up trigger: catching this kicks off a fresh device-connect that - requests ``billing:manage`` (and tells the user an ADMIN must tick "Allow - terminal billing"). Also fires mid-session if the scope is stripped on refresh + requests ``billing:manage`` (and tells the user an ADMIN must select "Allow + Remote Spending"). Also fires mid-session if the scope is stripped on refresh after the user loses ADMIN. """ @@ -363,11 +363,11 @@ def _raise_for_error( ) raise BillingAuthError(message or "Authentication required.", **common) if status == 403: - # This terminal's spending was revoked (NOT the same as never having the - # scope). Disable spend UI immediately; recovery is reconnect. + # Remote spending was stopped for this terminal (NOT the same as never + # having the scope). Disable spend UI immediately; recovery is reconnect. if error == "remote_spending_revoked": raise BillingRemoteSpendingRevoked( - message or "Remote Spending was revoked for this terminal.", **common + message or "Remote spending was stopped for this terminal.", **common ) if error == "insufficient_scope": raise BillingScopeRequired( diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index c3e8efd22290..2303e2141158 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -765,6 +765,36 @@ def resolve_provider_full( if user_pdef is not None: return user_pdef + # 0.5 Exact Hermes provider IDs must win over LOSSY alias collapsing. + # Example: kimi-coding-cn should stay distinct from kimi-coding instead of + # normalizing through the shared models.dev alias "kimi-for-coding". + # A collapse is lossy only when MULTIPLE distinct registry providers + # normalize to the same canonical name — resolving through the alias + # would then lose which one the caller meant. Single-entry rewrites + # (e.g. "copilot" → "github-copilot") are correct routing and must keep + # resolving through the built-in chain below so overlay transports apply. + if canonical != raw: + try: + from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY + _pcfg = _AUTH_PROVIDER_REGISTRY.get(raw) + if _pcfg is not None: + _collapsed_siblings = [ + _rid + for _rid in _AUTH_PROVIDER_REGISTRY + if normalize_provider(_rid) == canonical + ] + if len(_collapsed_siblings) > 1: + return ProviderDef( + id=_pcfg.id, + name=_pcfg.name, + transport="openai_chat", + api_key_env_vars=tuple(_pcfg.api_key_env_vars or ()), + base_url=_pcfg.inference_base_url or "", + source="hermes-auth-registry", + ) + except Exception: + pass + # 1. Built-in (models.dev + overlays) pdef = get_provider(canonical) if pdef is not None: diff --git a/hermes_state.py b/hermes_state.py index 398b1442d6f7..8d8ff6bc0213 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -5066,6 +5066,47 @@ class SessionDB: ) return model_history, display_history + def get_ancestor_display_prefix(self, session_id: str) -> List[Dict[str, Any]]: + """Return the ancestor-only display messages for a session lineage. + + These are messages from parent/grandparent sessions (compression + ancestors) that appear in the display transcript but NOT in the + tip session's model-fed history. Used by ``session.resume`` to + build the ``display_history_prefix`` that ``_live_session_payload`` + prepends to the live model history. + + Previously the prefix was calculated as + ``display_history[:len(display) - len(raw)]``, but that overcounts + when ``repair_message_sequence`` removes messages from the MIDDLE + of the tip history (e.g. verification candidates collapsed by the + consecutive-assistant merge) — the length difference includes both + ancestor messages AND repair-removed tip messages, but the slice + only captures the first N display messages (which are tip messages + when there are no ancestors), causing duplication. This method + returns ONLY the genuine ancestor messages, identified by + ``session_id != tip_session_id``. (#65919) + """ + session_ids = self._session_lineage_root_to_tip(session_id) + if len(session_ids) <= 1: + return [] + with self._lock: + placeholders = ",".join("?" for _ in session_ids) + rows = self._conn.execute( + f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} " + f"FROM messages WHERE session_id IN ({placeholders}) AND active = 1 " + "ORDER BY id", + tuple(session_ids), + ).fetchall() + ancestor_rows = [r for r in rows if r["session_id"] != session_id] + if not ancestor_rows: + return [] + return self._rows_to_conversation( + ancestor_rows, + session_id=session_id, + include_ancestors=True, + repair_alternation=False, + ) + def get_conversation_root(self, session_id: str) -> str: """Return the ROOT id of *session_id*'s lineage chain. diff --git a/nix/devShell.nix b/nix/devShell.nix index e93e10ef056f..c5dfa6cf1f69 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -43,6 +43,9 @@ ${combinedNonNpm} ${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths} + # Force Node to use Nix's playwright-test binary instead of node_modules/.bin + export PATH="${pkgs.playwright-test}/bin:$PATH" + # for the devshell to pick up the src export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel) echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT" diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 043d1e942021..f1a23b1029d5 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -22,6 +22,9 @@ wl-clipboard, xclip, + # linux-only dev deps + cage, + # Flake inputs — passed explicitly by packages.nix and overlays.nix uv2nix, pyproject-nix, @@ -61,8 +64,7 @@ let bundledSkills = lib.cleanSourceWith { src = ../skills; - filter = - path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path); + filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path); }; # Optional skills are NOT in the wheel (pythonSrc excludes them, see @@ -70,8 +72,7 @@ let # same mechanism Homebrew packaging uses. bundledOptionalSkills = lib.cleanSourceWith { src = ../optional-skills; - filter = - path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path); + filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path); }; # Import bundled plugins (memory, context_engine, platforms/*). Keeping @@ -251,7 +252,14 @@ stdenv.mkDerivation (finalAttrs: { export HERMES_PYTHON=${devPython}/bin/python3 ''; - devDeps = runtimeDeps ++ [ devPython ]; + devDeps = + runtimeDeps + ++ [ + devPython + ] + ++ lib.optionals stdenv.isLinux [ + cage # for running e2e tests without popping windows + ]; }; meta = with lib; { diff --git a/package-lock.json b/package-lock.json index 0b5dfdb169d8..3236f2e7310a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,6 +155,7 @@ "devDependencies": { "@electron/rebuild": "^4.0.6", "@eslint/js": "^9.39.4", + "@playwright/test": "=1.58.2", "@testing-library/dom": "^10.4.0", "@testing-library/react": "^16.3.2", "@types/d3-force": "^3.0.10", @@ -376,6 +377,22 @@ } } }, + "apps/desktop/node_modules/@playwright/test": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz", + "integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "apps/desktop/node_modules/@types/node": { "version": "22.20.0", "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz", @@ -386,6 +403,53 @@ "undici-types": "~6.21.0" } }, + "apps/desktop/node_modules/fsevents": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz", + "integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "apps/desktop/node_modules/playwright": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz", + "integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.58.2" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "fsevents": "2.3.2" + } + }, + "apps/desktop/node_modules/playwright-core": { + "version": "1.58.2", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz", + "integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=18" + } + }, "apps/desktop/node_modules/undici-types": { "version": "6.21.0", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3835d8e31efa..a49a99bdd0e1 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -41,6 +41,7 @@ Environment variables: MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false) MATRIX_ALLOW_PUBLIC_ROOMS Allow Matrix tools to create public rooms (default: false) + MATRIX_MAX_MESSAGE_LENGTH Outbound message chunk size in characters (default: 16000) MATRIX_APPROVAL_REQUIRE_SENDER Require reaction controls to come from the original requester when requester metadata is available (default: true) @@ -352,9 +353,39 @@ class _MatrixChoicePickerPrompt: bot_reaction_events: dict[str, str] = field(default_factory=dict) -# Matrix message size limit (4000 chars practical, spec has no hard limit -# but clients render poorly above this). -MAX_MESSAGE_LENGTH = 4000 +# Matrix message size limit. The spec allows large events (~65 KB), but very +# large bodies can render poorly in some clients. The previous 4,000-char +# default was overly conservative and split Markdown tables mid-row (#53026). +DEFAULT_MAX_MESSAGE_LENGTH = 16000 +MATRIX_MAX_MESSAGE_LENGTH_CEILING = 65535 + + +def _resolve_max_message_length(config) -> int: + """Resolve outbound chunk size from config, env, or plugin registry.""" + extra = getattr(config, "extra", {}) or {} + raw = extra.get("max_message_length") + if raw is None: + raw = os.getenv("MATRIX_MAX_MESSAGE_LENGTH") + if raw is None: + try: + from gateway.platform_registry import platform_registry + + entry = platform_registry.get("matrix") + if entry and entry.max_message_length: + raw = entry.max_message_length + except Exception: + pass + if raw is None: + return DEFAULT_MAX_MESSAGE_LENGTH + try: + value = int(raw) + except (TypeError, ValueError): + return DEFAULT_MAX_MESSAGE_LENGTH + return max(500, min(value, MATRIX_MAX_MESSAGE_LENGTH_CEILING)) + + +# Back-compat alias for callers/tests that import the module constant. +MAX_MESSAGE_LENGTH = DEFAULT_MAX_MESSAGE_LENGTH # Store directory for E2EE keys and sync state. # Uses get_hermes_home() so each profile gets its own Matrix store. @@ -799,21 +830,28 @@ class MatrixAdapter(BasePlatformAdapter): """Gateway adapter for Matrix (any homeserver).""" supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown) - splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH) + splits_long_messages = True # send() chunks via truncate_message(max_message_length) # Matrix clients commonly reserve typed "/" for client-local commands; # the adapter accepts "!command" as the alias that always reaches Hermes # (see _normalize_matrix_bang_command), so instruction text shows "!". typed_command_prefix = "!" - # Threshold for detecting Matrix client-side message splits. - # When a chunk is near the ~4000-char practical limit, a continuation - # is almost certain. - _SPLIT_THRESHOLD = 3900 + # Class-level defaults so partially-constructed instances (tests build + # adapters via object.__new__ without __init__) keep working; __init__ + # overrides both from _resolve_max_message_length(). + max_message_length = DEFAULT_MAX_MESSAGE_LENGTH + _split_threshold = DEFAULT_MAX_MESSAGE_LENGTH - 100 def __init__(self, config: PlatformConfig): super().__init__(config, Platform.MATRIX) + self.max_message_length = _resolve_max_message_length(config) + # Mirror other platform adapters for tests/tooling that read MAX_MESSAGE_LENGTH. + self.MAX_MESSAGE_LENGTH = self.max_message_length + # When a chunk is near the outbound limit, a continuation is almost certain. + self._split_threshold = max(100, self.max_message_length - 100) + self._homeserver: str = ( config.extra.get("homeserver", "") or os.getenv("MATRIX_HOMESERVER", "") ).rstrip("/") @@ -1612,7 +1650,7 @@ class MatrixAdapter(BasePlatformAdapter): return SendResult(success=True) formatted = self.format_message(content) - chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH) + chunks = self.truncate_message(formatted, self.max_message_length) last_event_id = None for i, chunk in enumerate(chunks): @@ -3607,7 +3645,7 @@ class MatrixAdapter(BasePlatformAdapter): try: pending = self._pending_text_batches.get(key) last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0 - if last_len >= self._SPLIT_THRESHOLD: + if last_len >= self._split_threshold: delay = self._text_batch_split_delay_seconds else: delay = self._text_batch_delay_seconds @@ -4690,6 +4728,8 @@ def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None: os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower() if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"): os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower() + if "max_message_length" in matrix_cfg and not os.getenv("MATRIX_MAX_MESSAGE_LENGTH"): + os.environ["MATRIX_MAX_MESSAGE_LENGTH"] = str(matrix_cfg["max_message_length"]) return None @@ -4735,7 +4775,7 @@ def register(ctx) -> None: allow_all_env="MATRIX_ALLOW_ALL_USERS", cron_deliver_env_var="MATRIX_HOME_ROOM", standalone_sender_fn=_standalone_send, - max_message_length=4000, + max_message_length=DEFAULT_MAX_MESSAGE_LENGTH, emoji="🔐", allow_update_command=True, ) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index d91f2a799828..5e158dec030e 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2357,11 +2357,11 @@ class TelegramAdapter(BasePlatformAdapter): if attempt > MAX_NETWORK_RETRIES: message = ( "Telegram polling could not reconnect after %d network error retries. " - "Restarting gateway." % MAX_NETWORK_RETRIES + "Escalating to gateway recovery." % MAX_NETWORK_RETRIES ) logger.error("[%s] %s Last error: %s", self.name, message, _redact_telegram_error_text(error)) self._set_fatal_error("telegram_network_error", message, retryable=True) - await self._notify_fatal_error() + await self._handoff_polling_fatal_error() return delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY) @@ -2523,7 +2523,7 @@ class TelegramAdapter(BasePlatformAdapter): "gateway reconnect." % stuck_for, retryable=True, ) - await self._notify_fatal_error() + await self._handoff_polling_fatal_error() return else: stuck_task_ref = None @@ -2982,7 +2982,24 @@ class TelegramAdapter(BasePlatformAdapter): self.name, stop_error, exc_info=True, ) if not _already_fatal: - await self._notify_fatal_error() + await self._handoff_polling_fatal_error() + + async def _handoff_polling_fatal_error(self) -> None: + """Notify the runner without letting child teardown cancel this owner. + + The runner bounds adapter cleanup in a child task. ``disconnect()`` + cancels the tracked polling-recovery task and the heartbeat task, so + retaining the current notifier in either field would cancel the fatal + callback before the runner can finish its reconnect or shutdown + decision. Release only the current owner from whichever field tracks + it; unrelated tasks remain under teardown control. + """ + current_task = asyncio.current_task() + if self._polling_error_task is current_task: + self._polling_error_task = None + if getattr(self, "_polling_heartbeat_task", None) is current_task: + self._polling_heartbeat_task = None + await self._notify_fatal_error() async def _create_dm_topic( self, diff --git a/plugins/web/ddgs/_search_worker.py b/plugins/web/ddgs/_search_worker.py new file mode 100644 index 000000000000..0521284c53c1 --- /dev/null +++ b/plugins/web/ddgs/_search_worker.py @@ -0,0 +1,113 @@ +"""DDGS search child-process entrypoint (#68096). + +Invoked as ``python plugins/web/ddgs/_search_worker.py`` (script path from the +parent provider). Reads one JSON request from stdin, writes one JSON envelope +to stdout, then exits. + +Request:: + {"query": str, "safe_limit": int} + +Envelope:: + {"ok": true, "results": [...]} + {"ok": false, "error": str} + +Optional test hooks (only when ``HERMES_DDGS_ALLOW_TEST_HOOKS=1``):: + {"query": ..., "safe_limit": ..., "test_hook": "sleep"|"gil"|"success"|"error"|"empty"} +""" + +from __future__ import annotations + +import json +import os +import sys +import time + + +def _hold_gil(secs: int) -> None: + """Block in a foreign call that keeps the GIL (ctypes.PyDLL). + + Mirrors native ``primp`` holding the interpreter lock. ``PyDLL`` (unlike + ``CDLL``/``WinDLL``) does not release the GIL around the call. + """ + import ctypes + + if sys.platform == "win32": + lib = ctypes.PyDLL("kernel32") + lib.Sleep.argtypes = [ctypes.c_uint] + lib.Sleep(int(secs * 1000)) + return + + lib = ctypes.PyDLL(None) + try: + sleep = lib.sleep + except AttributeError: # pragma: no cover — macOS libSystem fallback + sleep = ctypes.PyDLL("/usr/lib/libSystem.B.dylib").sleep + sleep.argtypes = [ctypes.c_uint] + sleep(int(secs)) + + +def _run_test_hook(hook: str) -> dict: + if hook == "sleep": + time.sleep(30) + return {"ok": False, "error": "sleep hook returned unexpectedly"} + if hook == "gil": + _hold_gil(30) + return {"ok": False, "error": "gil hook returned unexpectedly"} + if hook == "success": + return { + "ok": True, + "results": [ + { + "title": "Hit", + "url": "https://example.com", + "description": "body", + "position": 1, + } + ], + } + if hook == "empty": + return {"ok": True, "results": []} + if hook == "error": + return {"ok": False, "error": "RuntimeError: boom"} + return {"ok": False, "error": f"unknown test_hook: {hook!r}"} + + +def _write_envelope(envelope: dict) -> None: + json.dump(envelope, sys.stdout) + sys.stdout.flush() + + +def main() -> int: + try: + request = json.load(sys.stdin) + except Exception as exc: # noqa: BLE001 + _write_envelope({"ok": False, "error": f"invalid request: {exc}"}) + return 2 + + hook = request.get("test_hook") + if hook: + if os.environ.get("HERMES_DDGS_ALLOW_TEST_HOOKS") != "1": + _write_envelope( + {"ok": False, "error": "test_hook refused (hooks not enabled)"} + ) + return 3 + envelope = _run_test_hook(str(hook)) + _write_envelope(envelope) + return 0 if envelope.get("ok") else 1 + + query = str(request.get("query") or "") + safe_limit = max(1, int(request.get("safe_limit") or 1)) + try: + # Import inside main so script startup stays light / patchable. + from plugins.web.ddgs.provider import _run_ddgs_search + + results = _run_ddgs_search(query, safe_limit) + _write_envelope({"ok": True, "results": results}) + return 0 + except Exception as exc: # noqa: BLE001 + _write_envelope({"ok": False, "error": f"{type(exc).__name__}: {exc}"}) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/web/ddgs/provider.py b/plugins/web/ddgs/provider.py index dcdeb0897ac9..44a3aca6c8c3 100644 --- a/plugins/web/ddgs/provider.py +++ b/plugins/web/ddgs/provider.py @@ -8,13 +8,24 @@ canonical implementation. The ``ddgs`` package is an optional dependency. ``is_available()`` reflects whether the package is importable; the plugin still registers either way so ``hermes tools`` can prompt the user to install it. + +Isolation note (#68096): ``ddgs``/``primp`` can block inside native code while +holding the Python GIL. A ``ThreadPoolExecutor`` + ``future.result(timeout=…)`` +cap (see #52118) cannot fire in that state — the waiter never reacquires the +GIL — so the whole Hermes process freezes through Ctrl+C/SIGTERM. Each search +therefore runs in a disposable child process the parent can terminate/kill. """ from __future__ import annotations -import concurrent.futures as _cf +import concurrent.futures as cf +import json import logging -from typing import Any, Dict +import os +import subprocess +import sys +import time +from typing import Any, Dict, Optional from agent.web_search_provider import WebSearchProvider @@ -23,18 +34,28 @@ logger = logging.getLogger(__name__) # Overall wall-clock cap for a single ddgs search. The DDGS constructor's # ``timeout`` only bounds individual HTTP requests; ddgs's multi-engine retry # loop has no overall cap, so a slow/rate-limited DuckDuckGo response can hang -# the (single, shared) agent loop indefinitely and block every platform -# (#36776). Enforce a hard cap here via a worker thread. +# the (single, shared) agent loop indefinitely (#36776). Enforce a hard cap +# here by killing a disposable worker process (#68096). _SEARCH_TIMEOUT_SECS = 30 +# How often the parent polls stdout / interrupt flag while waiting. +_POLL_INTERVAL_SECS = 0.1 + +# After terminate(), wait this long before escalating to kill(). +_TERMINATE_GRACE_SECS = 1.0 + + +class _SearchInterrupted(Exception): + """Raised when tools.interrupt.is_interrupted() trips during a search wait.""" + def _run_ddgs_search(query: str, safe_limit: int) -> list[dict[str, Any]]: """Run the blocking ddgs query and return normalized hits. - Module-level (not a closure) so tests can patch it directly without - spawning a real multi-second worker thread. ``DDGS(timeout=...)`` bounds + Module-level (not a closure) so the child worker can import it and so + tests can patch it for in-process unit tests. ``DDGS(timeout=…)`` bounds each individual HTTP request; the overall wall-clock cap is enforced by - the caller via a future timeout. + the parent via process timeout (#68096). """ from ddgs import DDGS # type: ignore @@ -55,6 +76,192 @@ def _run_ddgs_search(query: str, safe_limit: int) -> list[dict[str, Any]]: return results +# Optional test-only hook name forwarded to the child (see _search_worker.py). +# Production search() never sets this. +_test_hook: Optional[str] = None + +# Last worker Popen started by ``_run_ddgs_search_bounded`` (test reap checks). +_last_worker_proc: Optional[subprocess.Popen] = None + + +def _plugins_path_entry() -> str: + """Return the ``sys.path`` entry that makes ``import plugins`` work. + + Prefer the live ``plugins`` package location over counting ``dirname``s from + this file — that stays correct for source checkouts and site-packages. + """ + try: + import plugins as plugins_pkg + + pkg_file = getattr(plugins_pkg, "__file__", None) + if pkg_file: + return os.path.dirname(os.path.dirname(os.path.abspath(pkg_file))) + except Exception: # noqa: BLE001 — fall through to path-walk fallback + pass + return os.path.dirname( + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + ) + + +def _terminate_and_reap( + proc: Optional[subprocess.Popen], + *, + grace: float = _TERMINATE_GRACE_SECS, +) -> None: + """Terminate a worker, escalate to kill, and wait so no orphan remains. + + Does not close the parent's pipe ends — the caller must finish any + ``communicate()``/reader first. Closing stdout while another thread is + blocked in ``read()`` deadlocks on some platforms. + """ + if proc is None: + return + + def _wait_until_dead(seconds: float) -> bool: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if proc.poll() is not None: + return True + time.sleep(0.05) + return proc.poll() is not None + + try: + if proc.poll() is None: + proc.terminate() + _wait_until_dead(grace) + if proc.poll() is None: + proc.kill() + if not _wait_until_dead(grace): + logger.warning("DDGS worker pid=%s did not exit after kill", proc.pid) + except Exception as exc: # noqa: BLE001 — best-effort cleanup + logger.debug("DDGS worker reap error: %s", exc) + + +def _run_ddgs_search_bounded(query: str, safe_limit: int) -> list[dict[str, Any]]: + """Run ``_run_ddgs_search`` in a disposable process with a hard deadline. + + The parent never joins the child while it may be inside native code holding + *its* GIL — it only polls a communicator thread and, on timeout/interrupt, + terminates the child OS process. Raises ``TimeoutError``, + ``_SearchInterrupted``, or ``RuntimeError``. + """ + # Imported lazily so plugin import stays light for ``hermes tools`` probes. + from tools.interrupt import is_interrupted + + global _last_worker_proc + + request: dict[str, Any] = {"query": query, "safe_limit": safe_limit} + if _test_hook: + request["test_hook"] = _test_hook + + from tools.environments.local import _sanitize_subprocess_env + + env = _sanitize_subprocess_env(dict(os.environ)) + if _test_hook: + env["HERMES_DDGS_ALLOW_TEST_HOOKS"] = "1" + + # Running the worker as a script puts ``plugins/web/ddgs/`` on ``sys.path[0]``, + # which breaks ``import plugins...``. Prepend the path entry that makes the + # live ``plugins`` package importable (source tree or site-packages). + child_pythonpath = env.get("PYTHONPATH", "") + path_entry = _plugins_path_entry() + if path_entry and path_entry not in child_pythonpath.split(os.pathsep): + env["PYTHONPATH"] = ( + path_entry + os.pathsep + child_pythonpath if child_pythonpath else path_entry + ) + + worker_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_search_worker.py") + # Platform-only spawn knobs — stdin/stdout/stderr must stay as explicit + # keyword args on the Popen call so scripts/check_subprocess_stdin.py can + # see them (TUI gateway inherits stdin; #14036). + extra_kwargs: dict[str, Any] = {} + if sys.platform == "win32": + # New process group so terminate/kill reach the worker cleanly on Windows. + extra_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + # Own session so a hung primp/libcurl grandchild can be reaped with the worker. + extra_kwargs["start_new_session"] = True + + proc = subprocess.Popen( + [sys.executable, worker_path], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + # DEVNULL avoids the classic deadlock where a chatty child fills the + # stderr pipe buffer while the parent only drains stdout. + stderr=subprocess.DEVNULL, + env=env, + text=True, + **extra_kwargs, + ) + _last_worker_proc = proc + + # ``communicate`` runs in a side thread so the parent can poll interrupt / + # deadline without blocking. Killing the child unblocks communicate. + pool = cf.ThreadPoolExecutor(max_workers=1) + fut = pool.submit(proc.communicate, json.dumps(request)) + timed_out = False + interrupted = False + raw = "" + try: + deadline = time.monotonic() + _SEARCH_TIMEOUT_SECS + while True: + if is_interrupted(): + interrupted = True + break + remaining = deadline - time.monotonic() + if remaining <= 0: + timed_out = True + break + try: + out, _err = fut.result(timeout=min(_POLL_INTERVAL_SECS, remaining)) + raw = out or "" + break + except cf.TimeoutError: + continue + finally: + _terminate_and_reap(proc) + # After kill, communicate should return promptly; don't block forever. + if not fut.done(): + try: + out, _err = fut.result(timeout=_TERMINATE_GRACE_SECS) + if not raw: + raw = out or "" + except Exception: # noqa: BLE001 + pass + pool.shutdown(wait=False, cancel_futures=True) + + if interrupted: + raise _SearchInterrupted("DuckDuckGo search interrupted") + if timed_out: + raise TimeoutError( + f"DuckDuckGo search timed out after {_SEARCH_TIMEOUT_SECS}s" + ) + + raw = raw.strip() + if not raw: + raise RuntimeError( + f"DDGS worker exited without a result (code={proc.poll()})" + ) + + try: + envelope = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"DDGS worker returned invalid JSON: {raw[:200]!r}" + ) from exc + + if not isinstance(envelope, dict): + raise RuntimeError(f"DDGS worker returned an invalid envelope: {envelope!r}") + if envelope.get("ok"): + results = envelope.get("results") or [] + if not isinstance(results, list): + raise RuntimeError("DDGS worker returned non-list results") + return results + raise RuntimeError(str(envelope.get("error") or "DDGS worker failed")) + + class DDGSWebSearchProvider(WebSearchProvider): """DuckDuckGo HTML-scrape search provider. @@ -94,9 +301,9 @@ class DDGSWebSearchProvider(WebSearchProvider): def search(self, query: str, limit: int = 5) -> Dict[str, Any]: """Execute a DuckDuckGo search and return normalized results. - The synchronous ``ddgs`` call is run in a worker thread with a hard - wall-clock timeout (``_SEARCH_TIMEOUT_SECS``) so a hung search cannot - block the shared agent loop indefinitely (#36776). + The synchronous ``ddgs`` call runs in a disposable child process with + a hard wall-clock timeout (``_SEARCH_TIMEOUT_SECS``) so a hung native + ``primp`` call cannot freeze the Hermes process (#36776, #68096). """ try: import ddgs # type: ignore # noqa: F401 — availability probe @@ -110,40 +317,35 @@ class DDGSWebSearchProvider(WebSearchProvider): # in case the package ignores the hint. safe_limit = max(1, int(limit)) - # A fresh single-worker pool per call (rather than a module-level one) - # is intentional: on timeout the blocking ddgs call cannot be cancelled - # and keeps running, so a shared pool would serialise every later search - # behind that hung worker. A per-call pool isolates each search from a - # previously-hung one. - pool = _cf.ThreadPoolExecutor(max_workers=1) try: - future = pool.submit(_run_ddgs_search, query, safe_limit) - try: - web_results = future.result(timeout=_SEARCH_TIMEOUT_SECS) - except _cf.TimeoutError: - logger.warning( - "DDGS search timed out after %ds for query: %r", - _SEARCH_TIMEOUT_SECS, query, - ) - return { - "success": False, - "error": ( - f"DuckDuckGo search timed out after {_SEARCH_TIMEOUT_SECS}s — " - "DuckDuckGo may be rate-limiting or slow. Try again later " - "or switch to a different search provider." - ), - } + web_results = _run_ddgs_search_bounded(query, safe_limit) + except TimeoutError: + logger.warning( + "DDGS search timed out after %ds for query: %r", + _SEARCH_TIMEOUT_SECS, + query, + ) + return { + "success": False, + "error": ( + f"DuckDuckGo search timed out after {_SEARCH_TIMEOUT_SECS}s — " + "DuckDuckGo may be rate-limiting or slow. Try again later " + "or switch to a different search provider." + ), + } + except _SearchInterrupted: + logger.info("DDGS search interrupted for query: %r", query) + return { + "success": False, + "error": "DuckDuckGo search interrupted", + } except Exception as exc: # noqa: BLE001 — ddgs raises its own exceptions logger.warning("DDGS search error: %s", exc) return {"success": False, "error": f"DuckDuckGo search failed: {exc}"} - finally: - # Return immediately without joining the worker. On timeout the - # already-running ddgs call can't be cancelled (cancel_futures only - # affects not-yet-started work), so the worker runs to completion - # on its own; it writes nothing shared, so leaking it is safe. - pool.shutdown(wait=False, cancel_futures=True) - logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit) + logger.info( + "DDGS search '%s': %d results (limit %d)", query, len(web_results), limit + ) return {"success": True, "data": {"web": web_results}} def get_setup_schema(self) -> Dict[str, Any]: diff --git a/pyproject.toml b/pyproject.toml index 423314878f78..ca3648279575 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta" [project] name = "hermes-agent" -version = "0.18.2" +version = "0.19.0" description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere" readme = "README.md" # Upper bound is load-bearing, not cosmetic. uv resolves the project's diff --git a/run_agent.py b/run_agent.py index 6c13f737c861..d649addce7ea 100644 --- a/run_agent.py +++ b/run_agent.py @@ -65,6 +65,81 @@ from types import SimpleNamespace from hermes_constants import get_hermes_home +# --------------------------------------------------------------------------- +# Code-skew detection for the desktop/serve backend (#68178). +# +# The agent core is imported once at startup. If an auto-update (``git pull`` +# / ``hermes update``) rewrites the source tree underneath a running process, +# any lazy import that resolves a newly-added symbol from a freshly-updated +# file will load new code against a stale in-memory ``AIAgent`` class — +# producing an ``AttributeError`` that the conversation loop would otherwise +# retry indefinitely, burning provider API calls. +# +# We snapshot the checkout revision at module import time and expose a cheap +# check that the outer loop can use to refuse new work with a clear message. +# --------------------------------------------------------------------------- +_agent_boot_fingerprint: str | None = None + + +def _record_agent_boot_fingerprint() -> None: + """Snapshot the checkout revision when ``run_agent`` is first imported. + + Idempotent — subsequent calls are no-ops. Safe on non-git installs + (falls back to ``None`` and the skew check becomes a no-op). + """ + global _agent_boot_fingerprint + if _agent_boot_fingerprint is not None: + return + try: + from hermes_cli.main import _read_git_revision_fingerprint + + _agent_boot_fingerprint = _read_git_revision_fingerprint( + Path(__file__).resolve().parent + ) + except Exception: + _agent_boot_fingerprint = None + + +_record_agent_boot_fingerprint() + +# Cached result of the first confirmed skew detection. Once skew is found +# it is irreversible without external intervention (git reset/checkout), so +# we avoid repeated disk I/O on every turn. +_agent_code_skew_confirmed: bool = False +_agent_code_skew_labels: tuple[str, str] | None = None + + +def _detect_agent_code_skew() -> tuple[str, str] | None: + """Check whether the checkout revision has drifted since this process + started. Returns ``(boot_rev, disk_rev)`` short labels if skew is + detected, else ``None``. Once confirmed, the result is cached. + + See #68178. + """ + global _agent_code_skew_confirmed, _agent_code_skew_labels + if _agent_code_skew_confirmed: + return _agent_code_skew_labels + if _agent_boot_fingerprint is None: + return None + try: + from hermes_cli.main import _read_git_revision_fingerprint + + current = _read_git_revision_fingerprint(Path(__file__).resolve().parent) + except Exception: + return None + if current is None or current == _agent_boot_fingerprint: + return None + # Skew confirmed — cache permanently for this process. + def _short(fp: str) -> str: + sha = fp.rsplit(":", 1)[-1] + if sha and sha != "unresolved" and len(sha) > 10: + return sha[:10] + return sha or fp + _agent_code_skew_confirmed = True + _agent_code_skew_labels = (_short(_agent_boot_fingerprint), _short(current)) + return _agent_code_skew_labels + + def _launch_cwd_for_session(source: str) -> Optional[str]: """Working directory to stamp on a new session row, or None. @@ -6418,6 +6493,30 @@ class AIAgent: result = self.run_conversation(message, stream_callback=stream_callback) return result["final_response"] + def _check_code_skew_before_turn(self) -> str | None: + """Return a warning string if the source tree has been updated + underneath this process (code skew), else ``None``. + + Long-lived desktop/serve backend processes can have their source + rewritten by an auto-update while still running. If a lazy import + (e.g. ``agent/conversation_loop.py``) resolves newly-added symbols + against the stale in-memory ``AIAgent`` class, it produces an + ``AttributeError`` that would otherwise retry indefinitely. + + When skew is detected, the caller should refuse new work with a + clear message. See #68178. + """ + skew = _detect_agent_code_skew() + if skew is None: + return None + boot_rev, disk_rev = skew + return ( + f"Code skew detected: this process was loaded at revision {boot_rev} " + f"but the source tree is now at {disk_rev}. A lazy import could resolve " + f"new symbols against the stale in-memory class (AttributeError). " + f"Please restart the application to apply the update safely." + ) + def _run_codex_app_server_turn( self, *, diff --git a/scripts/ci/assemble_review_comment.py b/scripts/ci/assemble_review_comment.py new file mode 100644 index 000000000000..0f701f13e338 --- /dev/null +++ b/scripts/ci/assemble_review_comment.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""Assemble the unified CI review comment for a pull request. + +Every CI job that wants to appear in the review comment emits a +``review_status`` output: a JSON array of objects, each with a ``source`` +(the workflow name, used for dedup) and a ``results`` array of typed +result objects:: + + [ + { + "source": "review-label-gate", + "results": [ + {"kind": "action_required", "title": "...", "summary": "...", + "how_to_fix": "..."}, + {"kind": "info", "title": "...", "summary": "..."} + ] + }, + { + "source": "ci-timings", + "results": [ + {"kind": "warning", "title": "CI timings", "summary": "...", + "detail": "...", "link": "..."} + ] + } + ] + +Each result object has: + + kind: "error" | "action_required" | "warning" | "info" + title: section heading + summary: one-line description + detail: markdown detail (optional) + how_to_fix: markdown checklist (optional) + link: URL (optional) + link_label: label for the link (optional, default "View logs") + +The assembler flattens all results into a flat list of ReviewItems, +grouped by severity in the comment. Jobs that failed (from the +``needs`` context) but didn't emit any status get synthesized ❌ Error +items. Jobs that DID emit a status are excluded from the synthesized +error list — their own output is the authority for their classification. + +Exits 0 always — comment posting is best-effort (fork PRs are read-only). +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +# Hidden marker the comment system uses to find-and-edit its +# previous comment instead of stacking new ones on each run. +MARKER = "" + +# Severity ordering for display. +_SEVERITY_ORDER = ["error", "action_required", "warning", "info"] + +# Severities that trigger the "blocking issues" layout (vs. the +# "looks good!" banner). +_BLOCKING_SEVERITIES = ("error", "action_required", "warning") + +_SEVERITY_GROUP_HEADER = { + "error": "## ❌ Job failures", + "action_required": "## ⚠️ Action required", + "warning": "## ⚠️ Warnings", + "info": "## ℹ️ Details", +} + + +@dataclass +class ReviewItem: + """A single piece of review information with a severity tag.""" + + severity: str # "error" | "action_required" | "warning" | "info" + title: str # short section title, e.g. "package-lock.json" + summary: str # one-line summary + detail: str = "" # optional markdown detail (tables, bullet lists, etc.) + link: str = "" # optional URL emitted by the job (e.g. report URL) + link_label: str = "View report" # label for the emitted link + how_to_fix: str = "" # optional markdown checklist for action_required items + source: str = "" # workflow that declared this status (for dedup) + job_url: str = "" # auto-attached per-job log link (from the live poller) + + +# --------------------------------------------------------------------------- +# Collectors — each returns a list of ReviewItems (possibly empty) +# --------------------------------------------------------------------------- + + +def collect_from_statuses(review_statuses_json: str) -> tuple[list[ReviewItem], set[str]]: + """Parse the nested review_status JSON into flat ReviewItems. + + The input is a JSON array of ``{source, results: [...]}`` objects. + Each entry in ``results`` becomes one ReviewItem, tagged with the + parent's ``source``. + + Returns ``(items, sources)`` where ``sources`` is the set of source + values — used by :func:`collect_failed_jobs` to exclude jobs that + already declared their own status (so a failing job that emitted an + ``action_required`` status doesn't also show as a synthesized ❌ Error). + """ + if not review_statuses_json: + return [], set() + try: + data = json.loads(review_statuses_json) + except (json.JSONDecodeError, TypeError): + return [], set() + if not isinstance(data, list): + return [], set() + + items: list[ReviewItem] = [] + sources: set[str] = set() + + for entry in data: + if not isinstance(entry, dict): + continue + source = entry.get("source", "") + if source: + sources.add(source) + for r in entry.get("results", []): + if not isinstance(r, dict): + continue + kind = r.get("kind", "info") + if kind not in _SEVERITY_ORDER: + kind = "info" + items.append(ReviewItem( + severity=kind, + title=r.get("title", "Unknown"), + summary=r.get("summary", ""), + detail=r.get("detail", ""), + link=r.get("link", ""), + link_label=r.get("link_label", "View logs"), + how_to_fix=r.get("how_to_fix", ""), + source=source, + )) + + return items, sources + + +def collect_failed_jobs( + needs_json: str, + run_url: str, + exclude_sources: set[str] | None = None, + job_urls: dict[str, str] | None = None, +) -> list[ReviewItem]: + """Build error items for failed CI jobs from the ``needs`` context. + + ``needs_json`` is the JSON string emitted by ``all-checks-pass`` — a + ``{job_name: result}`` dict where result is ``success`` / ``failure`` + / ``skipped``. Only ``failure`` entries become error items. + + ``exclude_sources`` is a set of ``source`` values from status objects + declared by workflow_call jobs. Job names containing any of these + source strings are excluded — their failure is already covered by their + own status output. + + ``job_urls`` is an optional ``{job_name: html_url}`` dict from the + live poller. When a job's name is in this dict, the ❌ Error link + points directly to that job's logs page instead of the whole run. + Falls back to ``run_url`` when no per-job URL is available. + """ + if not needs_json: + return [] + try: + needs = json.loads(needs_json) + except (json.JSONDecodeError, TypeError): + return [] + + # Pre-normalize exclude sources once: lowercase + hyphens→spaces, so + # "review-label-gate" matches "Review label gate / Review label gate". + norm_sources = { + src.lower().replace("-", " ") for src in (exclude_sources or set()) + } + + items: list[ReviewItem] = [] + for name, result in sorted(needs.items()): + if result != "failure": + continue + if norm_sources: + norm = name.lower().replace("-", " ") + if any(src in norm for src in norm_sources): + continue + job_url = (job_urls or {}).get(name, run_url) + items.append(ReviewItem( + severity="error", + title=name, + summary=f"Job **{name}** failed.", + job_url=job_url, + )) + return items + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def _render_item(item: ReviewItem) -> str: + """Render a single ReviewItem as a markdown block. + + The group header (``## ❌ Job failures`` etc.) carries the severity + emoji, so items don't repeat it. Links are shown inline next to the + title. Layout per item:: + + ### {title} · [View report](url) · [View job](url) + + {summary} + + {detail} + + **How to fix:** + + {how_to_fix} + """ + title = f"### {item.title}" + # Build inline links next to the title. + links: list[str] = [] + if item.link: + links.append(f"[{item.link_label}]({item.link})") + if item.job_url: + links.append(f"[View job]({item.job_url})") + if links: + title += " · " + " · ".join(links) + + parts = [title, "", item.summary] + + if item.detail: + parts += ["", item.detail] + if item.how_to_fix: + parts += ["", "**How to fix:**", "", item.how_to_fix] + + return "\n".join(parts) + + +def _render_group(header: str, items: list[ReviewItem]) -> str: + """Render a severity group: ``##`` header + items separated by ``---``.""" + blocks = [_render_item(i) for i in items] + return f"{header}\n\n" + "\n\n---\n\n".join(blocks) + + +def _render_info_details(items: list[ReviewItem]) -> str: + """Render each info item as its own collapsible ``
`` block.""" + blocks = [] + for item in items: + inner = _render_item(item) + blocks.append( + f"
\n{item.title}\n\n{inner}\n\n
" + ) + return "\n\n".join(blocks) + + +def _render_pending_items(pending_jobs: list[str]) -> str: + """Render the dimmed ```` items for jobs still running.""" + job_list = ", ".join(f"`{j}`" for j in sorted(pending_jobs)) + return f"\n\n---\n\nStill running {len(pending_jobs)} job{'s' if len(pending_jobs) != 1 else ''}: {job_list}\n" + + +def render_comment(items: list[ReviewItem], pending_jobs: list[str] | None = None, commit_info: str = "") -> str: + """Render the full comment body from a list of review items. + + Items are grouped by severity under ``##`` group headers, separated + by ``---``. Errors and action_required items are always visible. + Warnings are shown only when present. Info items are in a collapsible + ``
`` block. If ``pending_jobs`` is non-empty, a dimmed + ```` footer is appended listing jobs still running. + + When there are no errors, action_required, or warnings (only info + items, or nothing at all), a "looks good!" banner is shown at the top, + and info items (if any) follow in a collapsible ``
`` block. + """ + pending = pending_jobs or [] + + # Group by severity + by_severity: dict[str, list[ReviewItem]] = {s: [] for s in _SEVERITY_ORDER} + for item in items: + by_severity.setdefault(item.severity, []).append(item) + + info = by_severity.get("info", []) + has_blocking = any(by_severity.get(s) for s in _BLOCKING_SEVERITIES) + + body = f"{MARKER}\n# ૮ >ﻌ< ა ci review\n\n" + + if commit_info: + body += f"{commit_info}\n\n" + + if not items and not pending: + return f"{body}looks good to me!" + + sections: list[str] = [] + + for sev in _BLOCKING_SEVERITIES: + group = by_severity.get(sev, []) + if group: + sections.append(_render_group(_SEVERITY_GROUP_HEADER[sev], group)) + + # Info: collapsible
+ if info: + sections.append(_render_info_details(info)) + + if pending: + body += _render_pending_items(pending) + + if sections: + body += "\n\n---\n\n".join(sections) + + return body + + +# --------------------------------------------------------------------------- +# Assembly +# --------------------------------------------------------------------------- + + +def _attach_job_urls(items: list[ReviewItem], job_urls: dict[str, str], run_url: str) -> None: + """Fill in per-job log links for all items. + + Uses the same case-insensitive, hyphen-normalized matching as + :func:`collect_failed_jobs`: the item's ``source`` is matched against + job names in ``job_urls``. Sets ``job_url`` on the item — this is + separate from ``link`` (the job-emitted URL, e.g. a report artifact), + so both can appear in the rendered comment. + """ + if not job_urls and not run_url: + return + # Pre-normalize job_url keys once. + norm_urls: dict[str, str] = {} + for name, url in job_urls.items(): + norm_urls[name.lower().replace("-", " ")] = url + + for item in items: + if item.job_url: + continue + src = item.source.lower().replace("-", " ") + # Try exact match first, then substring match. + if src in norm_urls: + item.job_url = norm_urls[src] + continue + for norm_name, url in norm_urls.items(): + if src and src in norm_name: + item.job_url = url + break + # If no per-job URL found, fall back to run_url for items with a source. + if not item.job_url and item.source and run_url: + item.job_url = run_url + + +def assemble( + needs_json: str = "", + run_url: str = "", + job_urls: dict[str, str] | None = None, + review_statuses_json: str = "", + pending_jobs: list[str] | None = None, + commit_info: str = "", +) -> str: + """Assemble the full comment body from all available inputs.""" + items: list[ReviewItem] = [] + + # 1. Structured statuses from workflow_call jobs (review-labels, etc.) + status_items, sources = collect_from_statuses(review_statuses_json) + items.extend(status_items) + + # 2. Synthesized error items for failed jobs not covered by statuses + items.extend(collect_failed_jobs(needs_json, run_url, exclude_sources=sources, job_urls=job_urls)) + + # 3. Attach per-job log links to all items (not just synthesized errors) + _attach_job_urls(items, job_urls or {}, run_url) + + return render_comment(items, pending_jobs, commit_info) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--needs-json", + default="", + help="JSON string of {job_name: result} from the all-checks-pass job.", + ) + parser.add_argument( + "--run-url", + default="", + help="URL to the CI run summary page (for failed job links).", + ) + parser.add_argument( + "--review-statuses-json", + default="", + help="JSON array of {source, results: [...]} objects from workflow_call jobs.", + ) + parser.add_argument( + "--pending-jobs", + default="", + help="Comma-separated list of job names still running (shown in a dimmed footer).", + ) + parser.add_argument( + "--output", + type=Path, + required=True, + help="Output file for the assembled comment body.", + ) + args = parser.parse_args() + + pending = [j.strip() for j in args.pending_jobs.split(",") if j.strip()] if args.pending_jobs else None + + body = assemble( + needs_json=args.needs_json, + run_url=args.run_url, + review_statuses_json=args.review_statuses_json, + pending_jobs=pending, + ) + + args.output.write_text(body) + print(f"Wrote {len(body)} chars to {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/emit_review_status.py b/scripts/ci/emit_review_status.py new file mode 100644 index 000000000000..dcf58a7a0e19 --- /dev/null +++ b/scripts/ci/emit_review_status.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Emit review_status JSON for the review-labels workflow. + +Builds a JSON array with one entry:: + + [ + { + "source": "review-label-gate", + "results": [ + {"kind": "action_required", "title": "...", "summary": "...", + "how_to_fix": "..."}, + {"kind": "info", "title": "...", "summary": "..."} + ] + } + ] + +The ``source`` field is the workflow name that declared the status; the +assembler uses it to exclude the corresponding job from the synthesized +❌ Error list (the job already has its own status section). + +The array can contain 0, 1, or 2 results — one per lane that ran +(``ci_review``, ``mcp_catalog``). When the ``ci-reviewed`` label is +present, the kind is ``info``; when missing, it's ``action_required`` +with the verification checklist. +""" + +from __future__ import annotations + +import argparse +import json +import sys + +# The source identifier used for error-synthesis exclusion. This must +# match (as a normalized substring) the job name as it appears in the +# GitHub Actions API. The ci.yml job key is ``review-labels`` with +# ``name: Review label gate``, and the reusable workflow's job is also +# ``name: Review label gate``, so the API shows the job as +# "Review label gate / Review label gate". Normalizing "review-label-gate" +# (lowercase, hyphens→spaces) gives "review label gate", which is a +# substring of "review label gate / review label gate". +SOURCE = "review-label-gate" + + +def build_results( + ci_review: bool, + mcp_catalog: bool, + label_present: bool, +) -> list[dict]: + """Build the list of result objects for this source.""" + results: list[dict] = [] + + if ci_review: + if label_present: + results.append({ + "kind": "info", + "title": "CI-sensitive file review", + "summary": "`ci-reviewed` label is present.", + }) + else: + results.append({ + "kind": "action_required", + "title": "CI-sensitive file review", + "summary": ( + "This PR changes CI-sensitive files (eslint config, " + "workflow YAMLs, or composite actions). These influence " + "what the js-autofix job executes and pushes to main." + ), + "how_to_fix": ( + "Add the `ci-reviewed` label after verifying:\n" + "- no new eslint rules with custom `fix` functions that write outside linted paths,\n" + "- no workflow changes that widen permissions or remove guards,\n" + "- no composite action changes that alter what gets executed." + ), + }) + + if mcp_catalog: + if label_present: + results.append({ + "kind": "info", + "title": "MCP catalog security review", + "summary": "`ci-reviewed` label is present.", + }) + else: + results.append({ + "kind": "action_required", + "title": "MCP catalog security review", + "summary": ( + "This PR changes the bundled MCP catalog or MCP catalog " + "installer code. MCP entries can define local commands " + "that users later install into `mcp_servers`, so this " + "needs explicit maintainer review before merge." + ), + "how_to_fix": ( + "Add the `ci-reviewed` label after verifying:\n" + "- any new/changed `optional-mcps/**/manifest.yaml` command and args are expected,\n" + "- stdio transports do not use shell+egress/exfiltration payloads,\n" + "- git install refs are pinned and bootstrap commands are minimal,\n" + "- requested env vars/secrets match the upstream MCP's documented needs." + ), + }) + + return results + + +def build_statuses( + ci_review: bool, + mcp_catalog: bool, + label_present: bool, +) -> list[dict]: + """Build the full review_status array (one entry with a results list).""" + results = build_results(ci_review, mcp_catalog, label_present) + if not results: + return [] + return [{"source": SOURCE, "results": results}] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ci-review", action="store_true", + help="Whether CI-sensitive files changed.") + parser.add_argument("--mcp-catalog", action="store_true", + help="Whether the MCP catalog / installer changed.") + parser.add_argument("--label-present", action="store_true", + help="Whether the ci-reviewed label is present.") + parser.add_argument("--output", default="-", + help="Output file ('-' for stdout, or a GITHUB_OUTPUT path).") + args = parser.parse_args() + + statuses = build_statuses(args.ci_review, args.mcp_catalog, args.label_present) + json_str = json.dumps(statuses) + + if args.output == "-": + print(json_str) + else: + # GITHUB_OUTPUT format: key=value\n + with open(args.output, "a", encoding="utf-8") as f: + f.write(f"review_status={json_str}\n") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/live_comment.py b/scripts/ci/live_comment.py new file mode 100644 index 000000000000..e4ca5c9e2de5 --- /dev/null +++ b/scripts/ci/live_comment.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 +"""Live-updating CI review comment. + +Polls the GitHub Actions API for job statuses in the current run, assembles +the review comment from whatever results are available, and upserts it as a +PR comment. Repeats every ``--interval`` seconds until all jobs are +completed (or ``--timeout`` is reached), so the comment updates in real time +as each job finishes. + +The comment is identified by the ```` marker +— the same one ``assemble_review_comment.py`` uses — so it replaces any +previous comment from an earlier run. + +Architecture: + + - :func:`classify_jobs` (pure, testable) — takes a list of raw API job + dicts and returns ``(completed, pending, job_urls)`` where ``completed`` + is a ``{name: result}`` dict (for :func:`assemble_review_comment.assemble`) + and ``pending`` is a list of job names still running. + + - :func:`find_comment_id` / :func:`upsert_comment` — thin API wrappers. + + - :func:`_fetch_timings_statuses` — downloads the ci-timings artifact + (if available) and parses the ``review_status=`` line from it, merging + the status objects into the review statuses array. + + - :func:`run` — the polling loop. Calls the API, classifies, assembles, + upserts, sleeps, repeats. Exits when all jobs are completed. + +The orchestrator job names (detect, all-checks-pass, comment-live, etc.) +are excluded from the comment — they're infrastructure, not review signal. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +API_BASE = "https://api.github.com" + +# Job names that are infrastructure (this script, the gate, the detector) +# and should never appear in the review comment. +_INFRA_JOBS = frozenset({ + "detect", + "all-checks-pass", + "comment-pending", + "comment-results", + "comment-live", + "CI review comment (pending)", + "CI review comment (results)", + "CI review comment (live)", + "All required checks pass", + "Detect affected areas", +}) + +# Map GitHub API conclusion values to our result strings. +_CONCLUSION_MAP = { + "success": "success", + "failure": "failure", + "skipped": "skipped", + "cancelled": "skipped", + "neutral": "skipped", + "timed_out": "failure", + "action_required": "skipped", +} + + +def classify_jobs(api_jobs: list[dict]) -> tuple[dict[str, str], list[str], dict[str, str]]: + """Classify raw API job dicts into completed + pending + job_urls. + + Returns ``(completed, pending, job_urls)``: + + - ``completed``: ``{job_name: result}`` where result is + ``"success"`` / ``"failure"`` / ``"skipped"``. Only non-infra jobs + that have finished. + - ``pending``: list of job names still running (in_progress / queued + / waiting). Excludes infra jobs. + - ``job_urls``: ``{job_name: html_url}`` — direct links to each + job's logs page, for the assembler to use in ❌ Error links. + + The API returns orchestrator-level jobs and sub-workflow jobs + (workflow_call) in separate runs — :func:`collect_run_jobs` merges + them. Each sub-workflow job has a ``_workflow_name`` prefix so the + display name is ``"Workflow / job"``. + """ + completed: dict[str, str] = {} + pending: list[str] = [] + job_urls: dict[str, str] = {} + + for job in api_jobs: + name = job.get("name", "unknown") + if job.get("_workflow_name"): + name = f"{job['_workflow_name']} / {name}" + if name in _INFRA_JOBS: + continue + status = job.get("status", "") + conclusion = job.get("conclusion", "") + html_url = job.get("html_url", "") + + if html_url: + job_urls[name] = html_url + + if status in ("in_progress", "queued", "waiting"): + pending.append(name) + elif status == "completed": + result = _CONCLUSION_MAP.get(conclusion, "skipped") + completed[name] = result + # else: unknown status → skip + + return completed, pending, job_urls + + +# --------------------------------------------------------------------------- +# API helpers +# --------------------------------------------------------------------------- + + +def _api_request(url: str, token: str) -> dict: + """Authenticated GitHub API GET (single page).""" + 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-live-comment", + }) + with urllib.request.urlopen(req) as resp: + data: dict = json.loads(resp.read()) + return data + + +def _api_get_paginated(url: str, token: str, list_key: str | None = None) -> list: + """Authenticated GitHub API GET with pagination.""" + 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-live-comment", + }) + 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 collect_run_jobs(token: str, repo: str, run_id: str) -> list[dict]: + """Collect all jobs in the orchestrator run + sub-workflow runs. + + Returns a flat list of job dicts (same shape as the API returns, plus + ``_workflow_name`` on sub-workflow jobs). + """ + owner, repo_name = repo.split("/") + run_info = _api_request(f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}", token) + created_at = run_info.get("created_at", "") + head_sha = run_info.get("head_sha", "") + + # Orchestrator jobs + orch_jobs = _api_get_paginated( + f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs", + token, list_key="jobs", + ) + + # Sub-workflow runs (workflow_call) + sub_runs = _api_get_paginated( + f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs?head_sha={head_sha}&event=workflow_call&per_page=100", + token, list_key="workflow_runs", + ) + sub_runs = [r for r in sub_runs if r.get("created_at", "") >= created_at] + + all_jobs: list[dict] = [] + # Orchestrator jobs: skip workflow-call placeholder steps (they're + # sub-workflow triggers, not review signal), but KEEP in_progress / + # queued jobs so the poller knows they're still running. + for job in orch_jobs: + steps = job.get("steps") or [] + if any(s.get("name", "").startswith("Run ./.github/") for s in steps): + continue + all_jobs.append(job) + + # Sub-workflow jobs (workflow_call). + # These runs may not exist yet on the first few polls — that's fine, + # classify_jobs() will just show 0 pending for them. + for sr in sub_runs: + sr_id = sr["id"] + sr_name = sr.get("name", "") + sr_jobs = _api_get_paginated( + f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{sr_id}/jobs", + token, list_key="jobs", + ) + for j in sr_jobs: + j["_workflow_name"] = sr_name + all_jobs.append(j) + + return all_jobs + + +def find_comment_id(token: str, repo: str, pr_number: str) -> int | None: + """Find our existing review comment by marker prefix.""" + owner, repo_name = repo.split("/") + comments = _api_get_paginated( + f"{API_BASE}/repos/{owner}/{repo_name}/issues/{pr_number}/comments", + token, + ) + for c in comments: + body = c.get("body", "") if isinstance(c, dict) else "" + if body.startswith(""): + return c.get("id") if isinstance(c, dict) else None + return None + + +def upsert_comment( + token: str, repo: str, pr_number: str, body: str, comment_id: int | None = None +) -> int | None: + """Create or update the review comment. Returns the comment ID.""" + owner, repo_name = repo.split("/") + if comment_id is None: + comment_id = find_comment_id(token, repo, pr_number) + + if comment_id: + url = f"{API_BASE}/repos/{owner}/{repo_name}/issues/comments/{comment_id}" + method = "PATCH" + else: + url = f"{API_BASE}/repos/{owner}/{repo_name}/issues/{pr_number}/comments" + method = "POST" + + data = json.dumps({"body": body}).encode("utf-8") + req = urllib.request.Request(url, data=data, method=method, headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + "User-Agent": "ci-live-comment", + }) + try: + with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read()) + return result.get("id") + except urllib.error.HTTPError as e: + print(f" API error {e.code}: {e.reason}", file=sys.stderr) + return None + + +# --------------------------------------------------------------------------- +# Artifact fetching (ci-timings review_status) +# --------------------------------------------------------------------------- + + +def _fetch_artifact_statuses( + token: str, repo: str, run_id: str, artifact_name: str, +) -> list[dict]: + """Download a workflow artifact and extract review_status entries. + + The ci-timings job writes a ``review-status.json`` file containing + ``review_status=`` (GITHUB_OUTPUT format) into its artifact. + This function downloads the artifact, parses the line, and returns + the parsed status array. Returns ``[]`` if the artifact doesn't exist + yet or can't be parsed. + """ + try: + result = subprocess.run( + ["gh", "run", "download", run_id, "--repo", repo, + "--name", artifact_name, "--dir", "/tmp/artifact-dl"], + capture_output=True, timeout=30, + ) + if result.returncode != 0: + return [] + except Exception: + return [] + + status_file = Path("/tmp/artifact-dl/review-status.json") + if not status_file.exists(): + return [] + + try: + content = status_file.read_text(encoding="utf-8").strip() + # GITHUB_OUTPUT format: review_status= + if content.startswith("review_status="): + content = content[len("review_status="):] + statuses = json.loads(content) + if isinstance(statuses, list): + return statuses + except (json.JSONDecodeError, OSError): + pass + + return [] + + +# --------------------------------------------------------------------------- +# Comment assembly +# --------------------------------------------------------------------------- + + +def _import_assembler(): + """Import assemble_review_comment.py from the same directory.""" + here = Path(__file__).resolve().parent + sys.path.insert(0, str(here)) + import assemble_review_comment as asm + return asm + + +def build_comment_body( + asm_mod, + completed: dict[str, str], + pending: list[str], + run_url: str, + job_urls: dict[str, str], + review_statuses_json: str, + commit_info: str = "", +) -> str: + """Assemble the comment body from current job states + static inputs.""" + needs_json = json.dumps(completed) if completed else "" + + return asm_mod.assemble( + needs_json=needs_json, + run_url=run_url, + job_urls=job_urls, + review_statuses_json=review_statuses_json, + pending_jobs=pending if pending else None, + commit_info=commit_info, + ) + + +def _merge_statuses( + base_statuses: list[dict], extra_statuses: list[dict] +) -> str: + """Merge two status arrays into one JSON string.""" + merged = list(base_statuses) + list(extra_statuses) + return json.dumps(merged) if merged else "" + + +# --------------------------------------------------------------------------- +# Polling loop +# --------------------------------------------------------------------------- + + +def run( + token: str, + repo: str, + run_id: str, + pr_number: str, + run_url: str, + review_statuses_json: str = "", + commit_info: str = "", + interval: int = 15, + timeout: int = 1800, + dry_run: bool = False, +) -> int: + """Poll for job statuses and update the PR comment until all done. + + Returns 0 always — comment posting is best-effort. + """ + asm = _import_assembler() + start = time.time() + last_body = "" + + # Parse the base statuses once (from review-labels, lockfile-diff, etc.) + try: + base_statuses = json.loads(review_statuses_json) if review_statuses_json else [] + except (json.JSONDecodeError, TypeError): + base_statuses = [] + print(f" Loaded {len(base_statuses)} base review status entries") + + while True: + elapsed = time.time() - start + if elapsed > timeout: + print(f"Timeout ({timeout}s) reached — stopping poll.", file=sys.stderr) + break + + try: + jobs = collect_run_jobs(token, repo, run_id) + except Exception as e: + print(f" API error collecting jobs: {e}", file=sys.stderr) + time.sleep(interval) + continue + + completed, pending, job_urls = classify_jobs(jobs) + total = len(completed) + len(pending) + print(f" [{elapsed:.0f}s] {len(completed)} completed, {len(pending)} pending " + f"({total} total jobs)") + + # Try to fetch ci-timings artifact statuses (may not exist yet). + artifact_statuses = _fetch_artifact_statuses( + token, repo, run_id, "ci-timings-report", + ) + if artifact_statuses: + print(f" Found ci-timings artifact with {len(artifact_statuses)} status entries") + + merged_json = _merge_statuses(base_statuses, artifact_statuses) + + body = build_comment_body( + asm, completed, pending, run_url, job_urls, + merged_json, + commit_info, + ) + + if body != last_body: + if dry_run: + print("--- DRY RUN — comment body ---") + print(body) + print("--- END ---") + else: + cid = upsert_comment(token, repo, pr_number, body) + if cid: + print(f" Updated comment {cid}") + else: + print(" Failed to update comment (will retry)", file=sys.stderr) + last_body = body + else: + print(" No change since last poll.") + + if not pending: + # Check if any dependency failed. If so, exit non-zero so the + # run shows as failed — this lets ``gh run rerun --failed`` + # (e.g. from label-rerun.yml) pick up and rerun the failed jobs. + failed_deps = [name for name, result in completed.items() if result == "failure"] + if failed_deps: + print(f" All jobs done, but {len(failed_deps)} failed: {', '.join(failed_deps)}") + print(" Exiting with error so the run can be rerun via --failed.") + return 1 + print(" All jobs completed — done.") + break + + time.sleep(interval) + + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--interval", type=int, default=15, + help="Seconds between polls (default: 15).") + parser.add_argument("--timeout", type=int, default=1800, + help="Max seconds to poll before giving up (default: 1800).") + parser.add_argument("--review-statuses-file", type=Path, default=None, + help="Path to a JSON file with merged review statuses from workflow_call jobs.") + parser.add_argument("--dry-run", action="store_true", + help="Print comment body instead of posting to PR.") + args = parser.parse_args() + + token = os.environ.get("GITHUB_TOKEN", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + pr_number = os.environ.get("PR_NUMBER", "") + run_url = os.environ.get("RUN_URL", "") + + if not args.dry_run: + if not token: + print("GITHUB_TOKEN is required", file=sys.stderr) + return 1 + if not repo: + print("GITHUB_REPOSITORY is required", file=sys.stderr) + return 1 + if not run_id: + print("GITHUB_RUN_ID is required", file=sys.stderr) + return 1 + if not pr_number: + print("PR_NUMBER is required", file=sys.stderr) + return 1 + + # Read merged review statuses from file (prepared by the ci.yml step). + review_statuses_json = "" + if args.review_statuses_file: + try: + review_statuses_json = args.review_statuses_file.read_text(encoding="utf-8") + except OSError as e: + print(f"Warning: could not read review statuses file: {e}", file=sys.stderr) + + # Build commit info line from env vars (set by ci.yml). + commit_sha = os.environ.get("COMMIT_SHA", "") + commit_msg = os.environ.get("COMMIT_MESSAGE", "") + commit_url = os.environ.get("COMMIT_URL", "") + commit_info = "" + if commit_sha: + short_sha = commit_sha[:7] + if commit_msg: + # Truncate commit message to first line, max 60 chars. + first_line = commit_msg.split("\n")[0][:60] + if commit_url: + commit_info = f"running on [{short_sha}]({commit_url}) — {first_line}" + else: + commit_info = f"running on {short_sha} — {first_line}" + elif commit_url: + commit_info = f"running on [{short_sha}]({commit_url})" + else: + commit_info = f"running on {short_sha}" + + return run( + token=token, + repo=repo, + run_id=run_id, + pr_number=pr_number, + run_url=run_url, + review_statuses_json=review_statuses_json, + commit_info=commit_info, + interval=args.interval, + timeout=args.timeout, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/lockfile_diff.py b/scripts/ci/lockfile_diff.py index 0a0e03c691ed..46712ad3760e 100644 --- a/scripts/ci/lockfile_diff.py +++ b/scripts/ci/lockfile_diff.py @@ -15,11 +15,11 @@ Usage (from a checkout that still has the base ref available): --output diff.md [--repo-root .] Reads every ``package-lock.json`` tracked at either ref (top-level and -nested — the repo has several), diffs each, and writes a Markdown report -to ``--output``. Exits 0 always; an empty report file means "no version -changes" (the caller uses that to decide whether to post/update the PR -comment). The report embeds ``COMMENT_MARKER`` so the workflow can find -and update its own previous comment instead of stacking new ones. +nested — the repo has several), diffs each, and writes a Markdown fragment +to ``--output``. Exits 0 always; an empty output file means "no version +changes" (the caller uses that to decide whether to include the section). +The fragment is consumed by ``scripts/ci/assemble_review_comment.py``, +which wraps it in a section with a header and action note. """ from __future__ import annotations @@ -29,9 +29,6 @@ import json import subprocess import sys -# Hidden marker used to locate the bot's previous comment for in-place update. -COMMENT_MARKER = "" - def parse_lockfile(text: str) -> dict[str, str]: """Reduce lockfile JSON to ``{install path: version}``. @@ -79,21 +76,24 @@ def _display_name(path: str) -> str: def render_markdown(diffs: dict[str, dict[str, list]]) -> str: - """Render per-lockfile diffs as a Markdown PR comment body. + """Render per-lockfile diffs as a Markdown fragment. ``diffs`` maps lockfile repo-path → the output of :func:`diff_locks`. Lockfiles with no version changes are omitted. Returns ``""`` when - nothing changed anywhere (caller skips commenting entirely). + nothing changed anywhere (caller skips the section entirely). + + The output is a fragment — per-lockfile ``####`` subsections with + tables — not a standalone comment. The ``assemble_review_comment`` + script wraps this in a section with its own header and action note, + so no top-level header or comment marker is emitted here. """ sections = [] - total = 0 for lockfile, d in sorted(diffs.items()): added, removed, updated = d["added"], d["removed"], d["updated"] n = len(added) + len(removed) + len(updated) if n == 0: continue - total += n - lines = [f"### `{lockfile}`", ""] + lines = [f"#### `{lockfile}`", ""] lines.append("| Package | Before | After |") lines.append("| --- | --- | --- |") for path, old, new in updated: @@ -107,13 +107,7 @@ def render_markdown(diffs: dict[str, dict[str, list]]) -> str: if not sections: return "" - header = ( - f"{COMMENT_MARKER}\n" - f"## ⚠️ `package-lock.json` changes ({total} package" - f"{'s' if total != 1 else ''})\n\n" - "This PR changes locked npm dependency versions." - ) - return header + "\n" + "\n\n".join(sections) + "\n" + return "\n\n".join(sections) + "\n" def _git_show(ref: str, path: str, repo_root: str) -> str | None: diff --git a/scripts/ci/timings_report.py b/scripts/ci/timings_report.py index 0e19e539c63a..f28bd9d703f8 100644 --- a/scripts/ci/timings_report.py +++ b/scripts/ci/timings_report.py @@ -895,6 +895,86 @@ def generate_summary(timings: dict, baseline: dict | None = None) -> str: return "\n".join(lines) +# --------------------------------------------------------------------------- +# Review status JSON for the unified PR comment +# --------------------------------------------------------------------------- + +# Wall-time regressions above this fraction of baseline are "warning" severity. +_TIMINGS_WARN_PCT = 0.25 + + +def generate_review_status( + timings: dict, baseline: dict | None, report_url: str | None = None +) -> list[dict]: + """Produce a review_status JSON array for the CI timings review section. + + Returns a list with one ``{source, results: [...]}`` entry. The + result kind is ``"info"`` or ``"warning"`` (timings is never error — + it's an observability job). *summary* is a single short line suitable + for the PR comment. *detail* has the per-job deltas as a markdown + fragment. + """ + stats = compute_stats(timings, baseline) + + if baseline is None: + severity = "info" + summary = f"Wall time {fmt_dur(stats['wall'])} (no baseline yet)." + else: + wall = stats["wall"] + bl_wall = stats["bl_wall"] or 0 + if bl_wall > 0: + pct = (wall - bl_wall) / bl_wall * 100 + wall_str = f"Wall time {fmt_dur(wall)} vs {fmt_dur(bl_wall)} ({pct:+.1f}%)." + if pct > _TIMINGS_WARN_PCT * 100: + severity = "warning" + else: + severity = "info" + else: + wall_str = f"Wall time {fmt_dur(wall)}." + severity = "info" + + if stats["slower"]: + wall_str += f" {stats['slower']} job(s) slower," + if stats["faster"]: + wall_str += f" {stats['faster']} faster," + if stats["unchanged"]: + wall_str += f" {stats['unchanged']} unchanged." + summary = wall_str + + # Per-job delta detail (top 5 by absolute change) + detail_lines: list[str] = [] + if baseline: + bl_map = {j["name"]: j for j in baseline.get("jobs", [])} + deltas: list[tuple[float, str, str]] = [] + for j in timings.get("jobs", []): + if is_skipped(j): + continue + bl = bl_map.get(j["name"]) + if not bl or is_skipped(bl): + continue + cur = j.get("duration_s") or 0 + bl_d = bl.get("duration_s") or 0 + diff = cur - bl_d + if abs(diff) < 1.0: + continue + deltas.append((abs(diff), j["name"], f"{diff:+.1f}s")) + deltas.sort(reverse=True) + for _, name, delta_str in deltas[:5]: + detail_lines.append(f"- {name}: {delta_str}") + + result: dict = { + "kind": severity, + "title": "CI timings", + "summary": summary, + "detail": "\n".join(detail_lines), + } + if report_url: + result["link"] = report_url + result["link_label"] = "View report" + + return [{"source": "ci timing", "results": [result]}] + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -916,6 +996,8 @@ def main(): 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)") + parser.add_argument("--review-status-out", default="", + help="If set, write a review-status JSON for the unified PR comment.") args = parser.parse_args() # Collect or load timings @@ -974,6 +1056,17 @@ def main(): f.write(summary) print(f"Wrote summary to {args.summary_out}") + # Write review status for the unified PR comment. + # The output goes to GITHUB_OUTPUT (or a file with the same key=value + # format) so the ci-timings job can expose it as a workflow_call output. + if args.review_status_out: + report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "") + statuses = generate_review_status(timings, baseline, report_url) + json_str = json.dumps(statuses) + with open(args.review_status_out, "a", encoding="utf-8") as f: + f.write(f"review_status={json_str}\n") + print(f"Wrote review status to {args.review_status_out}") + if __name__ == "__main__": main() diff --git a/scripts/contributor_audit.py b/scripts/contributor_audit.py index 9c371e4ae162..460bb1ca8970 100644 --- a/scripts/contributor_audit.py +++ b/scripts/contributor_audit.py @@ -49,6 +49,7 @@ IGNORED_PATTERNS = [ re.compile(r"^dependabot", re.IGNORECASE), re.compile(r"^renovate", re.IGNORECASE), re.compile(r"^Hermes\s+(Agent|Audit)$", re.IGNORECASE), + re.compile(r"^nousbot(-eng)?$", re.IGNORECASE), re.compile(r"^Ubuntu$", re.IGNORECASE), ] @@ -59,6 +60,7 @@ IGNORED_EMAILS = { "cursoragent@cursor.com", "hermes@nousresearch.com", "hermes-audit@example.com", + "nousbot@nousresearch.com", "hermes@habibilabs.dev", "omx@oh-my-codex.dev", "codex@openai.com", diff --git a/tests/agent/lsp/_mock_lsp_server.py b/tests/agent/lsp/_mock_lsp_server.py index 619b8da233f1..316e914569aa 100644 --- a/tests/agent/lsp/_mock_lsp_server.py +++ b/tests/agent/lsp/_mock_lsp_server.py @@ -17,6 +17,14 @@ Behaviour (all behaviours selectable via env var ``MOCK_LSP_SCRIPT``): (simulates a crashing server). - ``"slow"`` — same as ``clean`` but sleeps 1s before responding to ``initialize`` (lets us test timeout behaviour). +- ``"stale"`` — pushes one error on ``didOpen``, then goes SILENT on + ``didChange`` (no push) and rejects the pull endpoint with + method-not-found. Models a slow tsserver that hasn't re-checked + the edited content yet — the ghost-diagnostics scenario. +- ``"slow_push"`` — like ``stale`` on didOpen (one error) but on + ``didChange`` sleeps ``MOCK_LSP_PUSH_DELAY`` seconds (default 1.0) + and then pushes EMPTY diagnostics. Models a server that fixes + the ghost if you actually wait for it. Pull endpoint rejects. The script writes JSON-RPC framed messages to stdout and reads from stdin. No third-party dependencies — uses only stdlib so it runs @@ -96,20 +104,47 @@ def main(): td = params.get("textDocument") or {} uri = td.get("uri", "") version = td.get("version", 0) + is_change = msg.get("method") == "textDocument/didChange" + error_diag = [ + { + "range": { + "start": {"line": 0, "character": 0}, + "end": {"line": 0, "character": 5}, + }, + "severity": 1, + "code": "MOCK001", + "source": "mock-lsp", + "message": "synthetic error from mock-lsp", + } + ] + if script == "stale": + # Ghost scenario: publish an error for the ORIGINAL + # content, then never publish again after edits. + if not is_change: + write_message( + { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "version": version, "diagnostics": error_diag}, + } + ) + continue + if script == "slow_push": + diagnostics = error_diag + if is_change: + time.sleep(float(os.environ.get("MOCK_LSP_PUSH_DELAY", "1.0"))) + diagnostics = [] + write_message( + { + "jsonrpc": "2.0", + "method": "textDocument/publishDiagnostics", + "params": {"uri": uri, "version": version, "diagnostics": diagnostics}, + } + ) + continue diagnostics = [] if script == "errors": - diagnostics = [ - { - "range": { - "start": {"line": 0, "character": 0}, - "end": {"line": 0, "character": 5}, - }, - "severity": 1, - "code": "MOCK001", - "source": "mock-lsp", - "message": "synthetic error from mock-lsp", - } - ] + diagnostics = error_diag write_message( { "jsonrpc": "2.0", @@ -124,6 +159,17 @@ def main(): continue if msg.get("method") == "textDocument/diagnostic": + if script in {"stale", "slow_push"}: + # These scripts model push-only servers so the ghost + # can't be papered over by the pull channel. + write_message( + { + "jsonrpc": "2.0", + "id": msg["id"], + "error": {"code": -32601, "message": "method not found"}, + } + ) + continue # Pull endpoint — return empty. write_message( { diff --git a/tests/agent/lsp/test_stale_diagnostics.py b/tests/agent/lsp/test_stale_diagnostics.py new file mode 100644 index 000000000000..1f0aa13cc37f --- /dev/null +++ b/tests/agent/lsp/test_stale_diagnostics.py @@ -0,0 +1,251 @@ +"""Regression tests for the "ghost diagnostics" staleness bug. + +Scenario: the agent edits a TypeScript file, tsserver takes a long +time to re-check it, and the old diagnostics (for the PRE-edit +content) were reported as if they were current — the agent then +chases errors it already fixed. + +The contract under test: + +- ``wait_for_diagnostics`` must NOT be satisfied by diagnostics left + over from a previous edit cycle; it returns True only when fresh + (post-didChange) data arrived, False on timeout. +- ``diagnostics_for(fresh_only=True)`` must exclude stale stores. +- ``LSPService.get_diagnostics_sync`` must return [] ("no data") + rather than the stale diagnostics when the server never re-checks + within the wait budget, and must NOT mark the server broken. +- A slow-but-eventually-correct server ("slow_push") is waited on, + honouring the configured ``lsp.wait_timeout``. +""" +from __future__ import annotations + +import os +import sys +from pathlib import Path + +import pytest + +from agent.lsp.client import LSPClient + + +MOCK_SERVER = str(Path(__file__).parent / "_mock_lsp_server.py") + + +def _client(workspace: Path, script: str, **env_extra: str) -> LSPClient: + env = { + "MOCK_LSP_SCRIPT": script, + "PYTHONPATH": os.environ.get("PYTHONPATH", ""), + **env_extra, + } + return LSPClient( + server_id=f"mock-{script}", + workspace_root=str(workspace), + command=[sys.executable, MOCK_SERVER], + env=env, + cwd=str(workspace), + ) + + +@pytest.mark.asyncio +async def test_stale_push_does_not_satisfy_wait(tmp_path: Path): + """A push from the previous edit cycle must not end the wait early. + + The 'stale' mock publishes an error for the original content and + then goes silent — the wait after the edit must time out (False), + not return instantly on the leftover push. + """ + f = tmp_path / "x.py" + f.write_text("bad code\n") + + client = _client(tmp_path, "stale") + await client.start() + try: + v0 = await client.open_file(str(f), language_id="python") + assert await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0) + assert len(client.diagnostics_for(str(f))) == 1 # pre-edit error is real + + # Fix the file. The stale server never re-checks. + f.write_text("good code\n") + v1 = await client.open_file(str(f), language_id="python") + fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=1.0) + assert fresh is False, "wait must not be satisfied by pre-edit leftovers" + finally: + await client.shutdown() + + +@pytest.mark.asyncio +async def test_fresh_only_excludes_stale_stores(tmp_path: Path): + f = tmp_path / "x.py" + f.write_text("bad code\n") + + client = _client(tmp_path, "stale") + await client.start() + try: + v0 = await client.open_file(str(f), language_id="python") + await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0) + + f.write_text("good code\n") + await client.open_file(str(f), language_id="python") + # Merged legacy view still exposes the leftover push... + assert len(client.diagnostics_for(str(f))) == 1 + # ...but the fresh-only view correctly reports no verdict yet. + assert client.diagnostics_for(str(f), fresh_only=True) == [] + finally: + await client.shutdown() + + +@pytest.mark.asyncio +async def test_slow_push_is_waited_for(tmp_path: Path): + """A server that re-checks slowly (but within budget) gets waited on, + and the fresh (clean) result replaces the old error.""" + f = tmp_path / "x.py" + f.write_text("bad code\n") + + client = _client(tmp_path, "slow_push", MOCK_LSP_PUSH_DELAY="0.8") + await client.start() + try: + v0 = await client.open_file(str(f), language_id="python") + assert await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0) + assert len(client.diagnostics_for(str(f), fresh_only=True)) == 1 + + f.write_text("good code\n") + v1 = await client.open_file(str(f), language_id="python") + fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=5.0) + assert fresh is True, "slow push within budget must satisfy the wait" + assert client.diagnostics_for(str(f), fresh_only=True) == [] + finally: + await client.shutdown() + + +@pytest.mark.asyncio +async def test_wait_timeout_param_overrides_mode_budget(tmp_path: Path): + """The explicit timeout must control the wait budget (config plumb).""" + import asyncio + + f = tmp_path / "x.py" + f.write_text("bad code\n") + + client = _client(tmp_path, "stale") + await client.start() + try: + v0 = await client.open_file(str(f), language_id="python") + await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0) + f.write_text("good code\n") + v1 = await client.open_file(str(f), language_id="python") + + loop = asyncio.get_event_loop() + start = loop.time() + fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=0.5) + elapsed = loop.time() - start + assert fresh is False + # Must respect ~0.5s, not the 5s document default. + assert elapsed < 3.0 + finally: + await client.shutdown() + + +@pytest.mark.asyncio +async def test_stale_pull_result_dropped_when_change_races(tmp_path: Path): + """A pull answered for pre-edit content must not read as fresh after + a didChange raced past it (version-tag anchoring).""" + f = tmp_path / "x.py" + f.write_text("bad code\n") + + client = _client(tmp_path, "clean") + await client.start() + try: + v0 = await client.open_file(str(f), language_id="python") + await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0) + doc = client._docs[os.path.abspath(str(f))] + assert doc.fresh_pull() + + # Simulate an edit racing in: the version bump invalidates the + # stored pull without any explicit clearing. + f.write_text("good code\n") + await client.open_file(str(f), language_id="python") + assert not doc.fresh_pull() + assert client.diagnostics_for(str(f), fresh_only=True) == [] + finally: + await client.shutdown() + + +# --------------------------------------------------------------------------- +# Service-level: stale data must surface as "no data", never as errors +# --------------------------------------------------------------------------- + + +def _install_mock_server(script: str, server_id: str = "pyright"): + """Replace one registered server with a wrapper spawning the mock. + + Mirrors the helper in test_service.py — reuse pyright so .py files + route to the mock without a real toolchain. + """ + from agent.lsp.servers import SERVERS, ServerContext, ServerDef, SpawnSpec + + target_index = next(i for i, s in enumerate(SERVERS) if s.server_id == server_id) + original = SERVERS[target_index] + + def _spawn(root: str, ctx: ServerContext) -> SpawnSpec: + return SpawnSpec( + command=[sys.executable, MOCK_SERVER], + workspace_root=root, + cwd=root, + env={"MOCK_LSP_SCRIPT": script}, + initialization_options={}, + ) + + SERVERS[target_index] = ServerDef( + server_id=server_id, + extensions=original.extensions, + resolve_root=lambda fp, ws: ws, + build_spawn=_spawn, + seed_first_push=False, + description="mock " + server_id, + ) + return target_index, original + + +@pytest.fixture +def stale_repo(monkeypatch, tmp_path): + repo = tmp_path / "repo" + repo.mkdir() + (repo / ".git").mkdir() + (repo / "pyproject.toml").write_text("") + monkeypatch.chdir(str(repo)) + idx, original = _install_mock_server("stale") + yield repo + from agent.lsp.servers import SERVERS + + SERVERS[idx] = original + + +def test_service_reports_no_data_not_stale_errors(stale_repo): + """When the server never re-checks the edited content in budget, + get_diagnostics_sync must return [] and keep the server usable.""" + from agent.lsp.manager import LSPService + + f = stale_repo / "x.py" + f.write_text("bad code\n") + + svc = LSPService( + enabled=True, + wait_mode="document", + wait_timeout=1.0, + install_strategy="manual", + ) + try: + # First contact: didOpen gets the (real) pre-edit error push. + first = svc.get_diagnostics_sync(str(f), delta=False) + assert len(first) == 1 + + # Edit the file — mock never re-publishes (slow tsserver model). + f.write_text("good code\n") + ghost = svc.get_diagnostics_sync(str(f), delta=False) + assert ghost == [], "stale pre-edit error must not be reported as current" + + # Not marked broken: slow is not dead. + assert svc.enabled_for(str(f)) + status = svc.get_status() + assert status["broken"] == [] + finally: + svc.shutdown() diff --git a/tests/agent/test_billing_view.py b/tests/agent/test_billing_view.py index 596570f0f574..89824fa32610 100644 --- a/tests/agent/test_billing_view.py +++ b/tests/agent/test_billing_view.py @@ -1,4 +1,4 @@ -"""Unit tests for the Phase 2b terminal-billing core + HTTP client. +"""Unit tests for the Phase 2b Remote Spending core + HTTP client. Covers: - Decimal money parsing/formatting (server emits decimal strings, not 2dp). diff --git a/tests/agent/test_moonshot_schema.py b/tests/agent/test_moonshot_schema.py index 339e6ab529ee..6dc7944292bc 100644 --- a/tests/agent/test_moonshot_schema.py +++ b/tests/agent/test_moonshot_schema.py @@ -188,9 +188,10 @@ class TestTopLevelGuarantees: """The returned top-level schema is always a well-formed object.""" def test_non_dict_input_returns_empty_object(self): - assert sanitize_moonshot_tool_parameters(None) == {"type": "object", "properties": {}} - assert sanitize_moonshot_tool_parameters("garbage") == {"type": "object", "properties": {}} - assert sanitize_moonshot_tool_parameters([]) == {"type": "object", "properties": {}} + empty = {"type": "object", "properties": {}, "required": []} + assert sanitize_moonshot_tool_parameters(None) == empty + assert sanitize_moonshot_tool_parameters("garbage") == empty + assert sanitize_moonshot_tool_parameters([]) == empty def test_non_object_top_level_coerced(self): params = {"type": "string"} @@ -212,6 +213,66 @@ class TestTopLevelGuarantees: assert "type" not in params["properties"]["q"] +class TestRequiredArray: + """Rule 4: every object schema must carry a ``required`` array (#66835).""" + + def test_empty_object_gets_empty_required(self): + out = sanitize_moonshot_tool_parameters({"type": "object", "properties": {}}) + assert out["required"] == [] + + def test_object_with_only_optional_props_gets_empty_required(self): + params = { + "type": "object", + "properties": {"q": {"type": "string"}}, + } + out = sanitize_moonshot_tool_parameters(params) + assert out["required"] == [] + + def test_existing_required_preserved(self): + params = { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q"], + } + out = sanitize_moonshot_tool_parameters(params) + assert out["required"] == ["q"] + + def test_dangling_required_pruned(self): + params = { + "type": "object", + "properties": {"q": {"type": "string"}}, + "required": ["q", "ghost"], + } + out = sanitize_moonshot_tool_parameters(params) + assert out["required"] == ["q"] + + def test_non_list_required_replaced(self): + params = { + "type": "object", + "properties": {}, + "required": "q", # invalid: string, not array + } + out = sanitize_moonshot_tool_parameters(params) + assert out["required"] == [] + + def test_nested_object_property_gets_required(self): + params = { + "type": "object", + "properties": { + "filter": {"type": "object", "properties": {}}, + }, + } + out = sanitize_moonshot_tool_parameters(params) + assert out["properties"]["filter"]["required"] == [] + assert out["required"] == [] + + def test_coerced_top_level_gets_required(self): + # A non-object top level is forced to object and must gain required. + out = sanitize_moonshot_tool_parameters({"type": "string"}) + assert out["type"] == "object" + assert out["required"] == [] + + class TestToolListSanitizer: """sanitize_moonshot_tools() walks an OpenAI-format tool list.""" @@ -239,8 +300,10 @@ class TestToolListSanitizer: ] out = sanitize_moonshot_tools(tools) assert out[0]["function"]["parameters"]["properties"]["q"]["type"] == "string" - # Second tool already clean — should be structurally equivalent - assert out[1]["function"]["parameters"] == {"type": "object", "properties": {}} + # Second tool: empty object gains the required-array Moonshot demands + assert out[1]["function"]["parameters"] == { + "type": "object", "properties": {}, "required": [] + } def test_empty_list_is_passthrough(self): assert sanitize_moonshot_tools([]) == [] diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index a0acdea0433b..633fde7c02d9 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -1169,6 +1169,11 @@ class TestPromptBuilderConstants: assert "Matrix" in hint assert "MEDIA:" in hint assert "Markdown" in hint + # Regression (#52552): the hint must steer models away from Markdown + # tables — popular Matrix clients don't render HTML tables and the + # cells collapse into one continuous line. + assert "table" in hint.lower() + assert "Do NOT use Markdown tables" in hint def test_platform_hints_feishu(self): hint = PLATFORM_HINTS["feishu"] diff --git a/tests/agent/test_rotation_flush_persisted_boundary_68196.py b/tests/agent/test_rotation_flush_persisted_boundary_68196.py new file mode 100644 index 000000000000..c082be359149 --- /dev/null +++ b/tests/agent/test_rotation_flush_persisted_boundary_68196.py @@ -0,0 +1,118 @@ +"""Regression (#68196): rotating preflight compression must not re-append the +already-persisted transcript to the parent session on cold resume. + +On the first turn after a cold Desktop resume, the stored rows are handed to +``run_conversation()`` as ``conversation_history`` and live in the message list +as plain dicts that have NOT yet been stamped with ``_DB_PERSISTED_MARKER`` — +the normal turn flush (which stamps them) runs *after* preflight compression. + +The legacy rotation branch in ``agent/conversation_compression.py`` flushes the +current turn to the OLD session before ending it (#47202). It used to call +``agent._flush_messages_to_session_db(messages)`` with no history boundary, so +``_flush_messages_to_session_db`` saw an empty ``history_ids`` set and treated +every restored row as new — durably appending the whole transcript to the +parent a second time. The fix passes ``messages[:_persist_user_message_idx]`` +(the already-durable prefix ``turn_context`` anchors before preflight runs) as +``conversation_history`` so only the current turn's new messages are written. + +Without the fix the parent grows to 5 rows (the two originals + a duplicate of +both + the new turn). With it the parent holds exactly the two originals plus +the single new turn. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +from hermes_state import SessionDB + + +def _build_agent_with_db(db: SessionDB, session_id: str): + """Build an AIAgent wired to ``db`` and pinned to ``session_id``. + + Mirrors the helper in ``test_compression_concurrent_fork.py``: stub the + compressor so it returns deterministic output without an LLM call, and pin + ``compression_in_place=False`` so the legacy rotation path is exercised + regardless of the global default. + """ + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}): + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + session_db=db, + session_id=session_id, + skip_context_files=True, + skip_memory=True, + ) + + compressor = MagicMock() + + def _compress(*_a, **_kw): + time.sleep(0.01) + return [ + {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, + {"role": "user", "content": "tail"}, + ] + + compressor.compress.side_effect = _compress + compressor.compression_count = 1 + compressor.last_prompt_tokens = 0 + compressor.last_completion_tokens = 0 + compressor._last_summary_error = None + compressor._last_compress_aborted = False + compressor._last_aux_model_failure_model = None + compressor._last_aux_model_failure_error = None + agent.context_compressor = compressor + agent.compression_in_place = False + return agent + + +def _contents(rows): + return [r.get("content") for r in rows] + + +def test_rotation_flush_does_not_duplicate_persisted_prefix(tmp_path: Path) -> None: + """Cold-resume + rotating preflight compression keeps the parent transcript + at (persisted prefix + one new turn) — no second copy of the durable rows.""" + db = SessionDB(db_path=tmp_path / "state.db") + + parent_sid = "COLD_RESUME_PARENT" + db.create_session(parent_sid, source="desktop") + + # Two durable rows already in the parent. + db.append_message(parent_sid, "user", "persisted question") + db.append_message(parent_sid, "assistant", "persisted answer") + + # Cold resume: the stored rows come back as plain dicts, unstamped, and the + # live turn appends one new user message on top. + loaded = db.get_messages_as_conversation(parent_sid) + assert _contents(loaded) == ["persisted question", "persisted answer"] + messages = [*loaded, {"role": "user", "content": "new turn"}] + + agent = _build_agent_with_db(db, parent_sid) + # turn_context anchors this at the current-turn user message before preflight + # compression runs; emulate that anchor. + agent._persist_user_message_idx = len(messages) - 1 + + agent._compress_context(messages, "sys", approx_tokens=120_000) + + # The flush at the rotation boundary lands on the OLD (parent) session, + # which is then ended. Read it back verbatim (include_inactive to be robust + # to the end_session bookkeeping). + parent_rows = db.get_messages_as_conversation(parent_sid, include_inactive=True) + contents = _contents(parent_rows) + + assert contents.count("persisted question") == 1, ( + "Rotation flush re-appended the already-persisted prefix to the parent " + f"(#68196). Parent transcript is {contents!r}; expected the two durable " + "rows plus only the new turn." + ) + assert contents.count("persisted answer") == 1 + assert contents == ["persisted question", "persisted answer", "new turn"] diff --git a/tests/agent/test_tool_executor_checkpoint_paths.py b/tests/agent/test_tool_executor_checkpoint_paths.py new file mode 100644 index 000000000000..46c28fc37451 --- /dev/null +++ b/tests/agent/test_tool_executor_checkpoint_paths.py @@ -0,0 +1,40 @@ +"""Behavioral coverage for file-tool checkpoint path resolution.""" + +from types import SimpleNamespace + +from agent.tool_executor import _ensure_file_checkpoint +from tools.checkpoint_manager import CheckpointManager + + +def test_relative_file_checkpoint_uses_task_workspace(tmp_path, monkeypatch): + """Checkpoint lookup must use the same cwd as a relative file mutation.""" + process_cwd = tmp_path / "opt" / "hermes" + workspace_cwd = tmp_path / "opt" / "data" / "workspace" + process_cwd.mkdir(parents=True) + workspace_cwd.mkdir(parents=True) + + # Both directories contain content so checkpointing the wrong one would + # still succeed and remain observable as the regression did in Docker. + (process_cwd / "pyproject.toml").write_text("[project]\nname = 'hermes'\n") + (workspace_cwd / "pyproject.toml").write_text("[project]\nname = 'workspace'\n") + (workspace_cwd / "existing.txt").write_text("before\n") + + monkeypatch.chdir(process_cwd) + monkeypatch.setenv("TERMINAL_CWD", str(workspace_cwd)) + monkeypatch.setattr( + "tools.checkpoint_manager.CHECKPOINT_BASE", + tmp_path / "checkpoints", + ) + + manager = CheckpointManager(enabled=True) + agent = SimpleNamespace(_checkpoint_mgr=manager) + + _ensure_file_checkpoint( + agent, + "write_file", + {"path": "test_permissions2.txt"}, + "gateway-session", + ) + + assert manager.list_checkpoints(str(workspace_cwd)) + assert manager.list_checkpoints(str(process_cwd)) == [] diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index cf245a2f3266..fd549e114114 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -747,6 +747,28 @@ class TestChatCompletionsKimi: ) assert kw["tools"][0]["function"]["parameters"]["properties"]["q"]["type"] == "string" + def test_moonshot_outgoing_schema_carries_required_array(self, transport): + """Moonshot 400s on object schemas without an explicit `required` array + (#66835). Assert the wire-level tool schema — what actually leaves the + transport — carries `required: []` on a zero-required-param tool.""" + tools = [ + { + "type": "function", + "function": { + "name": "browser_snapshot", + "description": "Snapshot", + "parameters": {"type": "object", "properties": {}}, + }, + }, + ] + kw = transport.build_kwargs( + model="moonshotai/kimi-k3", + messages=[{"role": "user", "content": "Hi"}], + tools=tools, + max_tokens_param_fn=lambda n: {"max_tokens": n}, + ) + assert kw["tools"][0]["function"]["parameters"]["required"] == [] + def test_non_moonshot_tools_are_not_mutated(self, transport): """Other models don't go through the Moonshot sanitizer.""" original_params = { diff --git a/tests/ci/test_assemble_review_comment.py b/tests/ci/test_assemble_review_comment.py new file mode 100644 index 000000000000..373fb6a3a47f --- /dev/null +++ b/tests/ci/test_assemble_review_comment.py @@ -0,0 +1,643 @@ +"""Tests for scripts/ci/assemble_review_comment.py. + +The assembler collects status from every CI sub-workflow into ReviewItems +classified by severity (error / action_required / warning / info), then +renders them into a single PR comment body. + +Status data comes from two sources: + 1. --review-statuses-json: JSON array of {source, results: [...]} objects + from workflow_call jobs. Each result has kind/title/summary/detail/ + how_to_fix/link. The assembler flattens all results into ReviewItems. + 2. --needs-json: {job_name: result} from all-checks-pass. Failed jobs not + claimed by any status become synthesized ❌ Error items. + +Layout rules tested here: + - group headers: ## ❌ Job failures, ## ⚠️ Action required, ## ⚠️ Warnings + - each item is a ### section under its group header + - errors + action_required always visible + - warnings shown only when present + - info in a collapsible
block + - sections separated by --- + - how_to_fix rendered at bottom of action_required items + - empty → clean banner + - jobs with declared statuses excluded from failed-jobs list + - per-job URLs used for failed job links when available +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "assemble_review_comment.py" +_spec = importlib.util.spec_from_file_location("assemble_review_comment", _PATH) +if _spec is None or _spec.loader is None: + raise ImportError("Failed to load assemble_review_comment.py") +_mod = importlib.util.module_from_spec(_spec) +sys.modules["assemble_review_comment"] = _mod +_spec.loader.exec_module(_mod) + +MARKER = _mod.MARKER +ReviewItem = _mod.ReviewItem + + +def _status(source: str, results: list[dict]) -> str: + """Helper: build a review_statuses JSON string with one source entry.""" + return json.dumps([{"source": source, "results": results}]) + + +# ─── collect_from_statuses ────────────────────────────────────────── + + +def test_statuses_empty_json(): + items, sources = _mod.collect_from_statuses("") + assert items == [] + assert sources == set() + + +def test_statuses_bad_json(): + items, sources = _mod.collect_from_statuses("not json") + assert items == [] + assert sources == set() + + +def test_statuses_action_required(): + statuses = _status("review-label-gate", [{ + "kind": "action_required", + "title": "CI-sensitive file review", + "summary": "Changes detected.", + "how_to_fix": "Add the label.", + }]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 1 + assert items[0].severity == "action_required" + assert items[0].title == "CI-sensitive file review" + assert items[0].how_to_fix == "Add the label." + assert items[0].source == "review-label-gate" + assert sources == {"review-label-gate"} + + +def test_statuses_info(): + statuses = _status("review-label-gate", [{ + "kind": "info", + "title": "CI-sensitive file review", + "summary": "Label present.", + }]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 1 + assert items[0].severity == "info" + assert sources == {"review-label-gate"} + + +def test_statuses_multiple_results_same_source(): + """One source can emit multiple results of different kinds.""" + statuses = _status("review-label-gate", [ + {"kind": "action_required", "title": "CI review", "summary": "Missing label."}, + {"kind": "action_required", "title": "MCP review", "summary": "Missing label."}, + ]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 2 + assert sources == {"review-label-gate"} + + +def test_statuses_mixed_kinds_same_source(): + """One source can emit both a warning and an info.""" + statuses = _status("ci-timings", [ + {"kind": "warning", "title": "CI timings", "summary": "Slower."}, + {"kind": "info", "title": "Baseline", "summary": "OK."}, + ]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 2 + assert items[0].severity == "warning" + assert items[1].severity == "info" + assert sources == {"ci-timings"} + + +def test_statuses_multiple_sources(): + statuses = json.dumps([ + {"source": "review-label-gate", "results": [ + {"kind": "action_required", "title": "CI review", "summary": "Missing."}, + ]}, + {"source": "lockfile-diff", "results": [ + {"kind": "info", "title": "package-lock.json", "summary": "No changes."}, + ]}, + ]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 2 + assert sources == {"review-label-gate", "lockfile-diff"} + + +def test_statuses_unknown_kind_becomes_info(): + statuses = _status("some-job", [{ + "kind": "bogus", + "title": "X", + "summary": "Y", + }]) + items, _ = _mod.collect_from_statuses(statuses) + assert items[0].severity == "info" + + +def test_statuses_no_source(): + """Status without a source — still rendered, just not excluded from errors.""" + statuses = json.dumps([{ + "results": [{"kind": "info", "title": "X", "summary": "Y"}], + }]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 1 + assert sources == set() + + +def test_statuses_passes_through_optional_fields(): + statuses = _status("ci-timings", [{ + "kind": "warning", + "title": "CI timings", + "summary": "Slower.", + "detail": "- job: +5s", + "link": "https://report", + "link_label": "View report", + "how_to_fix": "Optimize.", + }]) + items, _ = _mod.collect_from_statuses(statuses) + assert items[0].detail == "- job: +5s" + assert items[0].link == "https://report" + assert items[0].link_label == "View report" + assert items[0].how_to_fix == "Optimize." + + +# ─── collect_failed_jobs ───────────────────────────────────────────── + + +def test_failed_jobs_empty_needs(): + assert _mod.collect_failed_jobs("", "https://run") == [] + + +def test_failed_jobs_no_failures(): + needs = json.dumps({"tests": "success", "lint": "skipped"}) + assert _mod.collect_failed_jobs(needs, "https://run") == [] + + +def test_failed_jobs_collects_only_failures(): + needs = json.dumps({"tests": "success", "lint": "failure", "js-tests": "failure"}) + items = _mod.collect_failed_jobs(needs, "https://run/123") + assert len(items) == 2 + assert all(i.severity == "error" for i in items) + # sorted by name + names = [i.title for i in items] + assert names == ["js-tests", "lint"] + assert all(i.job_url == "https://run/123" for i in items) + + +def test_failed_jobs_bad_json(): + assert _mod.collect_failed_jobs("not json", "https://run") == [] + + +def test_failed_jobs_excluded_by_source(): + """Jobs whose name contains a declared source are excluded.""" + needs = json.dumps({ + "Review label gate / Review label gate": "failure", + "tests": "failure", + }) + items = _mod.collect_failed_jobs(needs, "https://run", exclude_sources={"review-label-gate"}) + assert len(items) == 1 + assert items[0].title == "tests" + + +def test_failed_jobs_no_exclusion_without_sources(): + """Without exclude_sources, all failures are shown.""" + needs = json.dumps({"review-label-gate": "failure", "tests": "failure"}) + items = _mod.collect_failed_jobs(needs, "https://run") + assert len(items) == 2 + + +def test_failed_jobs_per_job_url(): + """When job_urls is provided, the link points to the specific job.""" + needs = json.dumps({"tests": "failure", "lint": "failure"}) + job_urls = {"tests": "https://run/1/job/2", "lint": "https://run/1/job/3"} + items = _mod.collect_failed_jobs(needs, "https://fallback", job_urls=job_urls) + assert len(items) == 2 + urls = {i.title: i.job_url for i in items} + assert urls["tests"] == "https://run/1/job/2" + assert urls["lint"] == "https://run/1/job/3" + + +def test_failed_jobs_fallback_to_run_url(): + """Jobs not in job_urls fall back to run_url.""" + needs = json.dumps({"tests": "failure", "lint": "failure"}) + job_urls = {"tests": "https://run/1/job/2"} + items = _mod.collect_failed_jobs(needs, "https://fallback", job_urls=job_urls) + urls = {i.title: i.job_url for i in items} + assert urls["tests"] == "https://run/1/job/2" + assert urls["lint"] == "https://fallback" + + +# ─── render_comment ─────────────────────────────────────────────────── + + +def test_render_empty_shows_clean_banner(): + """Completely clean — dog kaomoji + 'looks good' banner, no sections.""" + body = _mod.render_comment([]) + assert body.startswith(MARKER) + assert "૮ >ﻌ< ა" in body + assert "looks good to me!" in body + assert "##" not in body # no section headers + + +def test_render_info_only_shows_details(): + """Info items only — header + collapsible details, no blocking sections.""" + items = [ + ReviewItem(severity="info", title="lockfile", summary="No changes."), + ReviewItem(severity="info", title="timings", summary="OK."), + ] + body = _mod.render_comment(items) + assert "૮ >ﻌ< ა" in body + assert "
" in body + assert "
" in body + assert "No changes." in body + assert "OK." in body + # No blocking sections + assert "## ❌" not in body + assert "## ⚠️" not in body + + +def test_render_info_only_with_pending_shows_details_plus_footer(): + items = [ReviewItem(severity="info", title="lockfile", summary="No changes.")] + body = _mod.render_comment(items, pending_jobs=["ci-timings"]) + assert "૮ >ﻌ< ა" in body + assert "Still running" in body + assert "
" in body + assert "Still running" in body + assert "`ci-timings`" in body + + +def test_render_group_header_for_errors(): + """Errors appear under a '## ❌ Job failures' group header.""" + items = [ + ReviewItem(severity="error", title="tests", summary="Job **tests** failed.", link="https://run"), + ReviewItem(severity="error", title="lint", summary="Job **lint** failed.", link="https://run"), + ] + body = _mod.render_comment(items) + assert "## ❌ Job failures" in body + assert "### tests" in body + assert "### lint" in body + assert body.index("## ❌ Job failures") < body.index("### tests") + + +def test_render_group_header_for_action_required(): + items = [ + ReviewItem(severity="action_required", title="CI review", summary="Need label."), + ] + body = _mod.render_comment(items) + assert "## ⚠️ Action required" in body + assert "### CI review" in body + + +def test_render_group_header_for_warnings(): + items = [ + ReviewItem(severity="warning", title="CI timings", summary="Slower."), + ] + body = _mod.render_comment(items) + assert "## ⚠️ Warnings" in body + assert "### CI timings" in body + assert "
" not in body + + items2 = [ReviewItem(severity="info", title="x", summary="y")] + body2 = _mod.render_comment(items2) + assert "## ⚠️ Warnings" not in body2 + + +def test_render_no_duplicated_severity_in_item_body(): + """Items don't repeat the severity label — the group header carries it.""" + items = [ReviewItem(severity="error", title="tests", summary="Job failed.", link="https://run")] + body = _mod.render_comment(items) + assert "### tests" in body + assert "Job failed." in body + assert "**❌ Error**" not in body + + +def test_render_how_to_fix_at_bottom(): + items = [ + ReviewItem(severity="action_required", title="CI review", summary="Need label.", + how_to_fix="Add the `ci-reviewed` label."), + ] + body = _mod.render_comment(items) + assert "**How to fix:**" in body + assert "Add the `ci-reviewed` label." in body + assert body.index("Need label.") < body.index("How to fix") + + +def test_render_sections_separated_by_hr(): + items = [ + ReviewItem(severity="error", title="tests", summary="failed."), + ReviewItem(severity="action_required", title="CI review", summary="need label."), + ] + body = _mod.render_comment(items) + assert "\n\n---\n\n" in body + + +def test_render_errors_always_visible(): + items = [ + ReviewItem(severity="error", title="tests", summary="Job **tests** failed.", job_url="https://run"), + ReviewItem(severity="info", title="lockfile", summary="No changes."), + ] + body = _mod.render_comment(items) + assert "## ❌ Job failures" in body + assert "### tests" in body + assert "Job **tests** failed." in body + assert "[View job](https://run)" in body + assert "
" in body + assert "No changes." in body + + +def test_render_info_in_collapsible_details(): + """Each info item is its own
block.""" + items = [ + ReviewItem(severity="info", title="lockfile", summary="No changes."), + ReviewItem(severity="info", title="timings", summary="OK."), + ] + body = _mod.render_comment(items) + assert body.count("
") == 2 + assert body.count("
") == 2 + assert "lockfile" in body + assert "timings" in body + assert "No changes." in body + assert "OK." in body + + +def test_render_order_errors_then_action_then_warn_then_info(): + items = [ + ReviewItem(severity="info", title="i", summary="info"), + ReviewItem(severity="warning", title="w", summary="warn"), + ReviewItem(severity="action_required", title="a", summary="action"), + ReviewItem(severity="error", title="e", summary="error"), + ] + body = _mod.render_comment(items) + error_pos = body.index("## ❌ Job failures") + action_pos = body.index("## ⚠️ Action required") + warn_pos = body.index("## ⚠️ Warnings") + info_pos = body.index("
") + assert error_pos < action_pos < warn_pos < info_pos + + +# ─── render_comment (pending jobs) ──────────────────────────────────── + + +def test_render_pending_only_shows_header_with_clock(): + """Pending jobs only — header has 'still waiting', footer lists jobs, no sections.""" + body = _mod.render_comment([], pending_jobs=["ci-timings"]) + assert body.startswith(MARKER) + assert "૮ >ﻌ< ა" in body + assert "Still running" in body + assert "`ci-timings`" in body + assert "##" not in body + + +def test_render_pending_notif(): + items = [ReviewItem(severity="info", title="lockfile", summary="No changes.")] + body = _mod.render_comment(items, pending_jobs=["ci-timings"]) + assert "૮ >ﻌ< ა" in body + assert "Still running 1 job: `ci-timings`" in body + + +def test_render_pending_multiple_jobs_sorted(): + body = _mod.render_comment([], pending_jobs=["docker", "ci-timings"]) + assert "`ci-timings`" in body + assert "`docker`" in body + assert body.index("`ci-timings`") < body.index("`docker`") + + +def test_render_no_pending_no_footer(): + items = [ReviewItem(severity="info", title="x", summary="y")] + body = _mod.render_comment(items) + assert "Still running" not in body + + +# ─── assemble (integration) ────────────────────────────────────────── + + +def test_assemble_all_skipped_clean_banner(): + body = _mod.assemble() + assert body.startswith(MARKER) + assert "૮ >ﻌ< ა" in body + assert "looks good to me!" in body + assert "##" not in body + + +def test_assemble_failed_job_shown(): + needs = json.dumps({"tests": "failure", "lint": "success"}) + body = _mod.assemble(needs_json=needs, run_url="https://run/1") + assert "## ❌ Job failures" in body + assert "### tests" in body + assert "[View job](https://run/1)" in body + + +def test_assemble_with_review_statuses(): + """Statuses from review-labels render directly + exclude gate from errors.""" + statuses = _status("review-label-gate", [{ + "kind": "action_required", + "title": "CI-sensitive file review", + "summary": "Changes detected.", + "how_to_fix": "Add the label.", + }]) + needs = json.dumps({ + "Review label gate / Review label gate": "failure", + "tests": "success", + }) + body = _mod.assemble( + needs_json=needs, + run_url="https://run", + review_statuses_json=statuses, + ) + assert "## ⚠️ Action required" in body + assert "### CI-sensitive file review" in body + assert "Add the label." in body + assert "## ❌ Job failures" not in body + + +def test_assemble_pending_jobs(): + body = _mod.assemble(pending_jobs=["ci-timings"]) + assert "Still running" in body + assert "`ci-timings`" in body + + +def test_assemble_with_items_and_pending(): + needs = json.dumps({"tests": "failure"}) + body = _mod.assemble(needs_json=needs, run_url="https://run", pending_jobs=["ci-timings"]) + assert "## ❌ Job failures" in body + assert "### tests" in body + assert "Still running" in body + assert "`ci-timings`" in body + + +def test_assemble_with_timings_status(): + """Timings status from the nested format renders as info or warning.""" + statuses = _status("ci-timings", [{ + "kind": "info", + "title": "CI timings", + "summary": "Wall time 3m (no baseline yet).", + "detail": "", + "link": "https://report", + }]) + body = _mod.assemble(review_statuses_json=statuses) + assert "
" in body + assert "### CI timings" in body + assert "Wall time 3m" in body + assert "## ❌" not in body + assert "## ⚠️" not in body + + +def test_assemble_with_lockfile_status(): + """Lockfile no-changes status renders as info in the details block.""" + statuses = _status("lockfile-diff", [{ + "kind": "info", + "title": "package-lock.json", + "summary": "No lockfile changes — locked versions match the target branch.", + }]) + body = _mod.assemble(review_statuses_json=statuses) + assert "
" in body + assert "### package-lock.json" in body + assert "No lockfile changes" in body + + +def test_assemble_with_lockfile_changed_status(): + """Lockfile changed status renders as action_required with ci-reviewed how_to_fix.""" + statuses = _status("lockfile-diff", [{ + "kind": "action_required", + "title": "package-lock.json", + "summary": "Locked npm dependency versions changed.", + "detail": "#### `package-lock.json`\n\n| col | | |", + "how_to_fix": "Add the `ci-reviewed` label after verifying the version changes are expected.", + }]) + body = _mod.assemble(review_statuses_json=statuses) + assert "## ⚠️ Action required" in body + assert "### package-lock.json" in body + assert "Locked npm dependency versions changed." in body + assert "Add the `ci-reviewed` label" in body + assert "
" not in body # action_required is not in the collapsible block + + +# ─── _attach_job_urls ──────────────────────────────────────────────── + + +def test_attach_job_urls_fills_missing_links(): + """Items without a link get one from job_urls via source matching.""" + items = [ + ReviewItem(severity="info", title="Supply chain scan", + summary="No risks.", source="supply chain"), + ReviewItem(severity="warning", title="CI timings", + summary="Slower.", source="ci timings", + link="https://report"), # already has a link + ] + job_urls = { + "Supply Chain Audit / Scan PR for critical supply chain risks": "https://run/1/job/2", + } + _mod._attach_job_urls(items, job_urls, "https://fallback") + # First item gets the per-job URL as job_url (link untouched) + assert items[0].job_url == "https://run/1/job/2" + assert items[0].link == "" # no emitted link + # Second item keeps its existing link, job_url is set separately + assert items[1].link == "https://report" + assert items[1].job_url == "https://fallback" # fell back to run_url + + +def test_attach_job_urls_fallback_to_run_url(): + """Items with a source but no matching job URL fall back to run_url.""" + items = [ + ReviewItem(severity="info", title="X", summary="Y", source="some-job"), + ] + _mod._attach_job_urls(items, {}, "https://fallback") + assert items[0].job_url == "https://fallback" + + +def test_attach_job_urls_no_source_no_link(): + """Items without a source don't get a link.""" + items = [ + ReviewItem(severity="info", title="X", summary="Y"), # no source + ] + _mod._attach_job_urls(items, {"job": "https://run/1"}, "https://fallback") + assert items[0].job_url == "" + + +def test_assemble_attaches_links_to_all_items(): + """Integration: assemble() attaches job URLs to status items, not just errors.""" + statuses = _status("supply chain", [{ + "kind": "info", + "title": "Supply chain scan", + "summary": "No risks.", + }]) + job_urls = { + "Supply Chain Audit / Scan PR for critical supply chain risks": "https://run/1/job/2", + } + body = _mod.assemble( + review_statuses_json=statuses, + job_urls=job_urls, + run_url="https://fallback", + ) + assert "[View job](https://run/1/job/2)" in body + + +def test_render_commit_info_below_header(): + """Commit info is rendered below the header, above the content.""" + body = _mod.render_comment( + [ReviewItem(severity="error", title="tests", summary="failed.")], + commit_info="running on [abc1234](https://commit-url) — fix: thing", + ) + assert "# ૮ >ﻌ< ა ci review" in body + assert "running on [abc1234](https://commit-url)" in body + assert "fix: thing" in body + # Commit info appears before the content + assert body.index("abc1234") < body.index("## ❌") + + +def test_render_no_commit_info_when_empty(): + """No commit_info → no extra line below header.""" + body = _mod.render_comment([]) + assert "running on" not in body + + +def test_assemble_passes_commit_info(): + """assemble() passes commit_info through to render_comment.""" + body = _mod.assemble(commit_info="running on abc1234") + assert "running on abc1234" in body + assert "looks good to me!" in body + + +def test_render_both_emitted_link_and_job_url(): + """An item with both an emitted link and a job_url shows both.""" + item = ReviewItem( + severity="warning", + title="CI timings", + summary="Slower.", + link="https://artifact/report.html", + link_label="View report", + source="ci timings", + job_url="https://github.com/run/1/job/5", + ) + body = _mod.render_comment([item]) + assert "[View report](https://artifact/report.html)" in body + assert "[View job](https://github.com/run/1/job/5)" in body + # Both links on the same line, separated by · + assert " · " in body + + +def test_assemble_both_links_for_ci_timings(): + """Integration: ci-timings has a report URL (link) AND gets a job_url.""" + statuses = _status("ci timings", [{ + "kind": "warning", + "title": "CI timings", + "summary": "Wall time 5m vs 3m (+66%).", + "detail": "- tests: +120s", + "link": "https://artifact/report.html", + "link_label": "View report", + }]) + job_urls = {"CI timings": "https://github.com/run/1/job/5"} + body = _mod.assemble( + review_statuses_json=statuses, + job_urls=job_urls, + run_url="https://fallback", + ) + assert "[View report](https://artifact/report.html)" in body + assert "[View job](https://github.com/run/1/job/5)" in body diff --git a/tests/ci/test_live_comment.py b/tests/ci/test_live_comment.py new file mode 100644 index 000000000000..ffc952921310 --- /dev/null +++ b/tests/ci/test_live_comment.py @@ -0,0 +1,162 @@ +"""Tests for scripts/ci/live_comment.py — classify_jobs(). + +The poller's core logic is a pure function: take raw GitHub API job dicts +and split them into (completed, pending). The API wrapper + polling loop +are tested via E2E in CI, not here. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "live_comment.py" +_spec = importlib.util.spec_from_file_location("live_comment", _PATH) +if _spec is None or _spec.loader is None: + raise ImportError("Failed to load live_comment.py") +_mod = importlib.util.module_from_spec(_spec) +sys.modules["live_comment"] = _mod +_spec.loader.exec_module(_mod) + + +def _job(name: str, status: str, conclusion: str | None = None, workflow: str = "") -> dict: + """Build a raw API job dict.""" + j = {"name": name, "status": status, "conclusion": conclusion} + if workflow: + j["_workflow_name"] = workflow + return j + + +def test_classify_empty(): + completed, pending, job_urls = _mod.classify_jobs([]) + assert completed == {} + assert pending == [] + assert job_urls == {} + + +def test_classify_success(): + jobs = [_job("Python tests", "completed", "success")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "success"} + assert pending == [] + + +def test_classify_failure(): + jobs = [_job("Python tests", "completed", "failure")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "failure"} + assert pending == [] + + +def test_classify_skipped(): + jobs = [_job("Python tests", "completed", "skipped")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "skipped"} + assert pending == [] + + +def test_classify_in_progress(): + jobs = [_job("Python tests", "in_progress", None)] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {} + assert pending == ["Python tests"] + + +def test_classify_queued(): + jobs = [_job("Python tests", "queued", None)] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {} + assert pending == ["Python tests"] + + +def test_classify_waiting(): + jobs = [_job("Python tests", "waiting", None)] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {} + assert pending == ["Python tests"] + + +def test_classify_mixed(): + jobs = [ + _job("Python tests", "completed", "success"), + _job("Python lints", "completed", "failure"), + _job("JS & TS checks", "in_progress", None), + _job("Desktop E2E", "queued", None), + ] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "success", "Python lints": "failure"} + assert set(pending) == {"JS & TS checks", "Desktop E2E"} + + +def test_classify_infra_jobs_excluded(): + """Infra jobs (detect, all-checks-pass, comment-live) are never shown.""" + jobs = [ + _job("detect", "completed", "success"), + _job("Detect affected areas", "completed", "success"), + _job("all-checks-pass", "completed", "success"), + _job("All required checks pass", "completed", "success"), + _job("comment-live", "in_progress", None), + _job("CI review comment (live)", "in_progress", None), + _job("Python tests", "completed", "success"), + ] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "success"} + assert pending == [] + + +def test_classify_sub_workflow_jobs_prefixed(): + """Sub-workflow jobs get 'Workflow / job' display names.""" + jobs = [ + _job("test", "completed", "success", workflow="Tests"), + _job("check", "in_progress", None, workflow="JS Tests"), + ] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert "Tests / test" in completed + assert completed["Tests / test"] == "success" + assert "JS Tests / check" in pending + + +def test_classify_captures_html_url(): + """The poller captures html_url per job for per-job log links.""" + jobs = [ + {**_job("Python tests", "completed", "failure"), + "html_url": "https://github.com/repo/actions/runs/1/job/2"}, + _job("Python lints", "completed", "success"), + ] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert job_urls["Python tests"] == "https://github.com/repo/actions/runs/1/job/2" + # Jobs without html_url are simply absent from the dict + assert "Python lints" not in job_urls + + +def test_classify_cancelled_treated_as_skipped(): + jobs = [_job("Python tests", "completed", "cancelled")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "skipped"} + + +def test_classify_timed_out_treated_as_failure(): + jobs = [_job("Python tests", "completed", "timed_out")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "failure"} + + +def test_classify_neutral_treated_as_skipped(): + jobs = [_job("Python tests", "completed", "neutral")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "skipped"} + + +def test_classify_action_required_treated_as_skipped(): + jobs = [_job("Python tests", "completed", "action_required")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "skipped"} + + +def test_classify_unknown_status_skipped(): + """Unknown status values are silently ignored, not crashed on.""" + jobs = [_job("weird-job", "unknown_status", None)] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {} + assert pending == [] diff --git a/tests/ci/test_lockfile_diff.py b/tests/ci/test_lockfile_diff.py index 0554ed473331..46b3ff01d745 100644 --- a/tests/ci/test_lockfile_diff.py +++ b/tests/ci/test_lockfile_diff.py @@ -89,15 +89,14 @@ def test_nested_dedup_is_distinct_entry(): assert d["updated"] == [("node_modules/foo/node_modules/react", "17.0.2", "17.0.3")] -def test_render_markdown_contains_marker_and_versions(): +def test_render_markdown_contains_versions_and_nested_display(): d = _mod.diff_locks( _mod.parse_lockfile(BASE), _mod.parse_lockfile(_lock({"node_modules/react": {"version": "19.0.0"}})), ) md = _mod.render_markdown({"apps/desktop/package-lock.json": d}) - assert md.startswith(_mod.COMMENT_MARKER) # workflow finds its comment by prefix - assert "⚠️" in md - assert "`apps/desktop/package-lock.json`" in md + # Fragment starts directly with the per-lockfile subsection header. + assert md.startswith("#### `apps/desktop/package-lock.json`") assert "`18.2.0`" in md and "`19.0.0`" in md # nested display name keeps the parent chain visible assert "nested under foo" in md diff --git a/tests/ci/test_timings_report.py b/tests/ci/test_timings_report.py new file mode 100644 index 000000000000..dee48c09f309 --- /dev/null +++ b/tests/ci/test_timings_report.py @@ -0,0 +1,143 @@ +"""Tests for scripts/ci/timings_report.py — generate_review_status(). + +The review status is a JSON array in the unified nested format consumed +by the review comment assembler. It classifies the CI timings result as +info/warning (never error — timings is an observability job, not a gate) +and provides a one-line summary plus optional per-job delta detail. +""" + +from __future__ import annotations + +import importlib.util +from datetime import datetime, timezone +from pathlib import Path + +_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "timings_report.py" +_spec = importlib.util.spec_from_file_location("timings_report", _PATH) +if _spec is None or _spec.loader is None: + raise ImportError("Failed to load timings_report.py") +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +_T0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _ts(seconds: float) -> str: + """ISO timestamp `seconds` after T0.""" + dt = _T0.timestamp() + seconds + return datetime.fromtimestamp(dt, tz=timezone.utc).isoformat().replace("+00:00", "Z") + + +def _job(name: str, dur_s: float, start_s: float = 0.0, conclusion: str = "success") -> dict: + """Build a normalized job dict with realistic timestamps for wall-time math.""" + return { + "name": name, + "duration_s": dur_s, + "conclusion": conclusion, + "started_at": _ts(start_s), + "completed_at": _ts(start_s + dur_s), + "wait_s": 0.0, + } + + +def _timings(jobs: list[dict]) -> dict: + return {"run_id": "123", "head_sha": "abc", "created_at": "", "jobs": jobs} + + +def _result(statuses: list[dict]) -> dict: + """Extract the single result dict from the nested format.""" + assert len(statuses) == 1 + assert statuses[0]["source"] == "ci timing" + results = statuses[0]["results"] + assert len(results) == 1 + return results[0] + + +def test_no_baseline_is_info(): + t = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(t, None)) + assert result["kind"] == "info" + assert "no baseline" in result["summary"].lower() + assert "link" not in result # no report_url → no link field + + +def test_no_regression_is_info(): + cur = _timings([_job("tests", 60.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert result["kind"] == "info" + assert "+0.0%" in result["summary"] + + +def test_small_regression_is_info(): + cur = _timings([_job("tests", 65.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + # +8.3% — well under the 25% warning threshold + assert result["kind"] == "info" + + +def test_large_regression_is_warning(): + cur = _timings([_job("tests", 80.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + # +33% — above the 25% threshold + assert result["kind"] == "warning" + assert "+33" in result["summary"] + + +def test_improvement_is_info(): + cur = _timings([_job("tests", 40.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert result["kind"] == "info" + assert "-33" in result["summary"] + + +def test_detail_shows_top_deltas(): + cur = _timings([_job("slow-job", 120.0), _job("fast-job", 30.0, start_s=120.0)]) + bl = _timings([_job("slow-job", 60.0), _job("fast-job", 60.0, start_s=60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert "slow-job" in result["detail"] + assert "fast-job" in result["detail"] + # Sorted by abs delta — slow-job (+60) before fast-job (-30) + assert result["detail"].index("slow-job") < result["detail"].index("fast-job") + + +def test_skipped_jobs_excluded_from_detail(): + cur = _timings([_job("skipped-job", 0.0, conclusion="skipped"), _job("tests", 60.0)]) + bl = _timings([_job("skipped-job", 0.0, conclusion="skipped"), _job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert "skipped-job" not in result["detail"] + + +def test_report_url_passed_through(): + t = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(t, None, report_url="https://artifact/123")) + assert result["link"] == "https://artifact/123" + assert result["link_label"] == "View report" + + +def test_never_error_severity(): + """Timings is observability — even huge regressions are warnings, not errors.""" + cur = _timings([_job("tests", 600.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert result["kind"] == "warning" + assert result["kind"] != "error" + + +def test_nested_format_structure(): + """The return value is a list with one {source, results: [...]} entry.""" + t = _timings([_job("tests", 60.0)]) + statuses = _mod.generate_review_status(t, None) + assert isinstance(statuses, list) + assert len(statuses) == 1 + assert statuses[0]["source"] == "ci timing" + assert isinstance(statuses[0]["results"], list) + assert len(statuses[0]["results"]) == 1 + r = statuses[0]["results"][0] + assert r["kind"] == "info" + assert r["title"] == "CI timings" + assert "summary" in r + assert "detail" in r diff --git a/tests/cli/test_cli_external_editor.py b/tests/cli/test_cli_external_editor.py index 082c5e40fb89..639449517cb9 100644 --- a/tests/cli/test_cli_external_editor.py +++ b/tests/cli/test_cli_external_editor.py @@ -103,3 +103,41 @@ def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path) # Flag is consumed by _on_text_changed, but since no handler is attached # in tests it stays True until the handler resets it. assert cli_obj._skip_paste_collapse is True + + +def test_inline_pastes_stores_full_content(tmp_path): + """History should recall the actual pasted text, not the placeholder.""" + cli_obj = _make_cli() + paste_file = tmp_path / "paste.txt" + paste_file.write_text("line one\nline two", encoding="utf-8") + buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines \u2192 {paste_file}]") + + cli_obj._inline_pastes(buffer) + + assert buffer.text == "line one\nline two" + assert buffer.cursor_position == len("line one\nline two") + # Skip flag set so the resulting text-change doesn't re-collapse. + assert cli_obj._skip_paste_collapse is True + + +def test_inline_pastes_leaves_plain_text_untouched(): + """No placeholder → buffer text and collapse flag are unchanged.""" + cli_obj = _make_cli() + buffer = _FakeBuffer(text="just a normal message") + + cli_obj._inline_pastes(buffer) + + assert buffer.text == "just a normal message" + assert cli_obj._skip_paste_collapse is False + + +def test_inline_pastes_missing_file_keeps_placeholder(tmp_path): + """A recalled reference whose file is gone stays as the placeholder.""" + cli_obj = _make_cli() + placeholder = f"[Pasted text #1: 2 lines \u2192 {tmp_path / 'gone.txt'}]" + buffer = _FakeBuffer(text=placeholder) + + cli_obj._inline_pastes(buffer) + + assert buffer.text == placeholder + assert cli_obj._skip_paste_collapse is False diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index a990f6bf3427..48de5b7c95f4 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -289,28 +289,26 @@ class TestPromptToolkitTerminalCompatibility: result = _build_cpr_disabled_output(_NoFileno()) assert result is None or result.enable_cpr is False - def test_cpr_gating_local_vs_tunnel(self, monkeypatch): - """CPR is only suppressed on tunneled links / explicit opt-out. + def test_cpr_gating_posix_local_and_windows_preserve(self, monkeypatch): + """POSIX suppresses CPR without SSH; native Windows keeps PT default. - CPR works fine on local terminals and is only a layout hint, so the fix - for #13870 must not change default behavior locally — it gates on - _terminal_may_leak_cpr(). Local (no SSH env) -> CPR left enabled; - SSH session or PROMPT_TOOLKIT_NO_CPR=1 -> CPR suppressed. + Broader coverage (Application wiring + delayed-CPR PTY repro) lives in + ``tests/cli/test_cpr_local_leak.py``. """ + import sys as _sys + from cli import _terminal_may_leak_cpr for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"): monkeypatch.delenv(var, raising=False) - # Local terminal: leave prompt_toolkit's default (CPR on) untouched. + monkeypatch.setattr(_sys, "platform", "linux") + assert _terminal_may_leak_cpr() is True + monkeypatch.setattr(_sys, "platform", "darwin") + assert _terminal_may_leak_cpr() is True + monkeypatch.setattr(_sys, "platform", "win32") assert _terminal_may_leak_cpr() is False - # SSH session: the tunnel where the leak reproduces. - monkeypatch.setenv("SSH_CONNECTION", "10.0.0.1 22 10.0.0.2 51234") - assert _terminal_may_leak_cpr() is True - monkeypatch.delenv("SSH_CONNECTION", raising=False) - - # prompt_toolkit's own explicit opt-out is honored. monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1") assert _terminal_may_leak_cpr() is True diff --git a/tests/cli/test_cpr_local_leak.py b/tests/cli/test_cpr_local_leak.py new file mode 100644 index 000000000000..efd174196ff4 --- /dev/null +++ b/tests/cli/test_cpr_local_leak.py @@ -0,0 +1,191 @@ +"""Local CPR leak reproduction + classic-CLI Application output selection. + +* Deterministic local-PTY proof that delayed CPR replies leak as + ``ESC[row;colR`` / ``^[[row;colR`` when ``enable_cpr=True`` (no SSH). +* Integration-level assertion that, with no SSH env vars, classic CLI + output selection wires a CPR-disabled Output into Application on POSIX. +* Native Windows keeps prompt_toolkit's default output selection. +""" + +from __future__ import annotations + +import os +import select +import sys +import threading +import time + +import pytest + +from cli import ( + _build_cpr_disabled_output, + _select_classic_cli_pt_output, + _terminal_may_leak_cpr, +) + + +@pytest.fixture(autouse=True) +def _clear_cpr_env(monkeypatch): + for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"): + monkeypatch.delenv(var, raising=False) + + +class TestClassicCliOutputSelection: + def test_posix_local_without_ssh_selects_cpr_disabled_output(self, monkeypatch): + """Changed contract: no SSH vars, still CPR-disabled on POSIX.""" + monkeypatch.setattr(sys, "platform", "linux") + assert _terminal_may_leak_cpr() is True + out = _select_classic_cli_pt_output(sys.stdout) + assert out is not None + assert out.enable_cpr is False + + def test_application_receives_cpr_not_supported_without_ssh(self, monkeypatch): + """Classic-CLI Application construction must get CPR-disabled output.""" + from prompt_toolkit.application import Application + from prompt_toolkit.layout import FormattedTextControl, Layout, Window + from prompt_toolkit.renderer import CPR_Support + + monkeypatch.setattr(sys, "platform", "linux") + out = _select_classic_cli_pt_output(sys.stdout) + assert out is not None + + app = Application( + layout=Layout(Window(FormattedTextControl("x"))), + output=out, + full_screen=False, + ) + assert app.renderer.cpr_support == CPR_Support.NOT_SUPPORTED + + def test_windows_preserves_default_output_selection(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + assert _terminal_may_leak_cpr() is False + assert _select_classic_cli_pt_output(sys.stdout) is None + + def test_windows_honors_explicit_no_cpr(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1") + assert _terminal_may_leak_cpr() is True + out = _select_classic_cli_pt_output(sys.stdout) + # Build may return None if stdout is not a real tty in CI; if it + # succeeds it must be CPR-disabled. + assert out is None or out.enable_cpr is False + + +def _openpty_or_skip(): + import pty + + try: + return pty.openpty() + except OSError as exc: + pytest.skip(f"no PTY devices available: {exc}") + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX PTY harness") +class TestDelayedCprLocalPtyLeak: + def test_delayed_cpr_reply_leaks_when_enable_cpr_true(self): + """Local (no SSH) delayed ESC[6n reply lands as ESC[39;1R on stdin.""" + import tty + + from prompt_toolkit.data_structures import Size + from prompt_toolkit.output.vt100 import Vt100_Output + + master, slave = _openpty_or_skip() + try: + tty.setraw(slave) + slave_w = os.fdopen(os.dup(slave), "w", buffering=1) + stop = threading.Event() + queries = 0 + + def terminal() -> None: + nonlocal queries + buf = b"" + while not stop.is_set(): + try: + r, _, _ = select.select([master], [], [], 0.05) + except OSError: + break + if not r: + continue + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + buf += chunk + while True: + idx = buf.find(b"\x1b[6n") + if idx < 0: + buf = buf[-8:] if len(buf) > 8 else buf + break + buf = buf[idx + 4 :] + queries += 1 + time.sleep(0.12) + try: + os.write(master, b"\x1b[39;1R") + except OSError: + pass + + threading.Thread(target=terminal, daemon=True).start() + out = Vt100_Output( + slave_w, lambda: Size(rows=40, columns=80), enable_cpr=True + ) + out.ask_for_cpr() + out.flush() + for i in range(4): + slave_w.write(f"\rgpt-5.6-sol Q {i}\n") + slave_w.flush() + time.sleep(0.02) + time.sleep(0.3) + + data = b"" + while True: + r, _, _ = select.select([slave], [], [], 0.05) + if not r: + break + data += os.read(slave, 4096) + + stop.set() + slave_w.close() + + assert queries >= 1 + assert b"\x1b[39;1R" in data + finally: + try: + os.close(slave) + except OSError: + pass + try: + os.close(master) + except OSError: + pass + + def test_cpr_disabled_output_sends_no_query(self): + """Hermes CPR-disabled builder must not emit ESC[6n.""" + master, slave = _openpty_or_skip() + try: + slave_w = os.fdopen(slave, "w", buffering=1) + out = _build_cpr_disabled_output(slave_w) + assert out is not None + assert out.enable_cpr is False + + seen = b"" + + def reader() -> None: + nonlocal seen + r, _, _ = select.select([master], [], [], 0.25) + if r: + seen = os.read(master, 4096) + + threading.Thread(target=reader, daemon=True).start() + slave_w.write("status ok\n") + slave_w.flush() + # Do not call ask_for_cpr — renderer skips it when NOT_SUPPORTED. + time.sleep(0.3) + slave_w.close() + assert b"\x1b[6n" not in seen + finally: + try: + os.close(master) + except OSError: + pass diff --git a/tests/gateway/relay/test_relay_adapter.py b/tests/gateway/relay/test_relay_adapter.py index 91d38edd4777..dba0d0edfa23 100644 --- a/tests/gateway/relay/test_relay_adapter.py +++ b/tests/gateway/relay/test_relay_adapter.py @@ -157,6 +157,27 @@ def _make_dm_event(chat_id="dm-1", user_id="user-42"): return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) +def _make_scoped_event_with_author( + chat_id="chan-1", scope_id="scope-9", user_id="user-42" +): + """An inbound scoped (guild/channel) message that ALSO carries the authentic + author user_id — the real shape of a Discord guild message (it has both a + guild scope_id and an author). Used to prove the adapter re-attaches BOTH + discriminators so the connector can fall back author-first when the guild + has no route row (managed agents join guilds dynamically).""" + from gateway.platforms.base import MessageEvent, MessageType + from gateway.session import SessionSource + + src = SessionSource( + platform=Platform.RELAY, + chat_id=chat_id, + chat_type="channel", + scope_id=scope_id, + user_id=user_id, + ) + return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) + + @pytest.mark.asyncio async def test_send_reattaches_scope_id_from_inbound_scope(): """The connector's egress guard resolves the owning tenant from @@ -234,10 +255,30 @@ async def test_send_preserves_explicit_user_id(): @pytest.mark.asyncio -async def test_scoped_reply_does_not_carry_user_id(): - """A scoped reply resolves by scope_id and must NOT carry a DM user_id even if - the same chat_id was somehow seen — scope capture wins and user_id stays out - (scope_id is the discriminator; user_id is the DM-only fallback).""" +async def test_scoped_reply_reattaches_both_scope_id_and_user_id(): + """A scoped (guild) reply now re-attaches BOTH scope_id AND the authentic + author user_id. scope_id is the connector's primary discriminator; user_id + is the author-first FALLBACK the connector uses when the guild has no route + row (a managed agent joins guilds dynamically, so a provision-time guild + route is not guaranteed). Regression for live 'discord egress declined: + target not routed to an onboarded tenant' on GUILD replies (paired with + gateway-gateway makeDiscordTenantOf guild-route-miss fallback).""" + t = _CaptureTransport() + a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t) + a._capture_scope( + _make_scoped_event_with_author( + chat_id="chan-1", scope_id="scope-9", user_id="user-42" + ) + ) + await a.send("chan-1", "hi") + assert t.sent["metadata"].get("scope_id") == "scope-9" + assert t.sent["metadata"].get("user_id") == "user-42" + + +@pytest.mark.asyncio +async def test_scoped_reply_without_inbound_author_carries_scope_only(): + """A scoped inbound with no author id yields scope_id only — the adapter + never invents a user_id it didn't observe.""" t = _CaptureTransport() a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t) a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9")) diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 81264fa37c9d..853a9538f5d0 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -243,6 +243,32 @@ class TestExtractCacheBustingConfig: assert out["compression.protect_last_n"] == 25 assert out["compression.codex_app_server_auto"] == "hermes" + def test_reads_checkpoint_subkeys(self): + from gateway.run import GatewayRunner + + out = GatewayRunner._extract_cache_busting_config( + { + "checkpoints": { + "enabled": True, + "max_snapshots": 12, + "max_total_size_mb": 333, + "max_file_size_mb": 5, + } + } + ) + + assert out["checkpoints.enabled"] is True + assert out["checkpoints.max_snapshots"] == 12 + assert out["checkpoints.max_total_size_mb"] == 333 + assert out["checkpoints.max_file_size_mb"] == 5 + + def test_reads_legacy_checkpoint_boolean(self): + from gateway.run import GatewayRunner + + out = GatewayRunner._extract_cache_busting_config({"checkpoints": True}) + + assert out["checkpoints.enabled"] is True + def test_missing_keys_yield_none(self): """Absent config keys must produce None values (still contribute to signature).""" from gateway.run import GatewayRunner diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index a9621924b577..56734698019a 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -330,7 +330,7 @@ class TestAdapterInit: adapter = APIServerAdapter(config) assert adapter._port == 8642 - def test_create_agent_forwards_config_reasoning_effort(self, monkeypatch): + def test_create_agent_forwards_runtime_config(self, monkeypatch): captured = {} class FakeAgent: @@ -349,7 +349,15 @@ class TestAdapterInit: monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "gpt-5.5") monkeypatch.setattr( "gateway.run._load_gateway_config", - lambda: {"agent": {"reasoning_effort": "xhigh"}}, + lambda: { + "agent": {"reasoning_effort": "xhigh"}, + "checkpoints": { + "enabled": True, + "max_snapshots": 7, + "max_total_size_mb": 321, + "max_file_size_mb": 4, + }, + }, ) monkeypatch.setattr( "gateway.run.GatewayRunner._load_reasoning_config", @@ -365,6 +373,10 @@ class TestAdapterInit: assert isinstance(agent, FakeAgent) assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"} + assert captured["checkpoints_enabled"] is True + assert captured["checkpoint_max_snapshots"] == 7 + assert captured["checkpoint_max_total_size_mb"] == 321 + assert captured["checkpoint_max_file_size_mb"] == 4 def test_create_agent_refreshes_max_iterations_from_runtime_config(self, monkeypatch): captured = {} diff --git a/tests/gateway/test_background_command.py b/tests/gateway/test_background_command.py index a6c44f6f1189..462b9ae0a65a 100644 --- a/tests/gateway/test_background_command.py +++ b/tests/gateway/test_background_command.py @@ -248,7 +248,16 @@ class TestRunBackgroundTask: mock_result = {"final_response": "Hello from background!", "messages": []} + checkpoint_config = { + "checkpoints": { + "enabled": True, + "max_snapshots": 8, + "max_total_size_mb": 222, + "max_file_size_mb": 3, + } + } with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \ + patch("gateway.run._load_gateway_config", return_value=checkpoint_config), \ patch("run_agent.AIAgent") as MockAgent: mock_agent_instance = MagicMock() mock_agent_instance.shutdown_memory_provider = MagicMock() @@ -264,6 +273,11 @@ class TestRunBackgroundTask: content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "") assert "Background task complete" in content assert "Hello from background!" in content + agent_kwargs = MockAgent.call_args.kwargs + assert agent_kwargs["checkpoints_enabled"] is True + assert agent_kwargs["checkpoint_max_snapshots"] == 8 + assert agent_kwargs["checkpoint_max_total_size_mb"] == 222 + assert agent_kwargs["checkpoint_max_file_size_mb"] == 3 mock_agent_instance.shutdown_memory_provider.assert_called_once() mock_agent_instance.close.assert_called_once() diff --git a/tests/gateway/test_checkpoint_config.py b/tests/gateway/test_checkpoint_config.py new file mode 100644 index 000000000000..51876f2f09b2 --- /dev/null +++ b/tests/gateway/test_checkpoint_config.py @@ -0,0 +1,53 @@ +"""Runtime coverage for gateway filesystem-checkpoint configuration.""" + + +def test_gateway_checkpoint_config_reaches_real_agent(tmp_path, monkeypatch): + """Raw gateway YAML must configure the real agent checkpoint manager.""" + from gateway import run as gateway_run + from run_agent import AIAgent + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + """checkpoints: + enabled: true + max_snapshots: 11 + max_total_size_mb: 345 + max_file_size_mb: 6 +""", + encoding="utf-8", + ) + + config = gateway_run._load_gateway_config() + agent = AIAgent( + model="anthropic/claude-sonnet-4", + api_key="test", + base_url="https://openrouter.ai/api/v1", + provider="openrouter", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=[], + **gateway_run._checkpoint_agent_kwargs(config), + ) + try: + manager = agent._checkpoint_mgr + assert manager.enabled is True + assert manager.max_snapshots == 11 + assert manager.max_total_size_mb == 345 + assert manager.max_file_size_mb == 6 + finally: + agent.close() + + +def test_checkpoint_agent_kwargs_supports_legacy_boolean_config(): + from gateway.run import _checkpoint_agent_kwargs + from hermes_cli.config import DEFAULT_CONFIG + + kwargs = _checkpoint_agent_kwargs({"checkpoints": True}) + defaults = DEFAULT_CONFIG["checkpoints"] + + assert kwargs["checkpoints_enabled"] is True + assert kwargs["checkpoint_max_snapshots"] == defaults["max_snapshots"] + assert kwargs["checkpoint_max_total_size_mb"] == defaults["max_total_size_mb"] + assert kwargs["checkpoint_max_file_size_mb"] == defaults["max_file_size_mb"] diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index d239728b7941..75bb826332e3 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -1033,7 +1033,10 @@ class TestMatrixRenderingPayloads: @pytest.mark.asyncio async def test_long_response_split_preserves_thread_context(self): - long_text = "Intro\n```python\n" + ("print('hello')\n" * 500) + "```\nDone" + # Build a payload guaranteed to exceed the adapter's outbound chunk + # size (configurable since #53026) so send() must split it. + repeats = (self.adapter.max_message_length // 15) + 200 + long_text = "Intro\n```python\n" + ("print('hello')\n" * repeats) + "```\nDone" result = await self.adapter.send( "!room:example.org", diff --git a/tests/gateway/test_matrix_message_length.py b/tests/gateway/test_matrix_message_length.py new file mode 100644 index 000000000000..095993b46bd0 --- /dev/null +++ b/tests/gateway/test_matrix_message_length.py @@ -0,0 +1,85 @@ +"""Tests for Matrix outbound message length configuration (#53026).""" +import asyncio +import os +from unittest.mock import AsyncMock, MagicMock, patch + +import pytest + +from gateway.config import PlatformConfig + + +def _make_adapter(**extra): + from plugins.platforms.matrix.adapter import MatrixAdapter + + config = PlatformConfig( + enabled=True, + token="syt_test_token", + extra={ + "homeserver": "https://matrix.example.org", + "user_id": "@bot:example.org", + **extra, + }, + ) + return MatrixAdapter(config) + + +class TestMatrixMaxMessageLength: + def test_default_limit_is_16000(self): + adapter = _make_adapter() + assert adapter.max_message_length == 16000 + assert adapter._split_threshold == 15900 + + def test_extra_override(self): + adapter = _make_adapter(max_message_length=12000) + assert adapter.max_message_length == 12000 + assert adapter._split_threshold == 11900 + + def test_env_override(self, monkeypatch): + monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000") + adapter = _make_adapter() + assert adapter.max_message_length == 20000 + + def test_extra_beats_env(self, monkeypatch): + monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000") + adapter = _make_adapter(max_message_length=10000) + assert adapter.max_message_length == 10000 + + def test_invalid_values_fall_back_to_default(self, monkeypatch): + monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "not-a-number") + adapter = _make_adapter() + assert adapter.max_message_length == 16000 + + def test_values_are_clamped(self): + adapter = _make_adapter(max_message_length=100) + assert adapter.max_message_length == 500 + adapter = _make_adapter(max_message_length=999999) + assert adapter.max_message_length == 65535 + + def test_apply_yaml_config_sets_env(self, monkeypatch): + from plugins.platforms.matrix.adapter import _apply_yaml_config + + monkeypatch.delenv("MATRIX_MAX_MESSAGE_LENGTH", raising=False) + _apply_yaml_config({}, {"max_message_length": 12000}) + assert os.getenv("MATRIX_MAX_MESSAGE_LENGTH") == "12000" + + def test_register_uses_default_limit(self): + from plugins.platforms.matrix.adapter import DEFAULT_MAX_MESSAGE_LENGTH, register + + ctx = MagicMock() + register(ctx) + kwargs = ctx.register_platform.call_args[1] + assert kwargs["max_message_length"] == DEFAULT_MAX_MESSAGE_LENGTH + + def test_send_uses_configured_limit(self): + adapter = _make_adapter(max_message_length=5000) + adapter._client = MagicMock() + adapter._client.send_message_event = AsyncMock(return_value="evt") + long_text = "x" * 12000 + + async def _run(): + with patch.object(adapter, "truncate_message", wraps=adapter.truncate_message) as trunc: + await adapter.send("!room:example.org", long_text) + trunc.assert_called_once() + assert trunc.call_args[0][1] == 5000 + + asyncio.run(_run()) diff --git a/tests/gateway/test_run_cleanup_progress.py b/tests/gateway/test_run_cleanup_progress.py index 0a66be4b30a8..b71c8b2712f5 100644 --- a/tests/gateway/test_run_cleanup_progress.py +++ b/tests/gateway/test_run_cleanup_progress.py @@ -233,6 +233,52 @@ async def test_cleanup_off_by_default_leaves_bubbles(monkeypatch, tmp_path): assert adapter.deleted == [] +@pytest.mark.asyncio +async def test_messaging_agent_forwards_checkpoint_config(monkeypatch, tmp_path): + """Writable gateway agents must receive the configured checkpoint limits.""" + captured = {} + + class CheckpointCaptureAgent(ProgressAgent): + def __init__(self, **kwargs): + captured.update(kwargs) + super().__init__(**kwargs) + + adapter = CleanupCaptureAdapter() + runner = _make_runner(adapter) + gateway_run = _install_fakes( + monkeypatch, CheckpointCaptureAgent, cleanup_on=False, + ) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr( + gateway_run, + "_load_gateway_config", + lambda: { + "checkpoints": { + "enabled": True, + "max_snapshots": 9, + "max_total_size_mb": 444, + "max_file_size_mb": 6, + } + }, + ) + + source = SessionSource(platform=Platform.TELEGRAM, chat_id="-1001") + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-checkpoints", + session_key="agent:main:telegram:group:-1001", + ) + + assert result["final_response"] == "done" + assert captured["checkpoints_enabled"] is True + assert captured["checkpoint_max_snapshots"] == 9 + assert captured["checkpoint_max_total_size_mb"] == 444 + assert captured["checkpoint_max_file_size_mb"] == 6 + + @pytest.mark.asyncio async def test_cleanup_registers_callback_and_deletes_on_success(monkeypatch, tmp_path): """With the flag on, the cleanup callback deletes the progress bubble.""" diff --git a/tests/gateway/test_telegram_conflict.py b/tests/gateway/test_telegram_conflict.py index e00a0f1d33c6..f85034b2f902 100644 --- a/tests/gateway/test_telegram_conflict.py +++ b/tests/gateway/test_telegram_conflict.py @@ -293,6 +293,32 @@ async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch): await _cancel_heartbeat(adapter) +@pytest.mark.asyncio +async def test_conflict_exhaustion_hands_off_before_child_disconnect(): + """The conflict recovery owner must survive its fatal callback handoff.""" + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***")) + adapter._polling_conflict_count = 5 # MAX_CONFLICT_RETRIES + disconnect_tasks = [] + + async def fatal_handler(failed_adapter): + disconnect_task = asyncio.create_task(failed_adapter.disconnect()) + disconnect_tasks.append(disconnect_task) + await asyncio.wait({disconnect_task}) + + adapter.set_fatal_error_handler(fatal_handler) + + conflict = type("Conflict", (Exception,), {}) + recovery_task = asyncio.create_task( + adapter._handle_polling_conflict(conflict("getUpdates conflict")) + ) + adapter._polling_error_task = recovery_task + result = await asyncio.gather(recovery_task, return_exceptions=True) + await asyncio.gather(*disconnect_tasks, return_exceptions=True) + + assert result == [None] + assert adapter._polling_error_task is None + + @pytest.mark.asyncio async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(monkeypatch): adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***")) @@ -730,4 +756,3 @@ async def test_conflict_callback_disarms_before_scheduling(monkeypatch): for _ in range(10): await asyncio.sleep(0) await _cancel_heartbeat(adapter) - diff --git a/tests/gateway/test_telegram_network_reconnect.py b/tests/gateway/test_telegram_network_reconnect.py index 4ffa4a99dd88..4b30ef68839f 100644 --- a/tests/gateway/test_telegram_network_reconnect.py +++ b/tests/gateway/test_telegram_network_reconnect.py @@ -14,7 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from gateway.config import PlatformConfig +from gateway.config import GatewayConfig, Platform, PlatformConfig def _ensure_telegram_mock(): @@ -37,6 +37,7 @@ _ensure_telegram_mock() from plugins.platforms.telegram import adapter as tg_adapter # noqa: E402 from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402 +from gateway.run import GatewayRunner # noqa: E402 @pytest.fixture(autouse=True) @@ -224,6 +225,76 @@ async def test_reconnect_triggers_fatal_after_max_retries(): fatal_handler.assert_called_once() +@pytest.mark.asyncio +async def test_retry_exhaustion_queues_reconnect_before_child_disconnect(tmp_path): + """Fatal teardown must not cancel the gateway's reconnect handoff. + + The gateway runs ``disconnect()`` in a bounded child task. If the current + polling-recovery owner remains in ``_polling_error_task``, Telegram teardown + cancels that parent while it is still awaiting the fatal handler, so the + handler never gets to queue background reconnection. + """ + config = GatewayConfig( + platforms={ + Platform.TELEGRAM: PlatformConfig(enabled=True, token="test-token") + }, + sessions_dir=tmp_path / "sessions", + ) + runner = GatewayRunner(config) + adapter = _make_adapter() + adapter._polling_network_error_count = 10 # MAX_NETWORK_RETRIES + adapter.set_fatal_error_handler(runner._handle_adapter_fatal_error) + runner.adapters = {Platform.TELEGRAM: adapter} + runner.delivery_router.adapters = runner.adapters + + recovery_task = asyncio.create_task( + adapter._handle_polling_network_error(Exception("still failing")) + ) + adapter._polling_error_task = recovery_task + result = await asyncio.gather(recovery_task, return_exceptions=True) + + assert result == [None] + assert runner.adapters == {} + assert Platform.TELEGRAM in runner._failed_platforms + assert runner._failed_platforms[Platform.TELEGRAM]["attempts"] == 0 + + +@pytest.mark.asyncio +async def test_heartbeat_watchdog_handoff_survives_child_disconnect(tmp_path): + """The wedged-recovery heartbeat watchdog must survive its fatal callback. + + The heartbeat loop force-escalates a stuck polling-recovery task. Like + the network/conflict terminal paths, the heartbeat task itself is the + owner that ``disconnect()`` cancels, so the fatal callback must release + ``_polling_heartbeat_task`` before notifying the runner. + """ + config = GatewayConfig( + platforms={ + Platform.TELEGRAM: PlatformConfig(enabled=True, token="test-token") + }, + sessions_dir=tmp_path / "sessions", + ) + runner = GatewayRunner(config) + adapter = _make_adapter() + adapter.set_fatal_error_handler(runner._handle_adapter_fatal_error) + runner.adapters = {Platform.TELEGRAM: adapter} + runner.delivery_router.adapters = runner.adapters + + # Simulate the heartbeat watchdog's fatal-escalation path directly. + adapter._set_fatal_error( + "telegram_network_error", + "Telegram reconnect task wedged; forcing gateway reconnect.", + retryable=True, + ) + heartbeat_task = asyncio.create_task(adapter._handoff_polling_fatal_error()) + adapter._polling_heartbeat_task = heartbeat_task + result = await asyncio.gather(heartbeat_task, return_exceptions=True) + + assert result == [None] + assert runner.adapters == {} + assert Platform.TELEGRAM in runner._failed_platforms + + # --------------------------------------------------------------------------- # Connection pool drain tests (PR #16466 salvage) # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_text_batching.py b/tests/gateway/test_text_batching.py index d72cb439d474..6c0cdaa6d9ff 100644 --- a/tests/gateway/test_text_batching.py +++ b/tests/gateway/test_text_batching.py @@ -278,9 +278,9 @@ class TestMatrixTextBatching: @pytest.mark.asyncio async def test_adaptive_delay_for_near_limit_chunk(self): - """Chunks near the 4000-char limit should trigger longer delay.""" + """Chunks near the outbound limit should trigger longer delay.""" adapter = _make_matrix_adapter() - long_text = "x" * 3950 + long_text = "x" * (adapter._split_threshold + 50) adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX)) await asyncio.sleep(0.15) diff --git a/tests/hermes_cli/test_billing_cli.py b/tests/hermes_cli/test_billing_cli.py index 6c1a7c3b6b08..d90dfb9c26bc 100644 --- a/tests/hermes_cli/test_billing_cli.py +++ b/tests/hermes_cli/test_billing_cli.py @@ -81,7 +81,11 @@ def test_billing_killswitch_off_blocks(cli, monkeypatch, capsys): monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) cli._show_billing("/billing") out = capsys.readouterr().out - assert "turned off for this org" in out + assert "Remote spending is off for this org." in out + assert ( + "A billing admin can turn it on from the portal's Hermes Agent page " + "to add funds here." + ) in out def test_billing_limit_screen_readonly(cli, monkeypatch, capsys): diff --git a/tests/hermes_cli/test_kimi_cn_provider_listing.py b/tests/hermes_cli/test_kimi_cn_provider_listing.py new file mode 100644 index 000000000000..1a49b0d8fd1a --- /dev/null +++ b/tests/hermes_cli/test_kimi_cn_provider_listing.py @@ -0,0 +1,176 @@ +"""Test that kimi-coding and kimi-coding-cn both appear in the /model picker. + +Both providers share the same models.dev ID (kimi-for-coding) but are distinct +profiles with different API keys, base URLs, and endpoints. The /model picker +must show both so users can pick the right endpoint for their key type. + +Regression: the original ``seen_mdev_ids`` dedup by mdev_id alone would skip +kimi-coding-cn after kimi-coding was emitted because both map to +``kimi-for-coding`` (#10526). The fix deduplicates by +``(mdev_id, canonical_profile_name)`` instead, allowing distinct profiles +through. +""" + +import os +from unittest.mock import patch + +from hermes_cli.model_switch import ( + list_authenticated_providers, + parse_model_flags, + switch_model, +) +from hermes_cli.providers import resolve_provider_full + + +# -- Only KIMI_CN_API_KEY set ------------------------------------------------ + + +@patch.dict(os.environ, {"KIMI_CN_API_KEY": "sk-cn-fake"}, clear=False) +def test_kimi_cn_appears_when_only_cn_key_set(): + """kimi-coding-cn should appear when only KIMI_CN_API_KEY is set.""" + providers = list_authenticated_providers(current_provider="kimi-coding-cn") + + # kimi-coding-cn must be listed (it has credentials) + cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None) + assert cn is not None, ( + "kimi-coding-cn should appear when KIMI_CN_API_KEY is set" + ) + assert cn["is_current"] is True + assert cn["total_models"] > 0 + + # kimi-coding must NOT appear (no KIMI_API_KEY) + intl = next((p for p in providers if p["slug"] == "kimi-coding"), None) + assert intl is None, ( + "kimi-coding should NOT appear when only KIMI_CN_API_KEY is set" + ) + + +# -- Only KIMI_API_KEY set --------------------------------------------------- + + +@patch.dict(os.environ, {"KIMI_API_KEY": "sk-intl-fake"}, clear=False) +def test_kimi_intl_appears_when_only_intl_key_set(): + """kimi-coding (international) should appear when only KIMI_API_KEY is set.""" + providers = list_authenticated_providers(current_provider="kimi-coding") + + intl = next((p for p in providers if p["slug"] == "kimi-coding"), None) + assert intl is not None, ( + "kimi-coding should appear when KIMI_API_KEY is set" + ) + assert intl["is_current"] is True + + # kimi-coding-cn must NOT appear (no KIMI_CN_API_KEY) + cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None) + assert cn is None, ( + "kimi-coding-cn should NOT appear when only KIMI_API_KEY is set" + ) + + +# -- Both keys set ----------------------------------------------------------- + +@patch.dict(os.environ, { + "KIMI_API_KEY": "sk-intl-fake", + "KIMI_CN_API_KEY": "sk-cn-fake", +}, clear=False) +def test_both_kimi_providers_appear_when_both_keys_set(): + """Both kimi-coding and kimi-coding-cn should appear when both keys set. + + They are distinct profiles with different env vars and endpoints. The + existing aliases (kimi, moonshot → kimi-coding; kimi-cn, moonshot-cn → + kimi-coding-cn) must NOT create additional rows. + """ + providers = list_authenticated_providers(current_provider="kimi-coding") + + # Both profile slugs must appear + intl = next((p for p in providers if p["slug"] == "kimi-coding"), None) + assert intl is not None, "kimi-coding should appear when KIMI_API_KEY is set" + assert intl["is_current"] is True + + cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None) + assert cn is not None, ( + "kimi-coding-cn should appear when KIMI_CN_API_KEY is set" + ) + assert cn["is_current"] is False # `current_provider` is kimi-coding + + # Exactly 2 Kimi entries — no duplicates for aliases (kimi, moonshot, + # moonshot-cn, kimi-cn) + kimi_slugs = [p["slug"] for p in providers if "kimi" in p["slug"] or "moonshot" in p["slug"]] + assert len(kimi_slugs) == 2, ( + f"Expected exactly 2 Kimi entries (kimi-coding, kimi-coding-cn), " + f"got {kimi_slugs}" + ) + + +# -- Both aliases deduped correctly ------------------------------------------ + +@patch.dict(os.environ, { + "KIMI_API_KEY": "sk-intl-fake", + "KIMI_CN_API_KEY": "sk-cn-fake", +}, clear=False) +def test_kimi_aliases_not_listed_separately(): + """Alias hermes_ids (kimi, moonshot) must NOT create phantom picker rows. + + They resolve to the same canonical profile (kimi-coding) and should be + deduped. Only the canonical slug (kimi-coding) should appear. + """ + providers = list_authenticated_providers(current_provider="kimi-coding-cn") + + slugs = {p["slug"] for p in providers} + # These alias slugs must NOT appear + for bad_slug in ("kimi", "moonshot", "moonshot-cn", "kimi-cn"): + assert bad_slug not in slugs, ( + f"Alias slug '{bad_slug}' must not appear in picker (resolved to " + f"canonical profile)" + ) + + +@patch.dict(os.environ, { + "KIMI_API_KEY": "sk-intl-fake", + "KIMI_CN_API_KEY": "sk-cn-fake", +}, clear=False) +def test_resolve_provider_full_preserves_kimi_cn_provider_identity(): + """Explicit kimi-coding-cn must not collapse to shared models.dev alias. + + Regression: resolve_provider_full('kimi-coding-cn') used normalize_provider(), + which mapped both kimi-coding and kimi-coding-cn to the models.dev alias + 'kimi-for-coding'. That silently rewired CN users to the international + endpoint and KIMI_API_KEY. + """ + pdef = resolve_provider_full("kimi-coding-cn", None, None) + assert pdef is not None + assert pdef.id == "kimi-coding-cn" + assert pdef.base_url == "https://api.moonshot.cn/v1" + assert pdef.api_key_env_vars == ("KIMI_CN_API_KEY",) + + +@patch.dict(os.environ, { + "KIMI_API_KEY": "sk-intl-fake", + "KIMI_CN_API_KEY": "sk-cn-fake", +}, clear=False) +def test_switch_model_with_explicit_kimi_cn_provider_stays_on_cn_endpoint(): + """/model ... --provider kimi-coding-cn must stay on moonshot.cn. + + This hits the real switch path used by gateway /model: parse flags first, + then call switch_model() with explicit_provider. The result must not rewrite + the target provider/base_url back to the international Kimi endpoint. + """ + model_input, explicit_provider, *_ = parse_model_flags( + "kimi-k2.6 —provider kimi-coding-cn" + ) + result = switch_model( + raw_input=model_input, + current_provider="deepseek", + current_model="deepseek-v4-flash", + current_base_url="https://api.deepseek.com/v1", + current_api_key="***", + is_global=False, + explicit_provider=explicit_provider, + user_providers={}, + custom_providers=None, + ) + + assert result.success is True + assert result.target_provider == "kimi-coding-cn" + assert result.new_model == "kimi-k2.6" + assert result.base_url == "https://api.moonshot.cn/v1" + assert result.api_key == "sk-cn-fake" diff --git a/tests/hermes_cli/test_list_picker_providers.py b/tests/hermes_cli/test_list_picker_providers.py index a2042a96db59..56d759ce4b7a 100644 --- a/tests/hermes_cli/test_list_picker_providers.py +++ b/tests/hermes_cli/test_list_picker_providers.py @@ -299,3 +299,103 @@ def test_current_custom_endpoint_passthrough_marks_current_row(monkeypatch): assert row["slug"] == "custom:ollama" assert row["is_current"] is True assert row["models"] == ["glm-5.1", "qwen3"] + + +# --------------------------------------------------------------------------- +# list_authenticated_providers: alias/canonical de-dup for Kimi (#49439) +# --------------------------------------------------------------------------- +# +# A single Kimi credential used to surface TWO picker rows: the alias slug +# "kimi" (emitted by the PROVIDER_TO_MODELS_DEV pass) plus its canonical +# "kimi-coding" (re-emitted by the CANONICAL_PROVIDERS cross-check pass), +# both backed by the same kimi-for-coding models.dev provider. The picker +# must list each authenticated credential exactly once, under the CANONICAL +# slug ("kimi-coding") — matching list_authenticated_providers' other alias +# rows and the overlay slug-resolution contract (see +# test_overlay_slug_resolution.py). + + +def _stub_kimi_discovery(monkeypatch, *, canonical): + """Isolate list_authenticated_providers to the Kimi alias family. + + Restricts the models.dev map / catalog / overlays / canonical list to + just the Kimi entries and stubs the model-id fetch so discovery stays + offline and deterministic. ``canonical`` is the CANONICAL_PROVIDERS list + the 2b cross-check pass should iterate. + """ + import agent.models_dev as md + import hermes_cli.models as hm + + kimi_map = { + "kimi": "kimi-for-coding", + "kimi-coding": "kimi-for-coding", + "moonshot": "kimi-for-coding", + "kimi-coding-cn": "kimi-for-coding", + } + monkeypatch.setattr(md, "PROVIDER_TO_MODELS_DEV", kimi_map) + monkeypatch.setattr( + md, "fetch_models_dev", + lambda *a, **k: { + "kimi-for-coding": {"name": "Kimi For Coding", "env": ["KIMI_API_KEY"]}, + }, + ) + + class _PInfo: + name = "Kimi For Coding" + + monkeypatch.setattr(md, "get_provider_info", lambda _pid: _PInfo()) + monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {}) + monkeypatch.setattr(hm, "CANONICAL_PROVIDERS", canonical) + monkeypatch.setattr(hm, "cached_provider_model_ids", + lambda *a, **k: ["kimi-k2.6", "kimi-k2.5"]) + monkeypatch.setattr(hm, "clear_provider_models_cache", lambda *a, **k: None) + + +def test_single_kimi_credential_yields_one_canonical_row(monkeypatch): + """One Kimi key yields a single row under the canonical 'kimi-coding' slug.""" + import hermes_cli.models as hm + + _stub_kimi_discovery( + monkeypatch, + canonical=[hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc")], + ) + monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi") + + rows = model_switch.list_authenticated_providers(max_models=10) + slugs = [r["slug"] for r in rows] + + # Exactly one Kimi / kimi-for-coding-backed row, under the canonical slug — + # not both the alias ("kimi") and its canonical ("kimi-coding"). + kimi_rows = [s for s in slugs if s in {"kimi", "kimi-coding"}] + assert kimi_rows == ["kimi-coding"], ( + f"expected a single canonical Kimi row, got: {slugs}" + ) + assert slugs.count("kimi-coding") == 1 + assert "kimi" not in slugs + + +def test_distinct_kimi_china_credential_still_listed(monkeypatch): + """A separate China (kimi-coding-cn) credential remains its own row. + + Negative-control guard: the de-dup must collapse only the alias/canonical + pair that share a credential, not legitimately distinct providers. + """ + import hermes_cli.models as hm + + _stub_kimi_discovery( + monkeypatch, + canonical=[ + hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc"), + hm.ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "desc"), + ], + ) + monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi") + monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test-kimi-cn") + + rows = model_switch.list_authenticated_providers(max_models=10) + slugs = [r["slug"] for r in rows] + + assert "kimi-coding" in slugs # canonical global row + assert slugs.count("kimi-coding") == 1 + assert "kimi" not in slugs # alias collapsed into the canonical row + assert "kimi-coding-cn" in slugs # distinct China endpoint preserved diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py index 7a5b2babb59b..deb13648a881 100644 --- a/tests/hermes_cli/test_subscription_cli.py +++ b/tests/hermes_cli/test_subscription_cli.py @@ -1,7 +1,7 @@ """Tests for the /subscription CLI change flow (cli.py::_show_subscription). Parity with the TUI overlay: the classic CLI now previews + applies a plan change -in-terminal (picker → preview → confirm → apply), grants terminal billing inline on +in-terminal (picker → preview → confirm → apply), allows remote spending inline on insufficient_scope, and leads a scheduled downgrade/cancel with a prominent banner. Interactive screens are driven by mocking `_prompt_text_input_modal`. """ @@ -158,7 +158,7 @@ def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsy def _put(**kw): calls["n"] += 1 if calls["n"] == 1: - raise nb.BillingScopeRequired("terminal billing required") + raise nb.BillingScopeRequired("remote spending required") return {"message": "Scheduled."} monkeypatch.setattr(nb, "put_subscription_pending_change", _put) @@ -171,7 +171,7 @@ def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsy # applied once (scope-denied), granted, replayed → applied again assert calls["n"] == 2 - assert "Terminal billing enabled" in out + assert "Remote Spending allowed" in out def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys): @@ -183,7 +183,7 @@ def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys): def _put(**kw): calls["n"] += 1 - raise nb.BillingScopeRequired("terminal billing required") + raise nb.BillingScopeRequired("remote spending required") monkeypatch.setattr(nb, "put_subscription_pending_change", _put) import hermes_cli.auth as auth @@ -194,7 +194,7 @@ def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys): out = capsys.readouterr().out assert calls["n"] == 1 # applied once, grant denied, no replay - assert "Couldn't enable terminal billing" in out + assert "Couldn't allow Remote Spending" in out def test_unknown_preview_effect_fails_safe(cli, monkeypatch, capsys): @@ -235,7 +235,10 @@ def test_bounded_stepup_does_not_loop_on_repeat_denial(cli, monkeypatch, capsys) out = capsys.readouterr().out assert calls["n"] == 2 # applied, granted, replayed once — no third attempt - assert "still isn't enabled" in out + assert ( + "Remote Spending still isn't active for this terminal — the authorization " + "didn't take. Retry, or make this change on the portal." + ) in out def test_upgrade_transport_failure_is_ambiguous_not_flat_failure(cli, monkeypatch, capsys): diff --git a/tests/test_agent_code_skew.py b/tests/test_agent_code_skew.py new file mode 100644 index 000000000000..be61a86e996d --- /dev/null +++ b/tests/test_agent_code_skew.py @@ -0,0 +1,72 @@ +"""Tests for agent-side code-skew detection (desktop/serve backend). + +Companion to ``tests/test_code_skew.py`` (gateway): these prove the same +protection exists for the long-lived ``hermes serve`` / desktop backend +process, which imports ``run_agent`` directly rather than going through the +gateway. See #68178. +""" + +import pytest + + +class TestAgentCodeSkewCaching: + def test_boot_fingerprint_recorded_at_import(self): + """``run_agent`` records its boot fingerprint on first import.""" + import run_agent + + # Should not be None on a git install. + assert run_agent._agent_boot_fingerprint is not None + + def test_detect_no_skew_when_unchanged(self): + """When the fingerprint hasn't changed, skew is None.""" + import run_agent + + assert run_agent._detect_agent_code_skew() is None + + def test_cached_skew_is_returned_immediately(self, monkeypatch): + """Once confirmed, the result is cached and returned without I/O.""" + import run_agent + + monkeypatch.setattr(run_agent, "_agent_code_skew_confirmed", True) + monkeypatch.setattr(run_agent, "_agent_code_skew_labels", ("abc1234567", "def4567890")) + + skew = run_agent._detect_agent_code_skew() + assert skew == ("abc1234567", "def4567890") + + def test_none_boot_fingerprint_means_no_skew(self, monkeypatch): + """If boot fingerprint could not be read, skew detection is a no-op.""" + import run_agent + + monkeypatch.setattr(run_agent, "_agent_boot_fingerprint", None) + monkeypatch.setattr(run_agent, "_agent_code_skew_confirmed", False) + monkeypatch.setattr(run_agent, "_agent_code_skew_labels", None) + + assert run_agent._detect_agent_code_skew() is None + + +class TestCheckCodeSkewBeforeTurn: + def test_returns_none_without_skew(self): + """When no skew exists, the method returns None.""" + import run_agent + + # Create a minimal fake agent with the method. + class FakeAgent: + pass + + fake = FakeAgent() + # The method lives on AIAgent, not a module function. Test by + # verifying the underlying function returns None when no skew. + result = run_agent._detect_agent_code_skew() + assert result is None + + def test_returns_warning_when_skew_confirmed(self, monkeypatch): + """When skew is confirmed, the method returns a descriptive warning.""" + import run_agent + + monkeypatch.setattr(run_agent, "_agent_code_skew_confirmed", True) + monkeypatch.setattr(run_agent, "_agent_code_skew_labels", ("abc1234567", "def4567890")) + + # The method is on AIAgent, so we need to instantiate or call via class. + # Instead, test the underlying function directly. + skew = run_agent._detect_agent_code_skew() + assert skew == ("abc1234567", "def4567890") diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 414b0d69c605..dd6ecbc55182 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -1365,6 +1365,57 @@ class TestMessageStorage: assert model_history == model_expected assert display_history == display_expected + def test_get_ancestor_display_prefix_single_session_returns_empty(self, db): + """A session with no compression ancestors has an empty prefix.""" + db.create_session("solo", "cli") + db.append_message("solo", role="user", content="hi") + db.append_message("solo", role="assistant", content="hello") + + assert db.get_ancestor_display_prefix("solo") == [] + + def test_get_ancestor_display_prefix_returns_ancestor_only_messages(self, db): + """The prefix contains ONLY ancestor messages, not tip messages. + + Previously the prefix was calculated as + display_history[:len(display) - len(raw)], which overcounts when + repair_message_sequence removes messages from the MIDDLE of the + tip history — the length difference includes both ancestor messages + AND repair-removed tip messages, but the slice captures the first N + display messages (tip messages when there are no ancestors), + causing duplication in _live_session_payload. (#65919) + """ + db.create_session("root", "tui") + db.append_message("root", role="user", content="ancestor prompt") + db.append_message("root", role="assistant", content="ancestor reply") + db.create_session("child", "tui", parent_session_id="root") + db.append_message("child", role="user", content="tip prompt") + db.append_message("child", role="assistant", content="tip reply") + # A verification candidate that repair_message_sequence collapses + # (consecutive-assistant merge replaces it with the next assistant). + db.append_message( + "child", + role="assistant", + content="verification candidate", + finish_reason="verification_required", + ) + db.append_message("child", role="assistant", content="post-verification reply") + + prefix = db.get_ancestor_display_prefix("child") + # Only the ancestor messages, not any tip messages. + assert len(prefix) == 2 + assert prefix[0]["role"] == "user" + assert prefix[0]["content"] == "ancestor prompt" + assert prefix[1]["role"] == "assistant" + assert prefix[1]["content"] == "ancestor reply" + + # The old broken calculation would produce a non-empty prefix + # (because repair collapses the verification candidate, making + # len(display) > len(raw)), even though there are 2 ancestor + # messages — it would overcount. + raw, display = db.get_resume_conversations("child") + old_prefix_len = max(0, len(display) - len(raw)) + assert len(prefix) <= old_prefix_len + def test_finish_reason_stored(self, db): db.create_session(session_id="s1", source="cli") db.append_message("s1", role="assistant", content="Done", finish_reason="stop") diff --git a/tests/test_live_system_guard_self_test.py b/tests/test_live_system_guard_self_test.py index 3bbe8c9f3b0c..0347d8510014 100644 --- a/tests/test_live_system_guard_self_test.py +++ b/tests/test_live_system_guard_self_test.py @@ -20,6 +20,7 @@ from __future__ import annotations import os import signal import subprocess +import types import pytest @@ -28,6 +29,79 @@ import pytest FOREIGN_PID = 1 +# ──────────────────── fail-closed self-protection ────────────── +# +# This file executes REAL kill primitives — os.kill(-1, SIGTERM), os.killpg, +# pkill -f python — and depends entirely on the autouse ``_live_system_guard`` +# fixture in tests/conftest.py to intercept them. That makes the canary +# fail-OPEN: in any collection context where this file is present but its home +# conftest is not, the primitives fire for real and ``os.kill(-1, SIGTERM)`` +# SIGTERMs every process the invoking user owns (a full desktop-session kill was +# reported in the field — see issue #68311). Such contexts are not exotic: +# published sdists that ship ``tests/`` but not ``tests/conftest.py``, trees +# assembled by copying ``test*.py`` files (that glob does NOT match +# ``conftest.py``), ``pytest --noconftest``, or running from a foreign rootdir. +# +# The fixture below makes the canary fail-CLOSED instead: it refuses to run any +# test in this file unless the guard is provably active, so no collection +# context can ever detonate the primitives. The one thing the canary can detect +# about its own safety is that the guard monkeypatches ``os.kill`` with a plain +# Python function, whereas the unguarded primitive is a C builtin. + + +def _live_system_guard_is_active() -> bool: + """True iff tests/conftest.py's ``_live_system_guard`` has patched os.kill. + + The guard replaces ``os.kill`` with a plain Python function; the raw, + unguarded primitive is a C builtin (``types.BuiltinFunctionType``). If + ``os.kill`` is still the builtin, the guard never loaded and every kill + primitive in this file would fire for real. + """ + return not isinstance(os.kill, types.BuiltinFunctionType) + + +@pytest.fixture(autouse=True) +def _refuse_to_fire_live_weapons(request): + """Fail closed: refuse to run a canary test unless the guard is active. + + Tests genuinely marked ``@pytest.mark.live_system_guard_bypass`` opt out + (they run the raw primitive deliberately and harmlessly, e.g. a signal-0 + liveness probe of our own PID), matching the guard's own bypass contract. + """ + if request.node.get_closest_marker("live_system_guard_bypass"): + yield + return + if not _live_system_guard_is_active(): + pytest.fail( + "REFUSING TO RUN: the live-system guard from tests/conftest.py is " + "not active in this interpreter (os.kill is still the raw C " + "builtin). This canary file executes real kill primitives — " + "os.kill(-1, SIGTERM), os.killpg, pkill -f python — and relies on " + "the guard to intercept them; unguarded, they SIGTERM every process " + "the current user owns. This usually means the file was collected " + "without its home tests/conftest.py (note: a test*.py copy glob " + "does NOT match conftest.py). See issue #68311.", + pytrace=False, + ) + yield + + +def test_fail_closed_probe_reports_guard_active(): + """In the real suite the guard is loaded, so the probe reports active and + ``_refuse_to_fire_live_weapons`` stays out of the way (no false positives + that would wedge CI).""" + assert _live_system_guard_is_active() is True + + +def test_fail_closed_probe_classifies_raw_builtin_as_unguarded(): + """The probe's discriminator, exercised against real objects: a raw C + builtin the guard never touches (``os.getpid``) is exactly what an + unguarded ``os.kill`` looks like and must read as 'guard not active', while + the loaded guard's ``os.kill`` is a plain Python function.""" + assert isinstance(os.getpid, types.BuiltinFunctionType) + assert not isinstance(os.kill, types.BuiltinFunctionType) + + # ──────────────────── kill primitives ───────────────────────── diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6b9cd1b140ea..da815f9979c1 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -1418,6 +1418,9 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch): self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False): captured.setdefault("history_calls", []).append((target, include_ancestors)) return ( @@ -1555,6 +1558,9 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch): self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False): return [{"role": "user", "content": "hello"}] @@ -1621,6 +1627,9 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path): self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, _target, include_ancestors=False, repair_alternation=False): return [{"role": "user", "content": "hello"}] @@ -5678,6 +5687,9 @@ def test_slash_exec_r7_read_commands_use_metadata_mirror_flag_on(monkeypatch): self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, key, include_ancestors=True, repair_alternation=False): assert key == "session-key" assert include_ancestors is True diff --git a/tests/tools/test_web_providers_ddgs.py b/tests/tools/test_web_providers_ddgs.py index 5166224bf0f8..459f3d835aa8 100644 --- a/tests/tools/test_web_providers_ddgs.py +++ b/tests/tools/test_web_providers_ddgs.py @@ -4,6 +4,7 @@ Covers: - DDGSWebSearchProvider.is_available() — reflects package importability - DDGSWebSearchProvider.search() — happy path, missing package, runtime error - Result normalization (title, url, description, position) +- Process-isolated timeout / interrupt / GIL-hold / reap (#68096) - _is_backend_available("ddgs") / _get_backend() integration - web_extract returns a search-only error when ddgs is active """ @@ -11,6 +12,7 @@ from __future__ import annotations import json import sys +import time import types import pytest @@ -52,6 +54,21 @@ def _install_fake_ddgs(monkeypatch, *, text_results=None, text_raises=None, text return fake +def _force_inprocess_search(monkeypatch, prov): + """Route bounded search through the in-process helper. + + Happy-path unit tests install a fake ``ddgs`` in the parent interpreter; + spawn workers would not see that fake. Isolation behavior is covered by + dedicated process tests below. + """ + monkeypatch.setattr( + prov, + "_run_ddgs_search_bounded", + lambda query, safe_limit: prov._run_ddgs_search(query, safe_limit), + raising=True, + ) + + # --------------------------------------------------------------------------- # DDGSWebSearchProvider unit tests # --------------------------------------------------------------------------- @@ -98,9 +115,10 @@ class TestDDGSProviderSearch: {"title": "B", "href": "https://b.example.com", "body": "desc B"}, {"title": "C", "href": "https://c.example.com", "body": "desc C"}, ]) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=5) + result = prov.DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is True web = result["data"]["web"] @@ -112,9 +130,10 @@ class TestDDGSProviderSearch: _install_fake_ddgs(monkeypatch, text_results=[ {"title": "A", "url": "https://a.example.com", "body": "desc A"}, ]) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=5) + result = prov.DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is True assert result["data"]["web"][0]["url"] == "https://a.example.com" @@ -124,9 +143,10 @@ class TestDDGSProviderSearch: {"title": f"R{i}", "href": f"https://r{i}.example.com", "body": ""} for i in range(10) ]) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=3) + result = prov.DDGSWebSearchProvider().search("q", limit=3) assert result["success"] is True assert len(result["data"]["web"]) == 3 @@ -151,54 +171,42 @@ class TestDDGSProviderSearch: def test_runtime_error_returns_failure(self, monkeypatch): _install_fake_ddgs(monkeypatch, text_raises=RuntimeError("rate limited 202")) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=5) + result = prov.DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is False assert "rate limited" in result["error"] or "failed" in result["error"].lower() def test_empty_results(self, monkeypatch): _install_fake_ddgs(monkeypatch, text_results=[]) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("nothing", limit=5) + result = prov.DDGSWebSearchProvider().search("nothing", limit=5) assert result["success"] is True assert result["data"]["web"] == [] + @pytest.mark.live_system_guard_bypass def test_hung_search_times_out_and_returns_failure(self, monkeypatch): - """#36776: a ddgs call that never returns must be bounded by the - wall-clock timeout and surface a failure instead of hanging the - shared agent loop. We patch the blocking helper to wait on an Event - (released in finally so no worker thread leaks past the test) and - shrink the timeout; search() must return success=False promptly.""" - import threading - import time - - # ddgs must import-probe True for search() to proceed. + """#36776 / #68096: a hung worker must be bounded by the wall-clock + timeout and reaped — even when the child never returns to Python.""" _install_fake_ddgs(monkeypatch) - monkeypatch.delitem(sys.modules, "plugins.web.ddgs.provider", raising=False) - import plugins.web.ddgs.provider as _prov + import plugins.web.ddgs.provider as prov - release = threading.Event() + monkeypatch.setattr(prov, "_test_hook", "sleep", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 0.4, raising=True) + monkeypatch.setattr(prov, "_TERMINATE_GRACE_SECS", 0.5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) - def _blocking_search(query, safe_limit): - release.wait(timeout=10) # bounded so the worker can never truly leak - return [] + start = time.monotonic() + result = prov.DDGSWebSearchProvider().search("hangs forever", limit=5) + elapsed = time.monotonic() - start - monkeypatch.setattr(_prov, "_run_ddgs_search", _blocking_search, raising=True) - monkeypatch.setattr(_prov, "_SEARCH_TIMEOUT_SECS", 0.3, raising=True) - - try: - start = time.monotonic() - result = _prov.DDGSWebSearchProvider().search("hangs forever", limit=5) - elapsed = time.monotonic() - start - - assert result["success"] is False - assert "timed out" in result["error"].lower() - # Returned well before the worker's 10s wait — proves the cap fired. - assert elapsed < 3.0, f"search did not return promptly ({elapsed:.1f}s)" - finally: - release.set() # let the orphaned worker finish immediately + assert result["success"] is False + assert "timed out" in result["error"].lower() + assert elapsed < 5.0, f"search did not return promptly ({elapsed:.1f}s)" + _assert_worker_reaped(prov) def test_fast_search_not_affected_by_timeout_wrapper(self, monkeypatch): """Happy-path guard: the timeout wrapper must not break a normal, @@ -207,14 +215,115 @@ class TestDDGSProviderSearch: monkeypatch, text_results=[{"title": "T", "href": "https://e.com", "body": "B"}], ) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=5) + result = prov.DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is True assert result["data"]["web"][0]["url"] == "https://e.com" assert result["data"]["web"][0]["title"] == "T" +# --------------------------------------------------------------------------- +# Process isolation (#68096) +# --------------------------------------------------------------------------- + + +def _assert_worker_reaped(prov) -> None: + """Assert the last DDGS worker process has exited.""" + proc = prov._last_worker_proc + assert proc is not None, "expected a DDGS worker process to have been started" + assert proc.poll() is not None, ( + f"DDGS worker still alive (pid={proc.pid}, returncode={proc.returncode})" + ) + + +@pytest.mark.live_system_guard_bypass +class TestDDGSProcessIsolation: + def test_gil_holding_worker_times_out_and_is_reaped(self, monkeypatch): + """#68096: parent deadline still fires when the child holds its GIL.""" + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + monkeypatch.setattr(prov, "_test_hook", "gil", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 0.5, raising=True) + monkeypatch.setattr(prov, "_TERMINATE_GRACE_SECS", 0.5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + start = time.monotonic() + result = prov.DDGSWebSearchProvider().search("gil hold", limit=5) + elapsed = time.monotonic() - start + + assert result["success"] is False + assert "timed out" in result["error"].lower() + assert elapsed < 5.0, f"GIL-hold search did not time out promptly ({elapsed:.1f}s)" + _assert_worker_reaped(prov) + + def test_interrupt_terminates_worker_promptly(self, monkeypatch): + """TUI/gateway interrupt must kill the DDGS child before the deadline.""" + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + # Flip interrupt after the first poll so the wait loop observes it. + calls = {"n": 0} + + def _interrupt_after_poll(): + calls["n"] += 1 + return calls["n"] >= 2 + + monkeypatch.setattr(prov, "_test_hook", "sleep", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 30, raising=True) + monkeypatch.setattr(prov, "_TERMINATE_GRACE_SECS", 0.5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", _interrupt_after_poll) + + start = time.monotonic() + result = prov.DDGSWebSearchProvider().search("interrupt me", limit=5) + elapsed = time.monotonic() - start + + assert result["success"] is False + assert "interrupted" in result["error"].lower() + assert elapsed < 5.0, f"interrupt did not return promptly ({elapsed:.1f}s)" + _assert_worker_reaped(prov) + + def test_spawned_worker_success_envelope(self, monkeypatch): + """Real spawn path: success envelope round-trips through the pipe.""" + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + monkeypatch.setattr(prov, "_test_hook", "success", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = prov.DDGSWebSearchProvider().search("q", limit=5) + assert result["success"] is True + assert result["data"]["web"][0]["url"] == "https://example.com" + _assert_worker_reaped(prov) + + def test_spawned_worker_error_envelope(self, monkeypatch): + """Real spawn path: error envelope becomes success=False.""" + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + monkeypatch.setattr(prov, "_test_hook", "error", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = prov.DDGSWebSearchProvider().search("q", limit=5) + assert result["success"] is False + assert "boom" in result["error"] + _assert_worker_reaped(prov) + + def test_no_orphan_after_successful_search(self, monkeypatch): + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + monkeypatch.setattr(prov, "_test_hook", "empty", raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = prov.DDGSWebSearchProvider().search("q", limit=5) + assert result["success"] is True + _assert_worker_reaped(prov) + # --------------------------------------------------------------------------- # Integration: _is_backend_available / _get_backend / check_web_api_key # --------------------------------------------------------------------------- diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 4fcce61e2dce..2b51d785e103 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -352,6 +352,9 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch): self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False): return [ {"role": "user", "content": "hello"}, @@ -418,6 +421,9 @@ def test_session_resume_defaults_to_deferred_build(server, monkeypatch): self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False): return [ {"role": "user", "content": "hello"}, @@ -565,6 +571,9 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch): self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False): return [multimodal_user, text_only_assistant] @@ -621,6 +630,9 @@ def test_session_resume_lazy_registers_watch_session_without_agent(server, monke self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False): return [ {"role": "user", "content": "delegated goal"}, @@ -700,6 +712,9 @@ def test_session_resume_lazy_reports_running_for_inflight_child(server, monkeypa self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False): return [{"role": "user", "content": "delegated goal"}] @@ -757,6 +772,9 @@ def test_session_resume_lazy_tolerates_missing_row_for_active_child(server, monk self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False): # No rows for an unwritten session. return [] @@ -860,6 +878,9 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch): self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return [] + def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False): return [ {"role": "user", "content": "hello"}, @@ -1083,6 +1104,9 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server, self.get_messages_as_conversation(session_id, include_ancestors=True), ) + def get_ancestor_display_prefix(self, _sid): + return list(ancestor_history) + def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False): if include_ancestors: return ancestor_history + current_history diff --git a/tui_gateway/server.py b/tui_gateway/server.py index bdf1f4d672c5..49b97fd8f3e8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6345,7 +6345,7 @@ def _(rid, params: dict) -> dict: # Display keeps the full transcript; the model-fed history drops a # dangling/interrupted tool-call tail so a session killed mid-loop does # not replay the unanswered call forever (#29086). - prefix = display_history[: max(0, len(display_history) - len(raw_history))] + prefix = db.get_ancestor_display_prefix(target) history = sanitize_replay_history(raw_history) # Restore the model/provider/reasoning/tier this chat last used so the # deferred build (and the info below) match the eager path — without them @@ -6421,9 +6421,7 @@ def _(rid, params: dict) -> dict: # re-issue the unanswered call forever — the permanent-"thinking" stuck # session in #29086. The messaging gateway already strips this; this is # the WebUI/TUI resume path picking up the same cleanup. - display_history_prefix = display_history[ - : max(0, len(display_history) - len(raw_history)) - ] + display_history_prefix = db.get_ancestor_display_prefix(target) history = sanitize_replay_history(raw_history) messages = _history_to_messages(display_history) tokens = _set_session_context(target) @@ -8138,7 +8136,7 @@ def _(rid, params: dict) -> dict: # =========================================================================== -# Phase 2b terminal billing RPC methods +# Phase 2b Remote Spending RPC methods # =========================================================================== # # These return STRUCTURED success envelopes (result.ok / result.error) rather diff --git a/ui-tui/README.md b/ui-tui/README.md index fe5ab7c8db1d..5a2094a3184f 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -96,7 +96,7 @@ npm run test:watch - `types.ts` — `SlashCommand` interface and `SlashRunCtx` execution context (gateway rpc, transcript helpers, session refs, stale-guard) - `registry.ts` — assembles `SLASH_COMMANDS` from all command files in registration order (core → billing → credits → session → ops → setup → debug) and exposes `findSlashCommand(name)` for case-insensitive lookup - `commands/core.ts` — general TUI commands -- `commands/billing.ts` — `/billing`: manage Nous terminal billing — buy credits, auto-reload, limits +- `commands/billing.ts` — `/billing`: manage Nous remote spending — buy credits, auto-reload, limits - `commands/credits.ts` — `/credits` - `commands/session.ts` — session and agent commands - `commands/ops.ts` — operations commands @@ -231,7 +231,7 @@ The following commands are handled directly by the TUI client. Unrecognized comm `/status`, `/title`, `/fortune`, `/redraw`, `/terminal-setup` ### Billing (`billing.ts`) -`/billing` — manage Nous terminal billing — buy credits, auto-reload, limits +`/billing` — manage Nous remote spending — buy credits, auto-reload, limits ### Session (`session.ts`) `/model`, `/sessions` (aliases `/switch`, `/session`, `/resume`), @@ -366,7 +366,7 @@ ui-tui/ types.ts SlashCommand interface and SlashRunCtx execution context registry.ts SLASH_COMMANDS assembly and findSlashCommand lookup commands/ - billing.ts /billing — manage Nous terminal billing + billing.ts /billing — manage Nous remote spending core.ts general TUI commands credits.ts /credits debug.ts /heapdump, /mem diff --git a/ui-tui/scripts/billing-fixtures.tsx b/ui-tui/scripts/billing-fixtures.tsx index f55eb725db14..53bcbeb9a8dc 100644 --- a/ui-tui/scripts/billing-fixtures.tsx +++ b/ui-tui/scripts/billing-fixtures.tsx @@ -42,11 +42,13 @@ const tier = (o: Partial = {}): SubscriptionTierOption = ...o }) +// Mirrors the live portal catalog so fixtures don't drift; the real overlay +// reads tiers from GET /api/billing/subscription, never from here. const TIERS = { free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }), - plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }), - super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }), - ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' }) + plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '22' }), + super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$100', monthly_credits: '110' }), + ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$200', monthly_credits: '220' }) } const tierList = (currentId?: string): SubscriptionTierOption[] => @@ -205,7 +207,7 @@ const FIXTURES: Record = { node: billEl(billState({ is_admin: false })) }, 'topup-disabled': { - desc: '/topup overview — terminal billing OFF for org', + desc: '/topup overview — remote spending OFF for org', node: billEl(billState({ cli_billing_enabled: false })) }, 'topup-buy': { diff --git a/ui-tui/src/__tests__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx index 8c4ee1542dfb..4c54c9949541 100644 --- a/ui-tui/src/__tests__/billingStepUp.test.tsx +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -93,11 +93,11 @@ const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => state: billState() }) -describe('BillingOverlay — step-up screen (Enable terminal billing)', () => { +describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => { it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => { const out = render(overlay('stepup')) expect(out).toContain('One-time setup') - expect(out).toContain('Enable terminal billing') + expect(out).toContain('Allow Remote Spending') expect(out).toContain('$100') // resumes the held purchase expect(out).toContain('Not now') expect(out).not.toContain('billing:manage') @@ -112,8 +112,8 @@ describe('BillingOverlay — overview (reordered, dollars)', () => { expect(out).toContain('Auto-reload') expect(out).toContain('Manage on portal') expect(out.toLowerCase()).not.toContain('credits') // dollars only - // No standalone "Enable terminal billing" item — discovered at pay time. - expect(out).not.toContain('Enable terminal billing') + // No standalone "Allow Remote Spending" item — discovered at pay time. + expect(out).not.toContain('Allow Remote Spending') }) it('renders the two-bar dollar usage when a usage model is present', () => { diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index eaf22e847f68..4a5a5ed21a16 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -149,6 +149,39 @@ describe('SubscriptionOverlay — overview', () => { expect(out.toLowerCase()).not.toContain('credits') }) + it('free with catalog: plans render inline; the generic portal row disappears', () => { + const out = render(overlay(freeWithCatalog())) + + expect(out).toContain('Plus · $20/mo · $1,000 credits/mo') + expect(out).toContain('Ultra · $40/mo · $3,000 credits/mo') + expect(out).not.toContain('upgrade') // a start, not a move + expect(out).not.toContain('$0/mo') // free tier is not an option + expect(out).not.toContain('Choose a plan') + expect(out).not.toContain('Start a subscription') + }) + + it('free with catalog: picking a plan opens the portal once, even on double-Enter', async () => { + const openManageLink = vi.fn(() => Promise.resolve(true)) + const preview = vi.fn(() => Promise.resolve(null)) + const sys = vi.fn() + + const mounted = mount({ + ctx: { ...ctx, openManageLink, preview, sys } as SubscriptionOverlayState['ctx'], + screen: 'overview', + state: freeWithCatalog() + }) + + inputHarness.handler?.('', { return: true }) // first row = Plus + inputHarness.handler?.('', { return: true }) + await vi.waitFor(() => expect(openManageLink).toHaveBeenCalled()) + mounted.cleanup() + + expect(openManageLink).toHaveBeenCalledTimes(1) + expect(preview).not.toHaveBeenCalled() + // openManageLink narrates the handoff itself. + expect(sys).not.toHaveBeenCalled() + }) + it('subscriber: status line + plan bar + top-up bar, no "credits"', () => { const out = render( overlay( @@ -318,6 +351,14 @@ const at = ( extra: Partial = {} ): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra }) +// Free account (no current sub) where NAS still returns the tier catalog. +const freeWithCatalog = (): SubscriptionStateResponse => + state({ + current: null, + tiers: TIERS.map(tier => ({ ...tier, is_current: false })), + usage: { available: true, plan_name: null, status: 'free' } + }) + describe('SubscriptionOverlay — overview actions', () => { it('admin subscriber: offers Change plan + Cancel subscription', () => { const out = render(overlay(subscriber())) @@ -353,11 +394,11 @@ describe('SubscriptionOverlay — overview actions', () => { }) describe('SubscriptionOverlay — step-up', () => { - it('prompts to enable terminal billing (never leaks the raw scope)', () => { + it('prompts to allow Remote Spending (never leaks the raw scope)', () => { const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } })) - expect(out).toContain('Terminal billing') - expect(out).toContain('Enable terminal billing') + expect(out).toContain('Remote Spending') + expect(out).toContain('Allow Remote Spending') expect(out).not.toContain('billing:manage') }) }) diff --git a/ui-tui/src/__tests__/topupCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts index 636aba932fcd..809355582de1 100644 --- a/ui-tui/src/__tests__/topupCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -311,8 +311,8 @@ describe('/billing slash command (overlay-driven)', () => { // ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ── it.each([ - ['admin', 'An admin turned off terminal billing for this terminal'], - ['self', 'You turned off terminal billing for this terminal'] + ['admin', 'An admin stopped remote spending for this terminal'], + ['self', 'You stopped remote spending for this terminal'] ])( 'ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy', async (actor, copy) => { @@ -387,7 +387,7 @@ describe('/billing slash command (overlay-driven)', () => { await Promise.resolve() await Promise.resolve() const out = printed(sys) - expect(out).toContain('Terminal billing is off for this account') + expect(out).toContain('Remote spending is off for this account') // Account-wide switch is NOT a per-terminal revoke — overlay stays open. expect(getOverlayState().billing).toBeTruthy() }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d708cc8ccfbb..4906290ac4c0 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -551,7 +551,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - sys('💳 Open this link to grant terminal billing access:') + sys('💳 Open this link to allow Remote Spending:') sys(url) if (code) { @@ -948,12 +948,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'message.interim': { const text = ev.payload?.text + if (typeof text === 'string' && text.trim()) { turnController.recordInterimMessage(text) } return } + case 'message.complete': { const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {}) diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index c29d722cf452..dddad408c8e1 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -127,7 +127,7 @@ export interface BillingOverlayCtx { */ charge: (amount: string, idempotencyKey?: string) => Promise /** - * Run the `billing.step_up` device flow (grant Remote Spending). Resolves + * Run the `billing.step_up` device flow (allow Remote Spending). Resolves * `true` when the grant lands. The browser opens via the gateway's * out-of-band `billing.step_up.verification` event — the overlay just awaits. */ @@ -176,15 +176,15 @@ export interface BillingOverlayState { // scheduled at date / no-op / blocked) + the apply action. // result — the outcome, including an SCA/decline upgrade handed off to the // portal. -// stepup — reached when a mutation returns insufficient_scope: grants the -// terminal-billing scope in place, then auto-replays the held action. +// stepup — reached when a mutation returns insufficient_scope: allows remote +// spending in place, then auto-replays the held action. export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' | 'stepup' -// The action held while the stepup screen grants terminal billing, replayed on -// grant: re-preview a tier, re-apply the confirmed pending change, or re-resume. +// The action held while the stepup screen allows remote spending, replayed after +// approval: re-preview a tier, re-apply the confirmed pending change, or re-resume. export type SubscriptionStepUpRetry = { kind: 'apply' } | { kind: 'preview'; tierId: string } | { kind: 'resume' } -/** Outcome of a terminal-billing step-up: granted, plus the typed denial (for copy). */ +/** Outcome of a remote-spending step-up: granted, plus the typed denial (for copy). */ export interface StepUpResult { granted: boolean error?: string @@ -215,7 +215,7 @@ export interface SubscriptionOverlayCtx { /** POST /upgrade: charge the card on the subscription + flip the plan now. */ upgrade: (tierId: string, idempotencyKey?: string) => Promise /** - * Run the `billing.step_up` device flow (grant terminal billing / "Remote + * Run the `billing.step_up` device flow (allow remote spending / "Remote * Spending"). Resolves `{granted}` plus the typed denial (`error`/`message`) so * the stepup screen shows the right recovery. The browser opens via the * gateway's out-of-band verification event — the stepup screen just awaits. diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 77222e9e0812..b3ab23ccd388 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -37,9 +37,9 @@ const renderBillingError = ( switch (env.error) { case 'insufficient_scope': // Reached by non-charge mutations (e.g. auto-reload config) that need - // terminal billing enabled. The resumable step-up lives on the buy/charge + // Remote Spending allowed. The resumable step-up lives on the buy/charge // path; point the user there rather than leaking the raw scope name. - sys('This needs terminal billing enabled. Start a top-up to enable it, then retry.') + sys('This needs Remote Spending allowed. Start a top-up to allow it, then retry.') break case 'remote_spending_revoked': { @@ -49,8 +49,8 @@ const renderBillingError = ( const who = env.actor === 'admin' - ? 'An admin turned off terminal billing for this terminal.' - : 'You turned off terminal billing for this terminal.' + ? 'An admin stopped remote spending for this terminal.' + : 'You stopped remote spending for this terminal.' sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`) @@ -67,9 +67,11 @@ const renderBillingError = ( case 'cli_billing_disabled': case 'remote_spending_disabled': - // Account-wide switch is OFF (dual-emitted error/code). An admin must flip - // it on the portal; this is NOT a per-terminal revoke. - sys('Terminal billing is off for this account — an admin must enable it on the portal.') + // Account-wide switch is OFF (dual-emitted error/code). A billing admin can + // turn it on from the portal's Hermes Agent page; this is NOT a per-terminal stop. + sys( + "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page." + ) break diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 63acc70276b9..a8afea727d99 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -555,7 +555,12 @@ class TurnController { this.flushPendingNotice() } - recordMessageComplete(payload: { rendered?: string; reasoning?: string; response_previewed?: boolean; text?: string }) { + recordMessageComplete(payload: { + rendered?: string + reasoning?: string + response_previewed?: boolean + text?: string + }) { this.closeReasoningSegment() // Ink renders markdown via ; the gateway's Rich-rendered ANSI @@ -687,6 +692,7 @@ class TurnController { } const authoritativeText = text.trimStart() + if (!authoritativeText) { return } diff --git a/ui-tui/src/app/useSubmission.test.ts b/ui-tui/src/app/useSubmission.test.ts new file mode 100644 index 000000000000..34202104dd47 --- /dev/null +++ b/ui-tui/src/app/useSubmission.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import type { PasteSnippet } from './interfaces.js' +import { expandSnips } from './useSubmission.js' + +const snip = (label: string, text: string): PasteSnippet => ({ label, text }) + +describe('expandSnips (paste history recall)', () => { + it('replaces a collapsed paste label with its full content', () => { + const label = '[[ hello.. [3 lines] .. world ]]' + const full = `here: ${label} done` + const expand = expandSnips([snip(label, 'hello\nfoo\nworld')]) + + expect(expand(full)).toBe('here: hello\nfoo\nworld done') + }) + + it('is a no-op for already-expanded / label-free text (recall round-trip)', () => { + const expanded = 'hello\nfoo\nworld' + // Re-submitting a recalled history entry has no snips and no labels. + expect(expandSnips([])(expanded)).toBe(expanded) + }) + + it('expands repeated identical labels in submission order', () => { + const label = '[[ x [1 lines] ]]' + const expand = expandSnips([snip(label, 'first'), snip(label, 'second')]) + + expect(expand(`${label} then ${label}`)).toBe('first then second') + }) + + it('leaves an unmatched label intact', () => { + const label = '[[ orphan [2 lines] ]]' + expect(expandSnips([])(label)).toBe(label) + }) +}) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 881257e386f6..a5c484cc288c 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -16,7 +16,7 @@ import { getUiState, patchUiState } from './uiStore.js' const DOUBLE_ENTER_MS = 450 -const expandSnips = (snips: PasteSnippet[]) => { +export const expandSnips = (snips: PasteSnippet[]) => { const byLabel = new Map() for (const { label, text } of snips) { @@ -217,9 +217,14 @@ export function useSubmission(opts: UseSubmissionOptions) { return } + // History stores expanded paste content, not the `[[…]]` label: snips + // are cleared on submit, so recall must be self-contained. Idempotent on + // label-free text, so re-submitting a recalled entry stays stable. + const toHistory = expandSnips(composerState.pasteSnips)(full) + if (looksLikeSlashCommand(full)) { appendMessage({ kind: 'slash', role: 'system', text: full }) - composerActions.pushHistory(full) + composerActions.pushHistory(toHistory) slashRef.current(full) composerActions.clearIn() @@ -235,7 +240,7 @@ export function useSubmission(opts: UseSubmissionOptions) { const live = getUiState() if (!live.sid) { - composerActions.pushHistory(full) + composerActions.pushHistory(toHistory) composerActions.enqueue(full) composerActions.clearIn() @@ -271,7 +276,7 @@ export function useSubmission(opts: UseSubmissionOptions) { return sendQueued(picked) } - composerActions.pushHistory(full) + composerActions.pushHistory(toHistory) if (getUiState().busy) { return handleBusyInput(full) @@ -285,7 +290,18 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef] + [ + appendMessage, + composerActions, + composerRefs, + composerState.pasteSnips, + handleBusyInput, + interpolate, + send, + sendQueued, + shellExec, + slashRef + ] ) const submit = useCallback( diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 984915f79075..c5577a5c6cf1 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -83,7 +83,7 @@ interface ScreenProps { function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { // Full charge menu only for an admin with the org kill-switch on; otherwise it // collapses to Manage-on-portal / Close + a one-line note. NOTE: this is the - // ORG-level gate (cli_billing_enabled), NOT the per-terminal billing scope — + // ORG-level gate (cli_billing_enabled), NOT the per-terminal remote spending scope — // that's discovered reactively at pay time (a charge 403s insufficient_scope // and the confirm screen routes into the resumable step-up). We deliberately // do NOT preflight the scope here. @@ -92,7 +92,7 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const note = !s.is_admin ? 'Billing actions need someone with billing permissions (owner, admin, or finance admin).' : !s.cli_billing_enabled - ? 'Terminal billing is off for this org — manage it on the portal.' + ? "Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page." : null // Always show the full billing menu for an admin/billing-on org — a missing @@ -495,14 +495,14 @@ function ConfirmScreen({ ) } -// ── Screen: Step-up (resumable "Enable terminal billing") ──────────── +// ── Screen: Step-up (resumable "Allow Remote Spending") ───────────── // Reached ONLY when a charge returns insufficient_scope — there is no preflight // or scope check anywhere; the buy path discovers it reactively. The modal stays // MOUNTED through the browser device-flow: // prompt (heads-up) → waiting (browser authorize) → granted (press Enter to // resume) → replay the held charge (pendingCharge.amount) → settle → close. // Never leaks the raw billing:manage scope — the user-facing concept is -// "terminal billing". +// "Remote Spending". function StepUpScreen({ amount, @@ -526,12 +526,12 @@ function StepUpScreen({ } setPhase('waiting') - ctx.sys('Opening your browser to enable terminal billing…') + ctx.sys('Opening your browser to allow Remote Spending…') void ctx.requestRemoteSpending().then(granted => { if (!granted) { ctx.sys( - "! Couldn't enable terminal billing — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged." + "! Couldn't allow Remote Spending — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged." ) onClose() @@ -550,12 +550,12 @@ function StepUpScreen({ } setPhase('resuming') - ctx.sys('✓ Terminal billing enabled — resuming your purchase.') + ctx.sys('✓ Remote Spending allowed — resuming your purchase.') void ctx.charge(amount, idempotencyKey).then(outcome => { // If the replay STILL can't spend (grant raced/expired or downscoped), // say so — don't close on a reassuring line with no charge made. if (outcome === 'needs_remote_spending') { - ctx.sys('! Terminal billing still needs approval — run /topup to try again. Your card was not charged.') + ctx.sys('! Remote Spending still needs approval — run /topup to try again. Your card was not charged.') } onClose() @@ -563,7 +563,7 @@ function StepUpScreen({ } const decline = () => { - ctx.sys('No charge made. Run /topup when you want to enable terminal billing.') + ctx.sys('No charge made. Run /topup when you want to allow Remote Spending.') onClose() } @@ -622,7 +622,7 @@ function StepUpScreen({ return ( - Enable terminal billing + Allow Remote Spending Waiting for your browser… Approve in the page that just opened. @@ -637,7 +637,7 @@ function StepUpScreen({ return ( - Terminal billing enabled + Remote Spending allowed Your ${amount} top-up is ready to finish. @@ -652,7 +652,7 @@ function StepUpScreen({ return ( - Enable terminal billing + Allow Remote Spending Resuming your ${amount} top-up… @@ -667,12 +667,12 @@ function StepUpScreen({ One-time setup - To charge this terminal, enable terminal billing once. + To charge from this terminal, allow Remote Spending once. It opens your browser to authorize, then your ${amount} top-up picks up right here. - + {footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)} diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 6de7ceeaa6f4..60c2b44dc114 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -29,7 +29,7 @@ interface SubscriptionOverlayProps { /** * The /subscription modal — an in-terminal plan-change flow (V3). A small state * machine: overview → picker → confirm → result, with a stepup screen spliced in - * when a mutation needs terminal billing. Downgrades / cancellations / resume are + * when a mutation needs remote spending. Downgrades / cancellations / resume are * chargeless; an upgrade charges the card on the subscription, and an SCA/decline * is handed off to the portal. Starting a NEW subscription still deep-links (needs * a fresh card). All RPCs live in subscription.ts, reached via `overlay.ctx`. @@ -162,7 +162,7 @@ function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: nu return errorResult(r) } -/** Map a failed terminal-billing step-up to the right recovery copy (typed). */ +/** Map a failed remote-spending step-up to the right recovery copy (typed). */ function stepUpDenialResult(res: { error?: string; message?: string }): SubscriptionResult { if (res.error === 'session_revoked') { return { message: 'Your session expired — run /portal to log in again, then retry the change.', ok: false } @@ -170,8 +170,7 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip if (res.error === 'remote_spending_revoked') { return { - message: - res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.', + message: res.message || 'Remote spending was stopped for this terminal — reconnect from the portal, then retry.', ok: false } } @@ -183,7 +182,7 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip return { message: res.message || - 'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.', + 'Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal.', ok: false } } @@ -196,7 +195,8 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip // Post-grant replays pass allowStepUp=false and surface this instead (mirrors the // CLI's allow_stepup=False cap). const scopeStillDeniedResult: SubscriptionResult = { - message: 'Terminal billing still isn’t enabled for this org — enable it on the portal, then retry.', + message: + 'Remote Spending still isn’t active for this terminal — the authorization didn’t take. Retry, or make this change on the portal.', ok: false } @@ -375,6 +375,12 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { // portal enforces who can act (members) / starting a new sub needs a card. const canChange = s.can_change_plan && !isFree + // On Free the catalog renders inline; picking a plan hands off to the portal, + // where starting a subscription needs card capture + checkout. + const freePlans = isFree + ? s.tiers.filter(tier => tier.is_enabled && tier.tier_order > 0).sort((a, b) => a.tier_order - b.tier_order) + : [] + // Guard the async resume so a double-press cannot fire two DELETEs mid-await. const busyRef = useRef(false) @@ -422,7 +428,31 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { } } - rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage }) + for (const tier of freePlans) { + // NAS sends a bare decimal string; tolerate pre-grouped ("1,000") too. + const credits = Number((tier.monthly_credits ?? '').replace(/,/g, '')) + const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : '' + + rows.push({ + label: `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}`, + run: () => { + if (busyRef.current) { + return + } + + busyRef.current = true + void ctx.openManageLink() + onClose() + } + }) + } + + // The inline plan rows are the subscribe path; only a catalog-less free state + // still needs the generic portal row. + if (!isFree || freePlans.length === 0) { + rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage }) + } + rows.push({ label: 'Close', run: onClose }) const sel = useMenu(rows, onClose) @@ -818,7 +848,7 @@ function ResultScreen({ onClose, overlay, t }: Omit) { ) } -// ── Screen: Step-up (grant terminal billing inline, then replay) ────── +// ── Screen: Step-up (allow remote spending inline, then replay) ─────── function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { const { ctx } = overlay @@ -908,7 +938,7 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { ] : phase === 'prompt' ? [ - { color: t.color.ok, label: 'Enable terminal billing', run: enable }, + { color: t.color.ok, label: 'Allow Remote Spending', run: enable }, { label: 'Cancel', run: back } ] : [] @@ -918,12 +948,12 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { return ( - Terminal billing + Remote Spending {phase === 'prompt' && ( <> - Changing your plan needs terminal billing enabled for this org. Enable it here, then continue. + Changing your plan needs Remote Spending allowed for this terminal. Allow it here, then continue. Someone with billing permissions (owner, admin, or finance admin) approves it once in the browser. @@ -935,7 +965,7 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { Opening your browser to approve… finish there, then come back — nothing is charged until you continue. )} - {phase === 'granted' && Terminal billing enabled. Continue to finish your change.} + {phase === 'granted' && Remote Spending allowed. Continue to finish your change.} {phase === 'resuming' && Applying your change…} {rows.map((row, i) => ( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 7fa2c34b37ae..49d1a6e8b00c 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -45,7 +45,7 @@ export interface SlashExecResponse { warning?: string } -// ── Terminal billing (Phase 2b) ────────────────────────────────────── +// ── Remote Spending (Phase 2b) ─────────────────────────────────────── // Wire shapes now live in @hermes/shared for reuse by TypeScript clients. export type { diff --git a/uv.lock b/uv.lock index 6665ef1a397e..13a9912aa785 100644 --- a/uv.lock +++ b/uv.lock @@ -1516,7 +1516,7 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.18.2" +version = "0.19.0" source = { editable = "." } dependencies = [ { name = "certifi" }, diff --git a/website/docs/guides/run-hermes-with-nous-portal.md b/website/docs/guides/run-hermes-with-nous-portal.md index d20295f169ad..d25a628bbf39 100644 --- a/website/docs/guides/run-hermes-with-nous-portal.md +++ b/website/docs/guides/run-hermes-with-nous-portal.md @@ -120,7 +120,7 @@ hermes config set model.default anthropic/claude-sonnet-4.6 ### Don't pick Hermes-4 for agent work -Hermes-4-70B and Hermes-4-405B are available on the Portal at deep discounts, but they're **chat/reasoning models**, not tool-call-tuned. They will struggle with multi-step agent loops. Use them via [Nous Chat](https://chat.nousresearch.com) for conversation/research work, or through the [subscription proxy](/user-guide/features/subscription-proxy) from non-agent tools. For Hermes Agent itself, stick to the frontier agentic models above. +Hermes-4-70B and Hermes-4-405B are available on the Portal at deep discounts, but they're **chat/reasoning models**, not tool-call-tuned. They will struggle with multi-step agent loops. Use them for conversation/research work through the [subscription proxy](/user-guide/features/subscription-proxy) from non-agent tools. For Hermes Agent itself, stick to the frontier agentic models above. The Portal's own [info page](https://portal.nousresearch.com/info) carries this warning too — it's the official Nous guidance, not just a Hermes-side opinion. diff --git a/website/docs/integrations/nous-portal.md b/website/docs/integrations/nous-portal.md index 46a61d759369..c47adb10734b 100644 --- a/website/docs/integrations/nous-portal.md +++ b/website/docs/integrations/nous-portal.md @@ -1,7 +1,7 @@ --- sidebar_position: 1 title: "Nous Portal" -description: "One subscription, 300+ frontier models, the Tool Gateway, and Nous Chat — the recommended way to run Hermes Agent" +description: "One subscription, 300+ frontier models, and the Tool Gateway — the recommended way to run Hermes Agent" --- # Nous Portal @@ -60,10 +60,6 @@ Without the gateway, hooking each of those up means a Firecrawl account, a FAL a You can also enable just specific gateway tools (e.g. web search but not image generation) — see [Mixing the gateway with your own backends](#mixing-the-gateway-with-your-own-backends) below. -### Nous Chat - -Your Portal account also covers [chat.nousresearch.com](https://chat.nousresearch.com) — Nous Research's web chat interface with the same model catalog. Useful when you're away from your terminal, or for non-agent conversation work. - ### No credentials in your dotfiles Because everything routes through one OAuth-authenticated Portal session, you don't accumulate a `.env` file with a dozen long-lived API keys. The refresh token at `~/.hermes/auth.json` is the only credential on disk, and Hermes mints short-lived JWTs from it per request — see [Token handling](#token-handling) below. @@ -76,7 +72,7 @@ Because everything routes through one OAuth-authenticated Portal session, you do Nous Research's own **Hermes 4** family (Hermes-4-70B, Hermes-4-405B) is available through the Portal at heavily discounted rates. These are **frontier hybrid-reasoning chat models** — strong at math, science, instruction following, schema adherence, roleplay, and long-form writing. -They are **not recommended for use inside Hermes Agent**, however. Hermes 4 is tuned for chat and reasoning, not the rapid-fire tool-calling loop the agent relies on. Use them for [Nous Chat](https://chat.nousresearch.com), for research workflows, or via the [subscription proxy](/user-guide/features/subscription-proxy) from other tooling — but for agent work, pick a frontier agentic model from the catalog instead: +They are **not recommended for use inside Hermes Agent**, however. Hermes 4 is tuned for chat and reasoning, not the rapid-fire tool-calling loop the agent relies on. Use them for research workflows or via the [subscription proxy](/user-guide/features/subscription-proxy) from other tooling — but for agent work, pick a frontier agentic model from the catalog instead: ```bash /model anthropic/claude-sonnet-4.6 # best general-purpose agentic model diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 343a056fe886..ed00d5b2a850 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -62,7 +62,7 @@ In the `model:` config section, you can use either `default:` or `model:` as the ### Nous Portal -[Nous Portal](https://portal.nousresearch.com) is Nous Research's unified subscription gateway and **the recommended way to run Hermes Agent**. One OAuth login covers 300+ frontier agentic models (Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, Grok, ...) plus the [Tool Gateway](/user-guide/features/tool-gateway) (web search, image generation, TTS, browser automation) plus [Nous Chat](https://chat.nousresearch.com) — billed against your Nous subscription instead of separate per-provider accounts. +[Nous Portal](https://portal.nousresearch.com) is Nous Research's unified subscription gateway and **the recommended way to run Hermes Agent**. One OAuth login covers 300+ frontier agentic models (Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, Grok, ...) plus the [Tool Gateway](/user-guide/features/tool-gateway) (web search, image generation, TTS, browser automation) — billed against your Nous subscription instead of separate per-provider accounts. ```bash hermes setup --portal # fresh install — OAuth + provider + gateway in one command diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 8b5786de5a55..0ca2b811fde6 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -113,7 +113,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/version` | Show Hermes Agent version, build, and environment info. | | `/usage` | Show token usage, cost breakdown, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits / plan usage pulled live from the provider's API. | | `/credits` | Show your Nous credit balance and a top-up handoff link. | -| `/billing` | CLI terminal-billing flow for Nous — view balance, buy credits, and manage auto-reload / monthly limits. | +| `/billing` | CLI Remote Spending flow for Nous — view balance, buy credits, and manage auto-reload / monthly limits. | | `/insights` | Show usage insights and analytics (last 30 days) | | `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status (CLI-only summary view). | | `/paste` | Attach a clipboard image | diff --git a/website/docs/user-guide/features/lsp.md b/website/docs/user-guide/features/lsp.md index 50df342792bb..25dce057c0ff 100644 --- a/website/docs/user-guide/features/lsp.md +++ b/website/docs/user-guide/features/lsp.md @@ -151,6 +151,12 @@ lsp: # How long to wait for diagnostics after each write. wait_mode: document # "document" or "full" + # Max seconds to wait for the server to re-check the file after an + # edit. Only *fresh* diagnostics (produced for the post-edit + # content) are ever reported; if the server doesn't finish within + # this budget, the edit reports "no LSP data" rather than stale + # errors from before the edit. Raise this for slow servers on big + # projects (tsserver, rust-analyzer mid-indexing). wait_timeout: 5.0 # How to handle missing server binaries. @@ -209,6 +215,13 @@ budget is `wait_timeout` seconds — typically the server responds in tens of milliseconds for pyright/tsserver and a few seconds for rust-analyzer mid-indexing. +Diagnostics are **freshness-gated**: a result only counts when the +server produced it for the content of the current edit (a +`publishDiagnostics` push at/after the change, or a pull request +answered after it). Slow servers that haven't re-checked yet result +in "no data" for that edit — never in yesterday's errors being +re-reported as current. + Servers are kept alive for the life of the Hermes process. There's no idle-timeout reaper — the cost of restarting the server's index on every write would be far higher than holding the daemon. diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index 75babf5cbab9..8dc73b1643ca 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -97,6 +97,7 @@ matrix: session_scope: room # auto|room|thread; room is recommended for project rooms auto_thread: true # Auto-create threads for responses (default: true) dm_mention_threads: false # Create thread when @mentioned in DM (default: false) + max_message_length: 16000 # Outbound chunk size in chars (default: 16000, max: 65535) ``` Or via environment variables: diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md index 8739d0fa3fb7..fa860d93ce86 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md @@ -120,7 +120,7 @@ hermes config set model.default anthropic/claude-sonnet-4.6 ### 不要在 agent 任务中使用 Hermes-4 -Hermes-4-70B 和 Hermes-4-405B 在 Portal 上以大幅折扣提供,但它们是**对话/推理模型**,并非针对工具调用优化的模型。它们在多步骤 agent 循环中表现不佳。请通过 [Nous Chat](https://chat.nousresearch.com) 将它们用于对话/研究工作,或通过[订阅代理](/user-guide/features/subscription-proxy)从非 agent 工具中使用。对于 Hermes Agent 本身,请坚持使用上述前沿 agentic 模型。 +Hermes-4-70B 和 Hermes-4-405B 在 Portal 上以大幅折扣提供,但它们是**对话/推理模型**,并非针对工具调用优化的模型。它们在多步骤 agent 循环中表现不佳。请通过[订阅代理](/user-guide/features/subscription-proxy)从非 agent 工具中将它们用于对话或研究工作。对于 Hermes Agent 本身,请坚持使用上述前沿 agentic 模型。 Portal 的[信息页面](https://portal.nousresearch.com/info)也有此说明——这是 Nous 官方指导,并非仅代表 Hermes 一方的意见。 @@ -270,4 +270,4 @@ hermes auth logout nous # 清除本地 refresh token - **[订阅代理](/user-guide/features/subscription-proxy)** — 在非 Hermes 工具中使用你的 Portal 订阅 - **[语音模式](/user-guide/features/voice-mode)** — 在 Portal 订阅上配置语音对话 - **[OAuth over SSH](/guides/oauth-over-ssh)** — 远程/无头主机登录方案 -- **[Profiles](/user-guide/profiles)** — 在多个 Hermes 配置之间共享一个 Portal 登录 \ No newline at end of file +- **[Profiles](/user-guide/profiles)** — 在多个 Hermes 配置之间共享一个 Portal 登录 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md index 265abb4aed10..275f77a0e84c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md @@ -1,7 +1,7 @@ --- sidebar_position: 1 title: "Nous Portal" -description: "一个订阅,300+ 前沿模型,Tool Gateway,以及 Nous Chat —— 运行 Hermes Agent 的推荐方式" +description: "一个订阅,300+ 前沿模型,以及 Tool Gateway —— 运行 Hermes Agent 的推荐方式" --- # Nous Portal @@ -56,10 +56,6 @@ Portal 代理了来自整个生态系统的精选 agentic 模型目录——统 你也可以只启用特定的 gateway 工具(例如只开启网页搜索,不开启图像生成)——详见下方[将 gateway 与自有后端混用](#mixing-the-gateway-with-your-own-backends)。 -### Nous Chat - -你的 Portal 账号同样覆盖 [chat.nousresearch.com](https://chat.nousresearch.com)——Nous Research 的网页对话界面,使用相同的模型目录。适合离开终端时使用,或用于非 agent 的普通对话场景。 - ### 凭证不落入 dotfiles 由于所有请求都通过一个经 OAuth 认证的 Portal 会话路由,你不会积累一个包含十几个长期 API 密钥的 `.env` 文件。磁盘上唯一的凭证是 `~/.hermes/auth.json` 中的 refresh token(刷新令牌),Hermes 会在每次请求时从中生成短期 JWT——详见下方[令牌处理](#token-handling)。 @@ -72,7 +68,7 @@ Portal 代理了来自整个生态系统的精选 agentic 模型目录——统 Nous Research 自家的 **Hermes 4** 系列(Hermes-4-70B、Hermes-4-405B)通过 Portal 提供,享有大幅折扣。这些是**前沿混合推理对话模型**——在数学、科学、指令遵循、schema 遵从、角色扮演和长文写作方面表现出色。 -但**不建议在 Hermes Agent 内部使用它们**。Hermes 4 针对对话和推理进行了调优,而非 agent 所依赖的高频工具调用循环。请将它们用于 [Nous Chat](https://chat.nousresearch.com)、研究工作流,或通过[订阅代理](/user-guide/features/subscription-proxy)从其他工具调用——但在 agent 场景下,请从目录中选择前沿 agentic 模型: +但**不建议在 Hermes Agent 内部使用它们**。Hermes 4 针对对话和推理进行了调优,而非 agent 所依赖的高频工具调用循环。请将它们用于研究工作流,或通过[订阅代理](/user-guide/features/subscription-proxy)从其他工具调用——但在 agent 场景下,请从目录中选择前沿 agentic 模型: ```bash /model anthropic/claude-sonnet-4.6 # 最佳通用 agentic 模型 @@ -269,4 +265,4 @@ Portal 通过 OpenRouter 代理,因此 OpenRouter 支持的所有模型通常 - **[语音模式](/user-guide/features/voice-mode)** —— 使用 Portal 的 OpenAI TTS 进行语音对话 - **[AI 提供商](/integrations/providers)** —— 完整提供商目录,供对比参考 - **[OAuth over SSH](/guides/oauth-over-ssh)** —— 从远程主机或纯浏览器环境登录 -- **[Profiles](/user-guide/profiles)** —— 多个 Hermes 配置共享一个 Portal 登录 \ No newline at end of file +- **[Profiles](/user-guide/profiles)** —— 多个 Hermes 配置共享一个 Portal 登录 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md index 68d7d5d07675..b6ea6e8dd88f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md @@ -52,7 +52,7 @@ sidebar_position: 1 ### Nous Portal -[Nous Portal](https://portal.nousresearch.com) 是 Nous Research 的统一订阅网关,也是**运行 Hermes Agent 的推荐方式**。一次 OAuth 登录即可访问 300+ 前沿智能体模型(Claude、GPT、Gemini、DeepSeek、Qwen、Kimi、GLM、MiniMax、Grok 等),以及 [Tool Gateway](/user-guide/features/tool-gateway)(网页搜索、图像生成、TTS、浏览器自动化)和 [Nous Chat](https://chat.nousresearch.com)——费用从你的 Nous 订阅中扣除,无需单独管理各提供商账户。 +[Nous Portal](https://portal.nousresearch.com) 是 Nous Research 的统一订阅网关,也是**运行 Hermes Agent 的推荐方式**。一次 OAuth 登录即可访问 300+ 前沿智能体模型(Claude、GPT、Gemini、DeepSeek、Qwen、Kimi、GLM、MiniMax、Grok 等)以及 [Tool Gateway](/user-guide/features/tool-gateway)(网页搜索、图像生成、TTS、浏览器自动化)——费用从你的 Nous 订阅中扣除,无需单独管理各提供商账户。 ```bash hermes setup --portal # 全新安装——一条命令完成 OAuth + 提供商 + 网关配置 @@ -1414,4 +1414,4 @@ fallback_model: ## 另请参阅 - [配置](/user-guide/configuration) — 通用配置(目录结构、配置优先级、终端后端、记忆、压缩等) -- [环境变量](/reference/environment-variables) — 所有环境变量的完整参考 \ No newline at end of file +- [环境变量](/reference/environment-variables) — 所有环境变量的完整参考