diff --git a/.dockerignore b/.dockerignore index ec3d52f81413..cfd0616efb84 100644 --- a/.dockerignore +++ b/.dockerignore @@ -97,9 +97,6 @@ packaging/ plans/ .plans/ -# ACP registry manifest (icon + agent.json) — not consumed at runtime -acp_registry/ - # Repo-level dotfiles that are git-only or dev-tooling config .env.example .envrc diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 0794b5ed4f8e..145d742d5b12 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -39,6 +39,9 @@ outputs: ci_review: description: Require CI-sensitive file review label. value: ${{ steps.classify.outputs.ci_review }} + ci_review_files: + description: JSON list of CI-sensitive files changed by the pull request. + value: ${{ steps.classify.outputs.ci_review_files }} runs: using: composite diff --git a/.github/actions/get-app-token/action.yml b/.github/actions/get-app-token/action.yml index 611533f28337..2aaf303ab2de 100644 --- a/.github/actions/get-app-token/action.yml +++ b/.github/actions/get-app-token/action.yml @@ -5,24 +5,32 @@ description: >- 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). + Callers must source App credentials from a protected, main-only environment. + Never pass an App private key to a pull_request job, a local action, or a + reusable workflow resolved from an untrusted PR ref. The fallback keeps a + trusted caller functional when its protected environment is misconfigured. - 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. + Composite actions cannot access contexts directly, so callers pass the + public vars.APP_CLIENT_ID and protected secrets.APP_PRIVATE_KEY as inputs. + When the private key is empty, the fallback fires. inputs: client-id: - description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow. + description: GitHub App Client ID. Pass vars.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: '' + owner: + description: GitHub App installation owner. Empty scopes the token to the current repository. + required: false + default: '' + repositories: + description: Comma- or newline-separated repositories to scope within the installation owner. + required: false + default: '' outputs: token: @@ -51,6 +59,8 @@ runs: with: client-id: ${{ inputs.client-id }} private-key: ${{ inputs.private-key }} + owner: ${{ inputs.owner }} + repositories: ${{ inputs.repositories }} - name: Fall back to GITHUB_TOKEN id: fallback diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6932fff39cca..d1ded0ff5413 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,6 +9,10 @@ name: CI # definitions, matrices, and concurrency settings. They no longer have # ``push:`` / ``pull_request:`` triggers of their own — everything flows # through this file. +# +# SECURITY: this workflow runs PR-controlled actions, workflows, and code. +# Do not add ``secrets: inherit`` or GitHub App credentials here. Trusted +# main-only automation uses protected environments in its own workflows. on: pull_request: @@ -46,22 +50,15 @@ jobs: docker_meta: ${{ steps.classify.outputs.docker_meta }} mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }} ci_review: ${{ steps.classify.outputs.ci_review }} + ci_review_files: ${{ steps.classify.outputs.ci_review_files }} 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: - # 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 }} + github-token: ${{ github.token }} # ───────────────────────────────────────────────────────────────────── # Lane-gated sub-workflows. Each runs in parallel after detect finishes. @@ -74,7 +71,6 @@ jobs: uses: ./.github/workflows/tests.yml with: slice_count: 8 - secrets: inherit lint: name: Python lints @@ -83,14 +79,12 @@ jobs: uses: ./.github/workflows/lint.yml with: event_name: ${{ needs.detect.outputs.event_name }} - secrets: inherit js-tests: name: JS & TS checks needs: detect if: needs.detect.outputs.frontend == 'true' uses: ./.github/workflows/js-tests.yml - secrets: inherit e2e-desktop: name: Desktop E2E @@ -103,48 +97,44 @@ jobs: needs: detect if: needs.detect.outputs.site == 'true' uses: ./.github/workflows/docs-site-checks.yml - secrets: inherit history-check: name: Deny unrelated histories needs: detect if: needs.detect.outputs.event_name == 'pull_request' uses: ./.github/workflows/history-check.yml - secrets: inherit contributor-check: name: Check contributors needs: detect if: needs.detect.outputs.python == 'true' uses: ./.github/workflows/contributor-check.yml - secrets: inherit uv-lockfile: name: Check uv.lock needs: detect uses: ./.github/workflows/uv-lockfile-check.yml - secrets: inherit lockfile-diff: name: package-lock.json diff needs: detect if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true' uses: ./.github/workflows/lockfile-diff.yml - secrets: inherit docker-lint: name: Lint Docker scripts needs: detect if: needs.detect.outputs.docker_meta == 'true' uses: ./.github/workflows/docker-lint.yml - secrets: inherit docker: name: Build&Test Docker image needs: detect - if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true' + # Trusted main pushes run docker.yml directly so its container-publish + # environment secrets never cross this reusable-workflow call. PR runs + # remain build/test-only and secret-free. + if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true') uses: ./.github/workflows/docker.yml - secrets: inherit supply-chain: name: Supply-chain scan @@ -163,14 +153,13 @@ jobs: uses: ./.github/workflows/review-labels.yml with: ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} + ci_review_files: ${{ needs.detect.outputs.ci_review_files }} mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }} supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }} - secrets: inherit osv-scanner: name: OSV scan uses: ./.github/workflows/osv-scanner.yml - secrets: inherit # ───────────────────────────────────────────────────────────────────── # Live-updating PR review comment. @@ -180,19 +169,21 @@ jobs: # 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. + # When the visible job set goes quiet, the poller waits 10 seconds and polls + # once more so downstream jobs created by an aggregate gate get included. # ───────────────────────────────────────────────────────────────────── comment-live: name: CI review comment (live) - needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check] + needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check, e2e-desktop] 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 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false - name: Run live comment poller env: @@ -313,8 +304,8 @@ jobs: # 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. + # The live comment poller can read the standalone review-status artifact + # after the HTML report is uploaded, so its link points straight at that report. # ───────────────────────────────────────────────────────────────────── ci-timings: name: CI timing report @@ -326,13 +317,6 @@ 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 @@ -346,38 +330,52 @@ jobs: - name: Collect timings and generate report env: - # Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to - # the built-in read-only token so the timings API read still works - # there instead of hard-failing this advisory job on every fork PR. - # 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 }} + GITHUB_TOKEN: ${{ github.token }} run: | python3 scripts/ci/timings_report.py \ --baseline ci-timings-baseline.json \ --output ci-timings-report.html \ --json-out ci-timings.json \ - --summary-out ci-timings-summary.md \ - --review-status-out review-status.json + --summary-out ci-timings-summary.md - - name: Upload HTML report + review status + - name: Upload HTML report # Advisory report — artifact-service blips must not fail the job. continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - id: ci-timings-artifact + id: ci-timings-html with: name: ci-timings-report - path: | - ci-timings-report.html - review-status.json + path: ci-timings-report.html + retention-days: 14 + + - name: Build linked review status + if: hashFiles('ci-timings.json') != '' + env: + CI_TIMINGS_REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url }} + run: | + python3 scripts/ci/timings_report.py \ + --from-json ci-timings.json \ + --baseline ci-timings-baseline.json \ + --review-status-out review-status.json \ + --review-status-only + + - name: Upload review status + if: hashFiles('review-status.json') != '' + continue-on-error: true + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: ci-timings-review-status + path: review-status.json retention-days: 14 - name: Output summary env: - REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}} + REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url}} run: | - echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY" - echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY" + { + echo "# CI Timing report" + echo "[View the full interactive report]($REPORT_URL)" + } >> "$GITHUB_STEP_SUMMARY" cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY" - name: Save baseline cache (main only) diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index fd09205a055c..3ac2c4741f89 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -60,7 +60,7 @@ jobs: id: app-token uses: ./.github/actions/get-app-token with: - client-id: ${{ secrets.APP_CLIENT_ID }} + client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index f500aca99537..5e5c19bdf3b6 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,8 +1,15 @@ name: Docker Build, Test, and Publish on: + # Trusted main pushes run this workflow directly so environment-scoped + # Docker Hub secrets are resolved by the top-level workflow, never across + # a reusable-workflow boundary. + push: + branches: [main] release: types: [published] + # CI calls this only for untrusted PR build/test coverage. Those runs never + # reach the protected publish or merge jobs below. workflow_call: permissions: @@ -20,7 +27,9 @@ env: IMAGE_NAME: nousresearch/hermes-agent jobs: - # Build, test, and optionally push the image for each architecture. + # Build and test the image for each architecture. This job runs PR code, + # so it must remain secret-free. Publishing happens in the separate, + # protected publish job after these tests pass. build: if: github.repository == 'NousResearch/hermes-agent' strategy: @@ -62,49 +71,6 @@ jobs: cache-from: ${{ matrix.cache-from }} cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }} - - name: Log in to Docker Hub - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - # Push by digest only (no tag). The merge job assembles the - # tagged manifest list. `push-by-digest=true` is docker's recommended - # pattern for multi-runner multi-platform builds. - - name: Push ${{ matrix.arch }} by digest - id: push - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 - with: - context: . - file: Dockerfile - platforms: ${{ matrix.platform }} - labels: | - org.opencontainers.image.revision=${{ github.sha }} - build-args: | - HERMES_GIT_SHA=${{ github.sha }} - outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true - cache-from: ${{ matrix.cache-from }} - cache-to: ${{ matrix.cache-to }} - - # Write the digest to a file and upload it as an artifact so the - # merge job can stitch both per-arch digests into a manifest list. - - name: Export digest - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - run: | - mkdir -p /tmp/digests - digest="${{ steps.push.outputs.digest }}" - touch "/tmp/digests/${digest#sha256:}" - - - name: Upload digest artifact - if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release' - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: digest-${{ matrix.arch }} - path: /tmp/digests/* - if-no-files-found: error - retention-days: 1 # Run the docker-integration test suite against the freshly-built # image already loaded into the local daemon (`:test`). @@ -147,6 +113,74 @@ jobs: run: | scripts/run_tests.sh tests/docker/ --file-timeout 600 + # --------------------------------------------------------------------------- + # Rebuild and push each architecture only after the unprivileged build/test + # matrix passes. This job is the sole Docker Hub credential boundary. + # --------------------------------------------------------------------------- + publish: + if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') + needs: [build] + environment: container-publish + strategy: + fail-fast: false + matrix: + include: + - arch: amd64 + runner: ubuntu-latest + platform: linux/amd64 + cache-from: type=gha,scope=docker-amd64 + cache-to: type=gha,mode=max,scope=docker-amd64 + - arch: arm64 + runner: ubuntu-24.04-arm + platform: linux/arm64 + cache-from: type=gha,scope=docker-arm64 + cache-to: type=gha,mode=max,scope=docker-arm64 + runs-on: ${{ matrix.runner }} + timeout-minutes: 30 + steps: + - name: Checkout trusted source + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3 + + - name: Log in to Docker Hub + uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + + # Push by digest only (no tag). The merge job assembles the tagged + # manifest list after both architecture publishers complete. + - name: Push ${{ matrix.arch }} by digest + id: push + uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0 + with: + context: . + file: Dockerfile + platforms: ${{ matrix.platform }} + labels: | + org.opencontainers.image.revision=${{ github.sha }} + build-args: | + HERMES_GIT_SHA=${{ github.sha }} + outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true + cache-from: ${{ matrix.cache-from }} + cache-to: ${{ matrix.cache-to }} + + - name: Export digest + run: | + mkdir -p /tmp/digests + digest="${{ steps.push.outputs.digest }}" + touch "/tmp/digests/${digest#sha256:}" + + - name: Upload digest artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: digest-${{ matrix.arch }} + path: /tmp/digests/* + if-no-files-found: error + retention-days: 1 + # --------------------------------------------------------------------------- # Stitch both per-arch digests into a single tagged multi-arch manifest. # This is a registry-side operation — no building, no layer re-push — @@ -158,8 +192,9 @@ jobs: merge: if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release') runs-on: ubuntu-latest - needs: [build] + needs: [publish] timeout-minutes: 10 + environment: container-publish steps: - name: Download digests uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index c07a94be8a3e..e9131c725224 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -2,6 +2,10 @@ name: E2E Desktop on: workflow_call: + outputs: + review_status: + description: Screenshot and visual-diff status for the CI review comment. + value: ${{ jobs.e2e.outputs.review_status }} permissions: contents: read @@ -15,6 +19,8 @@ jobs: name: Playwright E2E (Linux) runs-on: ubuntu-latest timeout-minutes: 20 + outputs: + review_status: ${{ steps.review-status.outputs.review_status }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -58,7 +64,8 @@ jobs: command: uv sync --locked --python 3.11 --extra all --extra dev # ── Build desktop app ───────────────────────────────────────────── - - run: npm run --prefix apps/desktop build + # The Playwright step below runs `npm run build` before testing so + # dist/ is always fresh — no separate build step needed here. # ── Restore visual baseline screenshots from main ────────────────── # Baselines are generated on main (via --update-snapshots) and cached. @@ -79,16 +86,18 @@ jobs: # 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. + # `npm run test:e2e` builds dist/ as a pretest hook so the renderer + # is always fresh — no separate build step needed. - 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" \ + npm run build && 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" \ + npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \ npx playwright test --reporter=list fi env: @@ -143,6 +152,38 @@ jobs: overwrite: true if-no-files-found: ignore + - name: Build screenshot review status + id: review-status + if: always() + working-directory: apps/desktop + env: + RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }} + run: | + python3 ../../scripts/ci/e2e_screenshot_status.py \ + --results-dir test-results \ + --manifest-output /tmp/e2e-screenshot-manifest.json \ + --evidence-dir /tmp/e2e-evidence \ + --artifact-url "$RESULTS_URL" \ + --output /tmp/e2e-review-status.json + { + echo 'review_status<<__E2E_REVIEW_STATUS__' + cat /tmp/e2e-review-status.json + echo '__E2E_REVIEW_STATUS__' + } >> "$GITHUB_OUTPUT" + + # The trusted workflow_run publisher consumes only this flat, bounded + # artifact. It turns selected images into GitHub attachment URLs; it + # never checks out or runs this PR's code. + - name: Upload inline E2E evidence + if: always() && github.ref_name != 'main' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + with: + name: e2e-evidence-${{ github.sha }} + path: /tmp/e2e-evidence + retention-days: 14 + overwrite: true + if-no-files-found: error + # ── 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 @@ -156,49 +197,50 @@ jobs: 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" + { + echo "## Desktop E2E — Visual Diff Report" + echo "" # 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" + echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." 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" + echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" + echo "" + echo "| Test | Diff | Actual | Expected |" + echo "|------|------|--------|----------|" # 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$//') + base=${diff%-diff.png} test_name=$(basename "$base") - echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY" + echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" done fi - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY" - echo "" >> "$GITHUB_STEP_SUMMARY" + echo "" + echo "📥 **Artifacts:**" + echo "" if [ -n "$RESULTS_URL" ]; then - echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY" + echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" fi if [ -n "$REPORT_URL" ]; then - echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY" + echo "- [playwright-report]($REPORT_URL) — interactive HTML report" fi if [ -n "$DIFFS_URL" ]; then - echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY" + echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" 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" + echo "" + echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." # 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" + echo "" + echo "### Test Results" + echo "" node -e " const r = require('./playwright-report/results.json'); const stats = r.stats || {}; @@ -208,5 +250,6 @@ jobs: 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 + " 2>/dev/null || true fi + } >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml index 8fb0460bc056..2dfbe7e0b17a 100644 --- a/.github/workflows/js-autofix.yml +++ b/.github/workflows/js-autofix.yml @@ -122,6 +122,7 @@ jobs: if: needs.generate-patch.outputs.has-fixes == 'true' runs-on: ubuntu-latest timeout-minutes: 15 + environment: trusted-automation permissions: contents: write # needed to push to bot/js-autofix pull-requests: write # needed for PR creation + auto-merge @@ -132,7 +133,7 @@ jobs: id: app-token uses: ./.github/actions/get-app-token with: - client-id: ${{ secrets.APP_CLIENT_ID }} + client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Download patch diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index 37fed82fbbf9..455ede33dd56 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -48,6 +48,9 @@ jobs: --lockfile=uv.lock --lockfile=package-lock.json --lockfile=website/package-lock.json + # The upstream reusable workflow uploads this exact file under its + # fixed artifact name, which the wrapper downloads below. + results-file-name: osv-results.sarif fail-on-vuln: false emit-status: @@ -64,7 +67,7 @@ jobs: - name: Download SARIF result uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - name: osv-results + name: OSV Scanner SARIF file path: /tmp/osv-results continue-on-error: true diff --git a/.github/workflows/publish-e2e-evidence.yml b/.github/workflows/publish-e2e-evidence.yml new file mode 100644 index 000000000000..3d7fe32d7806 --- /dev/null +++ b/.github/workflows/publish-e2e-evidence.yml @@ -0,0 +1,71 @@ +name: Publish E2E evidence + +# This runs only from the default branch after CI completes. It intentionally +# checks out main, never the PR ref, and treats the downloaded artifact as +# untrusted input before uploading validated GitHub attachments. +on: + workflow_run: + workflows: [CI] + types: [completed] + +permissions: + actions: read + contents: read + pull-requests: write + +concurrency: + group: publish-e2e-evidence-${{ github.event.workflow_run.id }} + cancel-in-progress: false + +jobs: + publish: + name: Publish inline E2E evidence + if: github.event.workflow_run.event == 'pull_request' + runs-on: ubuntu-latest + timeout-minutes: 10 + environment: gh-image + steps: + - name: Check out trusted publisher + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + with: + ref: ${{ github.event.repository.default_branch }} + persist-credentials: false + + # v1.2.0 resolves to 44f4b93ecbbe22de6c45fa2f62f519aee564ca8c. + - name: Install gh-image + env: + GH_TOKEN: ${{ github.token }} + run: gh extension install drogers0/gh-image --pin v1.2.0 + + - name: Download and attach evidence + env: + GH_TOKEN: ${{ github.token }} + GITHUB_TOKEN: ${{ github.token }} + GH_SESSION_TOKEN: ${{ secrets.GH_IMAGE_SESSION_TOKEN }} + SOURCE_REPO: ${{ github.repository }} + SOURCE_RUN_ID: ${{ github.event.workflow_run.id }} + run: | + set -euo pipefail + + PR_NUMBER=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID" --jq '.pull_requests[0].number // empty') + if [ -z "$PR_NUMBER" ]; then + echo "No pull request is associated with CI run $SOURCE_RUN_ID." + exit 0 + fi + + ARTIFACT_NAME=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID/artifacts" \ + --jq '.artifacts[] | select(.expired == false and (.name | startswith("e2e-evidence-"))) | .name' \ + | python3 -c 'import sys; print(next(iter(sys.stdin), "").strip())') + if [ -z "$ARTIFACT_NAME" ]; then + echo "No E2E evidence artifact was produced for CI run $SOURCE_RUN_ID." + exit 0 + fi + + EVIDENCE_DIR="$RUNNER_TEMP/e2e-evidence" + mkdir -p "$EVIDENCE_DIR" + gh run download "$SOURCE_RUN_ID" --repo "$SOURCE_REPO" --name "$ARTIFACT_NAME" --dir "$EVIDENCE_DIR" + + python3 scripts/ci/publish_e2e_evidence.py \ + --evidence-dir "$EVIDENCE_DIR" \ + --source-repo "$SOURCE_REPO" \ + --pr-number "$PR_NUMBER" diff --git a/.github/workflows/review-labels.yml b/.github/workflows/review-labels.yml index 737348d1c559..c8ea37dbbcaf 100644 --- a/.github/workflows/review-labels.yml +++ b/.github/workflows/review-labels.yml @@ -23,6 +23,10 @@ on: description: Whether CI-sensitive files (eslint config, workflows, actions) changed. type: boolean default: false + ci_review_files: + description: JSON list of CI-sensitive files changed by the pull request. + type: string + default: '[]' mcp_catalog: description: Whether the MCP catalog / installer changed. type: boolean @@ -78,18 +82,25 @@ jobs: id: build-status env: CI_REVIEW: ${{ inputs.ci_review }} + CI_REVIEW_FILES: ${{ inputs.ci_review_files }} MCP_CATALOG: ${{ inputs.mcp_catalog }} SUPPLY_CHAIN: ${{ inputs.supply_chain }} LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + BASE_SHA: ${{ github.event.pull_request.base.sha }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} run: | set -euo pipefail args=() if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi + args+=(--ci-review-files "$CI_REVIEW_FILES") if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi - python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT" + python3 scripts/ci/emit_review_status.py "${args[@]}" \ + --repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \ + --output "$GITHUB_OUTPUT" - name: Fail on missing label if: steps.label-check.outputs.ci_reviewed != 'true' diff --git a/.github/workflows/skills-index-freshness.yml b/.github/workflows/skills-index-freshness.yml index 4931ccaa0101..9e4b2767be56 100644 --- a/.github/workflows/skills-index-freshness.yml +++ b/.github/workflows/skills-index-freshness.yml @@ -21,6 +21,7 @@ jobs: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest timeout-minutes: 10 + environment: trusted-automation steps: - name: Probe live index id: probe @@ -113,7 +114,7 @@ jobs: id: app-token uses: ./.github/actions/get-app-token with: - client-id: ${{ secrets.APP_CLIENT_ID }} + client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Open issue on degraded / failed probe diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index ae05c9e70466..5415499e0245 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -21,6 +21,7 @@ jobs: if: github.repository == 'NousResearch/hermes-agent' runs-on: ubuntu-latest timeout-minutes: 15 + environment: trusted-automation steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -28,7 +29,7 @@ jobs: id: app-token uses: ./.github/actions/get-app-token with: - client-id: ${{ secrets.APP_CLIENT_ID }} + client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 @@ -60,12 +61,13 @@ jobs: if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch' runs-on: ubuntu-latest timeout-minutes: 15 + environment: trusted-automation steps: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token with: - client-id: ${{ secrets.APP_CLIENT_ID }} + client-id: ${{ vars.APP_CLIENT_ID }} private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Trigger Deploy Site workflow env: diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 47114b306546..cca61e03a452 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -65,17 +65,11 @@ jobs: 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: ${{ steps.app-token.outputs.token }} + GH_TOKEN: ${{ github.token }} + CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }} run: | set -euo pipefail @@ -93,7 +87,7 @@ jobs: # --- .pth files (auto-execute on Python startup) --- # The exact mechanism used in the litellm supply chain attack: # https://github.com/BerriAI/litellm/issues/24512 - PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true) + PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true) if [ -n "$PTH_FILES" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: .pth file added or modified @@ -141,8 +135,11 @@ jobs: # auto-loaded by the interpreter via site.py. Any nested file with the # same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated # and produced false positives that trained reviewers to ignore the scanner. - SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true) - if [ -n "$SETUP_HITS" ]; then + SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true) + # A maintainer-applied ci-reviewed label records the manual review + # required for intentional changes to an install hook. The scanner + # still blocks every unreviewed addition or modification. + if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then FINDINGS="${FINDINGS} ### 🚨 CRITICAL: Install-hook file added or modified These files can execute code during package installation or interpreter startup. diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index 1f29b25008e4..cdae2e037a59 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -215,11 +215,6 @@ jobs: # re-download, keeping the persisted cache small and fast to restore. run: uv cache prune --ci - - name: Packaged-wheel i18n smoke test - run: | - source .venv/bin/activate - python -m pytest -m integration tests/test_wheel_locales_e2e.py -v - - name: Run e2e tests run: | source .venv/bin/activate diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml deleted file mode 100644 index e95ef194fa71..000000000000 --- a/.github/workflows/upload_to_pypi.yml +++ /dev/null @@ -1,188 +0,0 @@ -name: Publish to PyPI - -# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15) -# Can also be triggered manually from the Actions tab as an escape hatch. -on: - push: - tags: - - "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc. - workflow_dispatch: - inputs: - confirm_tag: - description: "Tag to publish (e.g. v2026.5.15). Must already exist." - required: true - type: string - -# Restrict default token to read-only; each job escalates as needed. -permissions: - contents: read - -# Prevent overlapping publishes (e.g. two same-day tags pushed quickly). -concurrency: - group: pypi-publish - cancel-in-progress: false - -jobs: - build: - name: Build distribution 📦 - runs-on: ubuntu-latest - timeout-minutes: 30 - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false - # On workflow_dispatch, check out the confirmed tag. - ref: ${{ inputs.confirm_tag || github.ref }} - fetch-tags: true - - - name: Validate tag exists - if: github.event_name == 'workflow_dispatch' - run: | - if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then - echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo" - exit 1 - fi - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: "3.13" - - - name: Install uv - uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0 - - - name: Set up Node.js - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 - with: - node-version: "22" - - - name: Build web dashboard - uses: ./.github/actions/retry - with: - command: npm ci - working-directory: web - - - name: Compile web dashboard - run: npm run build - working-directory: web - - - name: Build TUI bundle - uses: ./.github/actions/retry - with: - command: npm ci - working-directory: ui-tui - - - name: Compile TUI bundle - run: npm run build - working-directory: ui-tui - - - name: Bundle TUI into hermes_cli - run: | - mkdir -p hermes_cli/tui_dist - cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js - - - name: Verify frontend assets exist - run: | - test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; } - test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; } - - - name: Bundle install scripts into wheel - run: | - mkdir -p hermes_cli/scripts - cp scripts/install.sh hermes_cli/scripts/install.sh - cp scripts/install.ps1 hermes_cli/scripts/install.ps1 - - - name: Build wheel and sdist - run: uv build --sdist --wheel - - - name: Upload distribution artifacts - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 - with: - name: python-package-distributions - path: dist/ - - publish: - name: Publish to PyPI - needs: build - runs-on: ubuntu-latest - timeout-minutes: 30 - environment: - name: pypi - url: https://pypi.org/p/hermes-agent - permissions: - id-token: write # OIDC trusted publishing - - steps: - - name: Download distribution artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: python-package-distributions - path: dist/ - - - name: Publish to PyPI - uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0 - with: - skip-existing: true - - sign: - name: Sign and attach to GitHub Release - # Only runs on tag pushes — release.py creates the GitHub Release, - # and workflow_dispatch won't have a matching release to attach to. - if: startsWith(github.ref, 'refs/tags/') - needs: publish - runs-on: ubuntu-latest - timeout-minutes: 30 - permissions: - contents: write # attach assets to the existing release - id-token: write # sigstore signing - - steps: - - name: Download distribution artifacts - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 - with: - name: python-package-distributions - path: dist/ - - - 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: Wait for GitHub Release to exist - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # release.py creates the GitHub Release after pushing the tag, - # but this workflow starts from the tag push — wait for it. - run: | - for i in $(seq 1 30); do - if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then - echo "Release $GITHUB_REF_NAME found" - exit 0 - fi - echo "Waiting for release... ($i/30)" - sleep 10 - done - echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload" - echo "skip_sign=true" >> "$GITHUB_ENV" - - - name: Sign with Sigstore - if: env.skip_sign != 'true' - uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0 - with: - inputs: >- - ./dist/*.tar.gz - ./dist/*.whl - - - name: Attach signed artifacts to GitHub Release - if: env.skip_sign != 'true' - env: - GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} - # release.py already created the GitHub Release — just upload - # the Sigstore signatures alongside the existing assets. - run: >- - gh release upload - "$GITHUB_REF_NAME" dist/*.sigstore.json - --repo "$GITHUB_REPOSITORY" - --clobber diff --git a/.gitignore b/.gitignore index 5b3c3b1c1572..294896331045 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,11 @@ docs/superpowers/* .update-incomplete .update-incomplete.lock +# Installer-written method stamp in the managed checkout root (scripts/install.sh). +# Runtime metadata only — never a code change. Ignore so `git status` stays clean +# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855). +/.install_method + # Tool Search live-test harness output — non-deterministic model transcripts, # regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo. scripts/out/ diff --git a/MANIFEST.in b/MANIFEST.in deleted file mode 100644 index 4827e3470adf..000000000000 --- a/MANIFEST.in +++ /dev/null @@ -1,17 +0,0 @@ -graft skills -graft optional-skills -graft optional-mcps -graft hermes_cli/web_dist -graft locales -# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the -# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs -# built from the sdist (e.g. Homebrew, downstream packagers). package-data -# below covers the wheel; this covers the sdist. See #34034 / #28149. -recursive-include plugins plugin.yaml plugin.yml -# Include the closed shared-metrics package schema in downstream sdists so the -# smoke test, documentation, and external validators can inspect the contract. -recursive-include hermes_cli/observability/schemas *.json -# Gateway assets include images plus YAML catalogs such as status_phrases.yaml. -recursive-include gateway/assets * -global-exclude __pycache__ -global-exclude *.py[cod] diff --git a/acp_adapter/entry.py b/acp_adapter/entry.py index 5048b7025982..55773536122a 100644 --- a/acp_adapter/entry.py +++ b/acp_adapter/entry.py @@ -190,7 +190,7 @@ def _run_setup_browser(assume_yes: bool = False) -> int: """Bootstrap agent-browser + Chromium. Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code - with ``hermes postinstall`` and the runtime lazy installer. + with the runtime lazy installer. Returns 0 on success, 1 on failure. """ diff --git a/acp_adapter/server.py b/acp_adapter/server.py index 266d587b0743..cc19f855d9b2 100644 --- a/acp_adapter/server.py +++ b/acp_adapter/server.py @@ -74,6 +74,10 @@ from acp_adapter.permissions import make_approval_callback from acp_adapter.provenance import session_provenance_meta from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets from acp_adapter.tools import build_tool_complete, build_tool_start +from agent.context_compressor import ( + COMPRESSED_SUMMARY_METADATA_KEY, + ContextCompressor, +) from tools.approval import ( reset_hermes_interactive_context, set_hermes_interactive_context, @@ -969,11 +973,49 @@ class HermesACPAgent(acp.Agent): return text return "" + @staticmethod + def _history_summary_meta(message: dict[str, Any], text: str) -> dict[str, Any] | None: + """Build the ``_meta`` payload for a replayed compaction summary. + + Compaction summaries are persisted as ordinary history messages — + standalone handoffs under ``role="user"`` OR ``role="assistant"`` + (the compressor picks whichever role keeps alternation valid), and + merge-into-tail messages where the summary is appended after the + first preserved tail message's real content. Without a wire flag, + ACP frontends render all of these as ordinary turns. + + Two distinct keys under ``_meta.hermes`` (ACP's extensibility + channel), so clients cannot accidentally hide real content: + + * ``compactionSummary: true`` — the entire chunk is the handoff + summary. Safe to restyle or collapse wholesale. + * ``containsCompactionSummary: true`` — a merged-tail message: real + preserved turn content followed by the summary. Clients may style + it, but collapsing the whole chunk would hide the preserved + content, hence the separate key. + + Detection honors the in-process ``_compressed_summary`` flag and + falls back to content classification, so it also works for a + DB-reloaded session that lost the in-memory flag. + """ + kind = ContextCompressor.classify_summary_content(text) + if kind is None and message.get(COMPRESSED_SUMMARY_METADATA_KEY): + # Flagged in-process but content didn't classify (e.g. future + # prefix drift): treat as a standalone summary — the flag is only + # ever set on summary-bearing messages. + kind = "standalone" + if kind == "standalone": + return {"hermes": {"compactionSummary": True}} + if kind == "merged": + return {"hermes": {"containsCompactionSummary": True}} + return None + @staticmethod def _history_message_update( *, role: str, text: str, + field_meta: dict[str, Any] | None = None, ) -> UserMessageChunk | AgentMessageChunk | None: """Build an ACP history replay update for a user/assistant message.""" block = TextContentBlock(type="text", text=text) @@ -981,11 +1023,13 @@ class HermesACPAgent(acp.Agent): return UserMessageChunk( session_update="user_message_chunk", content=block, + field_meta=field_meta, ) if role == "assistant": return AgentMessageChunk( session_update="agent_message_chunk", content=block, + field_meta=field_meta, ) return None @@ -1056,7 +1100,11 @@ class HermesACPAgent(acp.Agent): if role == "user": text = self._history_message_text(message) if text: - update = self._history_message_update(role=role, text=text) + update = self._history_message_update( + role=role, + text=text, + field_meta=self._history_summary_meta(message, text), + ) if update is not None and not await _send(update): return continue @@ -1068,7 +1116,11 @@ class HermesACPAgent(acp.Agent): text = self._history_message_text(message) if text: - update = self._history_message_update(role=role, text=text) + update = self._history_message_update( + role=role, + text=text, + field_meta=self._history_summary_meta(message, text), + ) if update is not None and not await _send(update): return @@ -1218,12 +1270,19 @@ class HermesACPAgent(acp.Agent): with state.runtime_lock: if state.is_running and state.current_prompt_text: state.interrupted_prompt_text = state.current_prompt_text - state.cancel_event.set() - try: - if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): - state.agent.interrupt() - except Exception: - logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True) + # Publish cancellation and hard-stop the agent before another + # prompt can acquire this lock and mistake the turn for + # redirectable work. + state.cancel_event.set() + try: + if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"): + state.agent.interrupt() + except Exception: + logger.debug( + "Failed to interrupt ACP session %s", + session_id, + exc_info=True, + ) logger.info("Cancelled session %s", session_id) async def fork_session( @@ -1352,6 +1411,26 @@ class HermesACPAgent(acp.Agent): elif rewrite_idle: user_text = steer_text user_content = steer_text + elif ( + text_only_prompt + and isinstance(user_content, str) + and not user_text.startswith("/") + ): + # Some ACP clients implement "stop and send" as two protocol calls: + # cancel the active prompt, then submit plain correction text. Keep + # the cancelled request attached so deictic follow-ups ("not that + # file") still have an explicit target. + interrupted_prompt = "" + with state.runtime_lock: + if not state.is_running and state.interrupted_prompt_text: + interrupted_prompt = state.interrupted_prompt_text + state.interrupted_prompt_text = "" + if interrupted_prompt: + user_text = ( + f"{interrupted_prompt}\n\n" + f"User correction/guidance after interrupt: {user_text}" + ) + user_content = user_text # Intercept slash commands — handle locally without calling the LLM. # Slash commands are text-only; if the client included images/resources, @@ -1366,23 +1445,54 @@ class HermesACPAgent(acp.Agent): await self._send_usage_update(state) return PromptResponse(stop_reason="end_turn") - # If Zed sends another regular prompt while the same ACP session is - # still running, queue it instead of racing two AIAgent loops against - # the same state.history. /steer and /queue are handled above and can - # land immediately. + # If the client sends another regular text prompt while this ACP session + # is running, route it through the core active-turn redirect. Rich media + # and older runtimes retain the proven next-turn queue fallback. + redirected = False + queued_depth: int | None = None with state.runtime_lock: if state.is_running: - queued_text = user_text or "[Image attachment]" - state.queued_prompts.append(queued_text) - depth = len(state.queued_prompts) - if self._conn: - update = acp.update_agent_message_text( - f"Queued for the next turn. ({depth} queued)" + if ( + text_only_prompt + and isinstance(user_content, str) + and getattr( + state.agent, + "_supports_active_turn_redirect", + False, ) - await self._conn.session_update(session_id, update) - return PromptResponse(stop_reason="end_turn") - state.is_running = True - state.current_prompt_text = user_text or "[Image attachment]" + is True + and hasattr(state.agent, "redirect") + ): + try: + redirected = bool(state.agent.redirect(user_content)) + except Exception: + logger.debug( + "ACP active-turn redirect failed for %s", + session_id, + exc_info=True, + ) + if not redirected: + queued_text = user_text or "[Image attachment]" + state.queued_prompts.append(queued_text) + queued_depth = len(state.queued_prompts) + else: + state.is_running = True + state.current_prompt_text = user_text or "[Image attachment]" + + if redirected: + if self._conn: + update = acp.update_agent_message_text( + "Redirected the active turn with your correction." + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") + if queued_depth is not None: + if self._conn: + update = acp.update_agent_message_text( + f"Queued for the next turn. ({queued_depth} queued)" + ) + await self._conn.session_update(session_id, update) + return PromptResponse(stop_reason="end_turn") logger.info("Prompt on session %s: %s", session_id, user_text[:100]) @@ -1911,7 +2021,10 @@ class HermesACPAgent(acp.Agent): lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens") if getattr(agent, "compression_enabled", True) is False: - lines.append("Compression is disabled for this agent.") + lines.append( + "Auto-compaction is disabled (compression.enabled: false); " + "/compress still compresses manually." + ) else: lines.append("Tip: run /compress to compress manually before the threshold.") @@ -1938,8 +2051,9 @@ class HermesACPAgent(acp.Agent): return "Nothing to compress — conversation is empty." try: agent = state.agent - if not getattr(agent, "compression_enabled", True): - return "Context compression is disabled for this agent." + # No compression_enabled gate: the flag disables *automatic* + # compaction only; manual /compress must keep working (matches + # the CLI /compress and gateway handlers). if not hasattr(agent, "_compress_context"): return "Context compression not available for this agent." @@ -1964,6 +2078,7 @@ class HermesACPAgent(acp.Agent): getattr(agent, "_cached_system_prompt", "") or "", approx_tokens=approx_tokens, task_id=state.session_id, + force=True, ) finally: agent._session_db = original_session_db diff --git a/acp_registry/agent.json b/acp_registry/agent.json deleted file mode 100644 index 1d3752bf15a1..000000000000 --- a/acp_registry/agent.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "id": "hermes-agent", - "name": "Hermes Agent", - "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", - "authors": ["Nous Research"], - "license": "MIT", - "distribution": { - "uvx": { - "package": "hermes-agent[acp]==0.19.0", - "args": ["hermes-acp"] - } - } -} diff --git a/acp_registry/icon.svg b/acp_registry/icon.svg deleted file mode 100644 index f42c0daea458..000000000000 --- a/acp_registry/icon.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/agent/account_usage.py b/agent/account_usage.py index 571d18446daf..b7abb1801764 100644 --- a/agent/account_usage.py +++ b/agent/account_usage.py @@ -701,6 +701,18 @@ def redeem_codex_reset_credit( remaining = max(0, available - 1) plural = "s" if remaining != 1 else "" if code == "reset": + # The redeemed reset restores the account's quota upstream — lift any + # persisted pool cooldowns so Hermes doesn't keep the credential + # frozen behind the now-stale ``last_error_reset_at`` (issue #43747). + try: + from hermes_cli.auth import clear_codex_pool_quota_cooldowns + + clear_codex_pool_quota_cooldowns() + except Exception: + logger.debug( + "Failed to clear Codex pool cooldowns after reset redemption", + exc_info=True, + ) return CodexResetRedeemResult( status="reset", message=( diff --git a/agent/agent_init.py b/agent/agent_init.py index c268c37d505c..241d3689ebd8 100644 --- a/agent/agent_init.py +++ b/agent/agent_init.py @@ -480,6 +480,7 @@ def init_agent( checkpoint_max_total_size_mb: int = 500, checkpoint_max_file_size_mb: int = 10, pass_session_id: bool = False, + requested_provider: str = None, ): """ Initialize the AI Agent. @@ -488,6 +489,7 @@ def init_agent( base_url (str): Base URL for the model API (optional) api_key (str): API key for authentication (optional, uses env var if not provided) provider (str): Provider identifier (optional; used for telemetry/routing hints) + requested_provider (str): Original provider identity before runtime canonicalization api_mode (str): API mode override: "chat_completions" or "codex_responses" model (str): Model name to use (default: "anthropic/claude-opus-4.6") max_iterations (int): Maximum number of tool calling iterations (default: 90) @@ -568,6 +570,11 @@ def init_agent( agent.base_url = base_url or "" provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None agent.provider = provider_name or "" + agent.requested_provider = ( + requested_provider.strip().lower() + if isinstance(requested_provider, str) and requested_provider.strip() + else agent.provider + ) agent._credential_pool = credential_pool agent.acp_command = acp_command or command agent.acp_args = list(acp_args or args or []) @@ -720,6 +727,8 @@ def init_agent( agent._execution_thread_id: int | None = None # Set at run_conversation() start agent._interrupt_thread_signal_pending = False agent._client_lock = threading.RLock() + agent._model_request_active = threading.Event() + agent._supports_active_turn_redirect = True # /steer mechanism — inject a user note into the next tool result # without interrupting the agent. Unlike interrupt(), steer() does @@ -731,6 +740,13 @@ def init_agent( agent._pending_steer: Optional[str] = None agent._pending_steer_lock = threading.Lock() + # Active-turn redirect mechanism. A regular follow-up sent while the model + # is generating is different from a hard /stop: preserve the valid turn + # prefix, cancel only the in-flight model request, and rebuild its tail with + # the correction. The loop drains this slot at a role-safe boundary. + agent._pending_redirect: Optional[str] = None + agent._pending_redirect_lock = threading.Lock() + # Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent` # runs each tool on its own ThreadPoolExecutor worker — those worker # threads have tids distinct from `_execution_thread_id`, so @@ -897,6 +913,12 @@ def init_agent( agent._stream_writer_tls = threading.local() agent._stream_writer_dropped = 0 + # Displayed reasoning text streamed during the current model response, + # captured only when a surface consumed it via a reasoning callback. Used + # by active-turn redirect to checkpoint what the user actually saw without + # ever persisting hidden provider reasoning. + agent._current_streamed_reasoning_text = "" + # Optional current-turn user-message override used when the API-facing # user message intentionally differs from the persisted transcript # (e.g. CLI voice mode adds a temporary prefix for the live call only). @@ -1869,6 +1891,12 @@ def init_agent( codex_app_server_auto_compaction, ) codex_app_server_auto_compaction = "native" + # Opt-in idle compaction: compact a session up front when it resumes after + # this many seconds of inactivity (0 = disabled). Time-based, so it + # complements the size-based threshold above. Consumed by build_turn_context(). + compression_idle_compact_after_seconds = max( + 0, int(_compression_cfg.get("idle_compact_after_seconds", 0)) + ) # Read optional explicit context_length override for the auxiliary # compression model. Custom endpoints often cannot report this via @@ -2295,6 +2323,9 @@ def init_agent( agent.compression_in_place = compression_in_place agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction agent.max_compression_attempts = compression_max_attempts + agent.compression_idle_compact_after_seconds = ( + compression_idle_compact_after_seconds + ) # Reject models whose context window is below the minimum required # for reliable tool-calling workflows (64K tokens). @@ -2545,6 +2576,7 @@ def init_agent( agent._primary_runtime = { "model": agent.model, "provider": agent.provider, + "requested_provider": agent.requested_provider, "base_url": agent.base_url, "api_mode": agent.api_mode, "api_key": getattr(agent, "api_key", ""), diff --git a/agent/agent_runtime_helpers.py b/agent/agent_runtime_helpers.py index 47ac8baa0320..5eba29971a74 100644 --- a/agent/agent_runtime_helpers.py +++ b/agent/agent_runtime_helpers.py @@ -922,6 +922,18 @@ def recover_with_credential_pool( ) return False, has_retried_429 + # Capture the current API key before any rotation — needed to + # identify which credential actually failed when + # mark_exhausted_and_rotate is called. Without this hint the + # pool falls back to current() or _select_unlocked(), which may + # return the NEXT (healthy) entry after a prior rotation, marking + # the wrong credential as exhausted (#43747). + _api_key_hint = getattr(agent, "api_key", None) or None + if not _api_key_hint: + _cur = pool.current() + if _cur: + _api_key_hint = getattr(_cur, "runtime_api_key", None) + effective_reason = classified_reason if effective_reason is None: if status_code == 402: @@ -952,13 +964,13 @@ def recover_with_credential_pool( if effective_reason == FailoverReason.billing: rotate_status = status_code if status_code is not None else 402 + # Runtime credentials can be resolved by a separate pool instance, + # leaving this recovery pool without ``current_id``. Match the key + # that actually failed instead of quarantining a different account. next_entry = pool.mark_exhausted_and_rotate( status_code=rotate_status, error_context=error_context, - # Runtime credentials can be resolved by a separate pool instance, - # leaving this recovery pool without ``current_id``. Match the key - # that actually failed instead of quarantining a different account. - api_key_hint=getattr(agent, "api_key", None), + api_key_hint=_api_key_hint, ) if next_entry is not None: _ra().logger.info( @@ -983,7 +995,7 @@ def recover_with_credential_pool( current_last_status, ) rotate_status = status_code if status_code is not None else 429 - next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context) + next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint) if next_entry is not None: _ra().logger.info( "Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s", @@ -1007,7 +1019,7 @@ def recover_with_credential_pool( if not has_retried_429 and not usage_limit_reached: return False, True rotate_status = status_code if status_code is not None else 429 - next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context) + next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint) if next_entry is not None: _ra().logger.info( "Credential %s (rate limit) — rotated to pool entry %s", @@ -1107,7 +1119,7 @@ def recover_with_credential_pool( # Refresh failed — rotate to next credential instead of giving up. # The failed entry is already marked exhausted by try_refresh_current(). rotate_status = status_code if status_code is not None else 401 - next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context) + next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint) if next_entry is not None: _ra().logger.info( "Credential %s (auth refresh failed) — rotated to pool entry %s", @@ -1166,6 +1178,7 @@ def try_recover_primary_transport( agent._client_kwargs = dict(rt["client_kwargs"]) agent.model = rt["model"] agent.provider = rt["provider"] + agent.requested_provider = rt.get("requested_provider", agent.provider) agent.base_url = rt["base_url"] agent.api_mode = rt["api_mode"] if hasattr(agent, "_transport_cache"): @@ -1329,6 +1342,7 @@ def restore_primary_runtime(agent) -> bool: # ── Core runtime state ── agent.model = rt["model"] agent.provider = rt["provider"] + agent.requested_provider = rt.get("requested_provider", agent.provider) agent.base_url = rt["base_url"] # setter updates _base_url_lower agent.api_mode = rt["api_mode"] if hasattr(agent, "_transport_cache"): @@ -1993,6 +2007,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo for name in ( "model", "provider", + "requested_provider", "base_url", "api_mode", "api_key", @@ -2021,6 +2036,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo # ── Swap core runtime fields ── agent.model = new_model agent.provider = new_provider + agent.requested_provider = new_provider # Use the new base_url when provided. When it's empty AND the # provider is actually changing, do NOT fall back to the current # (old provider's) URL — that silently pairs the new provider label @@ -2270,6 +2286,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo agent._primary_runtime = { "model": agent.model, "provider": agent.provider, + "requested_provider": agent.requested_provider, "base_url": agent.base_url, "api_mode": agent.api_mode, "api_key": getattr(agent, "api_key", ""), diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index a15072ce5b9a..aa24a244f683 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -2537,6 +2537,7 @@ def set_runtime_main( provider: str, model: str, *, + requested_provider: str = "", base_url: str = "", api_key: Any = "", api_mode: str = "", @@ -2552,6 +2553,7 @@ def set_runtime_main( global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT runtime = { "provider": (provider or "").strip().lower(), + "requested_provider": (requested_provider or "").strip().lower(), "model": (model or "").strip(), "base_url": (base_url or "").strip(), "api_key": ( @@ -3024,6 +3026,7 @@ _AUTO_PROVIDER_LABELS = { } _MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode") +_MAIN_RUNTIME_CONTEXT_FIELDS = _MAIN_RUNTIME_FIELDS + ("requested_provider",) def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]: @@ -3046,7 +3049,7 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, if not isinstance(main_runtime, dict): return {} normalized: Dict[str, Any] = {} - for field in _MAIN_RUNTIME_FIELDS: + for field in _MAIN_RUNTIME_CONTEXT_FIELDS: value = main_runtime.get(field) # Preserve a callable api_key (Entra ID bearer provider) unchanged. if field == "api_key" and callable(value) and not isinstance(value, str): @@ -3054,9 +3057,10 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, continue if isinstance(value, str) and value.strip(): normalized[field] = value.strip() - provider = normalized.get("provider") - if isinstance(provider, str): - normalized["provider"] = provider.lower() + for identity_field in ("provider", "requested_provider"): + identity = normalized.get(identity_field) + if isinstance(identity, str): + normalized[identity_field] = identity.lower() return normalized diff --git a/agent/billing_links.py b/agent/billing_links.py new file mode 100644 index 000000000000..1e9320ebb45a --- /dev/null +++ b/agent/billing_links.py @@ -0,0 +1,124 @@ +"""Provider-agnostic billing/credit recovery links. + +Maps a billing-classified failure onto a recovery link + label. *Detection* +is not done here — that is :mod:`agent.error_classifier` +(``FailoverReason.billing``), the single source of truth for "credit wall vs. +rate limit / auth / transport". The resulting :class:`BillingBlock` rides the +turn result and the gateway ``message.complete`` event so every surface (CLI, +TUI, desktop) renders one structured signal instead of re-parsing error text. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass +from typing import Optional + +from utils import base_url_host_matches + + +@dataclass +class BillingBlock: + """Structured billing-wall descriptor shared across every surface. + + ``is_nous`` is the routing bit: Nous has a first-class in-app billing surface + (desktop Settings → Billing, TUI/CLI ``/topup``), so surfaces prefer that over + ``billing_url``; third-party providers have no in-app flow, so ``billing_url`` + is the deep link the user actually needs. + """ + + provider: str + provider_label: str + model: str + billing_url: Optional[str] + is_nous: bool + message: str + + def to_dict(self) -> dict: + return asdict(self) + + +@dataclass(frozen=True) +class _Provider: + label: str + url: str + slugs: tuple[str, ...] + hosts: tuple[str, ...] = () + + +# Single source of truth: internal slug(s) + base_url host(s) → billing page. +# Curated "add credits / manage billing" landing pages, not marketing homes. +# Hosts back the OpenAI-compatible fallback where the slug is a generic bucket +# (e.g. "openai_compatible") but base_url reveals the real upstream. An unknown +# provider degrades to a readable label with no invented URL. +_PROVIDERS: tuple[_Provider, ...] = ( + _Provider("OpenAI", "https://platform.openai.com/settings/organization/billing", ("openai",), ("api.openai.com",)), + _Provider("Anthropic", "https://console.anthropic.com/settings/billing", ("anthropic",), ("api.anthropic.com",)), + _Provider("OpenRouter", "https://openrouter.ai/settings/credits", ("openrouter",), ("openrouter.ai",)), + _Provider("xAI", "https://console.x.ai/team/default/billing", ("xai", "xai-oauth"), ("api.x.ai",)), + _Provider("DeepSeek", "https://platform.deepseek.com/top_up", ("deepseek",), ("api.deepseek.com",)), + _Provider("Groq", "https://console.groq.com/settings/billing", ("groq",), ("api.groq.com",)), + _Provider("Mistral", "https://console.mistral.ai/billing", ("mistral",), ("api.mistral.ai",)), + _Provider("Together AI", "https://api.together.ai/settings/billing", ("together",), ("api.together.ai", "api.together.xyz")), + _Provider("Fireworks AI", "https://fireworks.ai/account/billing", ("fireworks",), ("fireworks.ai",)), + _Provider("Perplexity", "https://www.perplexity.ai/settings/api", ("perplexity",), ("perplexity.ai",)), + _Provider("Google AI", "https://aistudio.google.com/app/billing", ("google", "gemini"), ("generativelanguage.googleapis.com",)), + _Provider("Cohere", "https://dashboard.cohere.com/billing", ("cohere",)), + _Provider("Moonshot AI", "https://platform.moonshot.ai/console/pay", ("moonshot",)), + _Provider("NVIDIA", "https://build.nvidia.com/settings/billing", ("nvidia",)), +) + +_BY_SLUG: dict[str, _Provider] = {slug: p for p in _PROVIDERS for slug in p.slugs} + + +def is_nous_inference_route(provider: str, base_url: str) -> bool: + """True when the failing route is the Nous-managed inference gateway.""" + if (provider or "").strip().lower() == "nous": + return True + return base_url_host_matches(str(base_url or ""), "inference-api.nousresearch.com") + + +def _nous_billing_url() -> Optional[str]: + """Best-effort Nous portal billing URL (text-surface fallback; Nous prefers the in-app flow).""" + try: + from hermes_cli.nous_account import nous_portal_billing_url + + return nous_portal_billing_url(None) + except Exception: + return "https://portal.nousresearch.com/billing" + + +def _resolve_provider_link(slug: str, base_url: str) -> tuple[str, Optional[str]]: + """Resolve ``(label, url)``: exact slug → base_url host → readable-label fallback.""" + hit = _BY_SLUG.get(slug) + if hit: + return hit.label, hit.url + + base = str(base_url or "") + for p in _PROVIDERS: + if any(base_url_host_matches(base, host) for host in p.hosts): + return p.label, p.url + + return slug.replace("_", " ").replace("-", " ").strip().title() or "your provider", None + + +def build_billing_block( + *, + provider: str, + base_url: str, + model: str, + message: str = "", +) -> BillingBlock: + """Build the billing descriptor for a billing-classified failure. + + ``message`` is the guidance already assembled by the agent loop + (:func:`agent.conversation_loop._billing_or_entitlement_message`), carried + through unchanged so every surface shows identical copy. + """ + slug = (provider or "").strip().lower() + model = (model or "").strip() + + if is_nous_inference_route(slug, base_url): + return BillingBlock(slug or "nous", "Nous Portal", model, _nous_billing_url(), True, message or "") + + label, url = _resolve_provider_link(slug, base_url) + return BillingBlock(slug, label, model, url, False, message or "") diff --git a/agent/chat_completion_helpers.py b/agent/chat_completion_helpers.py index 8836160b6b83..58fef45a49a4 100644 --- a/agent/chat_completion_helpers.py +++ b/agent/chat_completion_helpers.py @@ -1719,6 +1719,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool agent._config_context_length = None agent.model = fb_model agent.provider = fb_provider + agent.requested_provider = fb_provider agent.base_url = fb_base_url agent.api_mode = fb_api_mode if hasattr(agent, "_transport_cache"): diff --git a/agent/codex_runtime.py b/agent/codex_runtime.py index 65e80f1d8680..525e58e4e1d3 100644 --- a/agent/codex_runtime.py +++ b/agent/codex_runtime.py @@ -702,6 +702,16 @@ def run_codex_app_server_turn( except Exception: pass agent._codex_session = None + _user_interrupted = bool( + getattr(agent, "_interrupt_requested", False) + ) + _interrupt_message = ( + getattr(agent, "_interrupt_message", None) + if _user_interrupted + else None + ) + if _user_interrupted: + agent.clear_interrupt() return { "final_response": ( f"Codex app-server turn failed: {exc}. " @@ -711,9 +721,27 @@ def run_codex_app_server_turn( "api_calls": 0, "completed": False, "partial": True, + "interrupted": _user_interrupted, + **( + {"interrupt_message": _interrupt_message} + if _interrupt_message + else {} + ), "error": str(exc), } + # This runtime bypasses the normal conversation-loop finalizer. Mirror its + # interrupt handoff/cleanup so a hard stop cannot poison the next turn and a + # message-bearing compatibility interrupt can still be replayed by callers. + _user_interrupted = bool( + turn.interrupted and getattr(agent, "_interrupt_requested", False) + ) + _interrupt_message = ( + getattr(agent, "_interrupt_message", None) if _user_interrupted else None + ) + if _user_interrupted: + agent.clear_interrupt() + # If the turn signalled the underlying client is wedged (deadline # blown, post-tool watchdog tripped, OAuth refresh died, subprocess # exited), retire the session so the next turn respawns codex @@ -819,6 +847,12 @@ def run_codex_app_server_turn( "api_calls": api_calls, "completed": not turn.interrupted and turn.error is None, "partial": turn.interrupted or turn.error is not None, + "interrupted": _user_interrupted, + **( + {"interrupt_message": _interrupt_message} + if _interrupt_message + else {} + ), "error": turn.error, # The codex app-server runtime IS an early-return path that bypasses # conversation_loop, but we flush the projected assistant/tool messages diff --git a/agent/context_compressor.py b/agent/context_compressor.py index 9597d9fbc2a3..9eaee872e353 100644 --- a/agent/context_compressor.py +++ b/agent/context_compressor.py @@ -288,6 +288,12 @@ _HISTORICAL_SUMMARY_PREFIXES = ( "config, etc.) may reflect work described here — avoid repeating it:", ) +# Restart handoff detection should be early and bounded: it needs to catch the +# restored protected head plus a small cluster of already-stacked handoff/ack +# turns, but it must not treat arbitrary summary-looking live-tail messages as +# proof that this is a resumed compacted session. +_RESTART_HANDOFF_PROBE_EXTRA_MESSAGES = 4 + # Minimum tokens for the summary output _MIN_SUMMARY_TOKENS = 2000 # Proportion of compressed content to allocate for summary @@ -318,6 +324,7 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600 # only meant to preserve continuity anchors from the dropped window, not to # become another unbounded transcript copy after the LLM summarizer failed. _FALLBACK_SUMMARY_MAX_CHARS = 8_000 +_FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS = 3_000 _FALLBACK_TURN_MAX_CHARS = 700 _AUTO_FOCUS_MAX_TURNS = 3 _AUTO_FOCUS_TURN_MAX_CHARS = 260 @@ -2305,9 +2312,17 @@ class ContextCompressor(ContextEngine): ) previous_summary_note = "" if self._previous_summary: + previous_summary = redact_sensitive_text(self._previous_summary.strip()) + if len(previous_summary) > _FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS: + previous_summary = ( + previous_summary[: _FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS - 45].rstrip() + + "\n...[previous summary snapshot truncated]" + ) previous_summary_note = ( - "\n\nPrevious compaction summary was present and should still be treated as " - "background continuity context, but the latest LLM summary update failed." + "\n\n## Previous Summary Snapshot\n" + f"{previous_summary}\n\n" + "The previous compaction summary above remains background " + "continuity context because the latest LLM summary update failed." ) reason_text = f" Summary failure reason: {reason}." if reason else "" @@ -2978,11 +2993,15 @@ This compaction should PRIORITISE preserving all information related to the focu if text.startswith(prefix): text = text[len(prefix):].lstrip() break - # Strip the trailing end marker too — a rehydrated handoff body that - # keeps it would leak the boundary directive into the iterative-update + # Strip the end marker too — a rehydrated handoff body that keeps it + # would leak the boundary directive into the iterative-update # summarizer prompt (and the marker is re-appended on insertion anyway). - if text.endswith(_SUMMARY_END_MARKER): - text = text[: -len(_SUMMARY_END_MARKER)].rstrip() + # Forced user-leading merged summaries keep the live tail request after + # this marker, so truncate at the marker even when it is not the final + # content. + marker_idx = text.find(_SUMMARY_END_MARKER) + if marker_idx >= 0: + text = text[:marker_idx].rstrip() return text @classmethod @@ -2992,7 +3011,29 @@ This compaction should PRIORITISE preserving all information related to the focu return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX @staticmethod - def _is_context_summary_content(content: Any) -> bool: + def _starts_with_summary_prefix(text: str) -> bool: + """Return True if *text* begins with any known handoff prefix.""" + if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX): + return True + return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES) + + @classmethod + def classify_summary_content(cls, content: Any) -> Optional[str]: + """Classify how *content* relates to a compaction summary. + + Returns: + ``"standalone"``: the entire message IS a compaction handoff + (current, legacy, or historical prefix at the start). Frontends + may restyle/collapse the whole message as a summary. + + ``"merged"``: a merge-into-tail message — real preserved turn + content wrapped under ``_MERGED_PRIOR_CONTEXT_HEADER``, followed by + ``_MERGED_SUMMARY_DELIMITER`` and the summary body. The message + *contains* a summary but is not only a summary; collapsing the + whole message would hide the preserved content. + + ``None``: no compaction summary detected. + """ text = _content_text_for_contains(content).lstrip() # Merge-into-tail summaries wrap prior tail content before the summary, # so the handoff prefix lands after _MERGED_SUMMARY_DELIMITER rather than @@ -3000,10 +3041,13 @@ This compaction should PRIORITISE preserving all information related to the focu # (auto-focus skip, carry-forward summary find, last-real-user anchor) # mistake a merged summary message for a real user turn. if _MERGED_SUMMARY_DELIMITER in text: - text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip() - if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX): - return True - return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES) + after = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip() + return "merged" if cls._starts_with_summary_prefix(after) else None + return "standalone" if cls._starts_with_summary_prefix(text) else None + + @classmethod + def _is_context_summary_content(cls, content: Any) -> bool: + return cls.classify_summary_content(content) is not None @staticmethod def _has_compressed_summary_metadata(message: Any) -> bool: @@ -3081,6 +3125,15 @@ This compaction should PRIORITISE preserving all information related to the focu "session with no user-authored turns" ) + @classmethod + def _is_context_summary_message(cls, message: Any) -> bool: + """Return True for summary handoff messages by metadata or content.""" + if not isinstance(message, dict): + return False + return cls._has_compressed_summary_metadata( + message + ) or cls._is_context_summary_content(message.get("content")) + @classmethod def _is_blank_user_turn(cls, message: Any) -> bool: """Return whether *message* is an empty, non-summary user-role echo.""" @@ -3239,6 +3292,24 @@ This compaction should PRIORITISE preserving all information related to the focu return grounded.strip() return f"{replacement}{body}".strip() + @classmethod + def _find_context_summaries( + cls, + messages: List[Dict[str, Any]], + start: int, + end: int, + ) -> list[tuple[int, str]]: + """Find handoff summaries inside a compression window.""" + summaries: list[tuple[int, str]] = [] + for idx in range(start, end): + content = messages[idx].get("content") + if cls._is_context_summary_message(messages[idx]): + summaries.append(( + idx, + cls._strip_summary_prefix(_content_text_for_contains(content)), + )) + return summaries + @classmethod def _find_latest_context_summary( cls, @@ -3247,10 +3318,9 @@ This compaction should PRIORITISE preserving all information related to the focu end: int, ) -> tuple[Optional[int], str]: """Find the newest handoff summary inside a compression window.""" - for idx in range(end - 1, start - 1, -1): - content = messages[idx].get("content") - if cls._is_context_summary_content(content): - return idx, cls._strip_summary_prefix(_content_text_for_contains(content)) + summaries = cls._find_context_summaries(messages, start, end) + if summaries: + return summaries[-1] return None, "" @classmethod @@ -3471,7 +3541,25 @@ This compaction should PRIORITISE preserving all information related to the focu idx += 1 return idx - def _effective_protect_first_n(self) -> int: + def _restart_handoff_probe_bounds( + self, + messages: List[Dict[str, Any]], + ) -> tuple[int, int]: + """Return the bounded transcript region that can indicate restart decay.""" + if not messages or self.protect_first_n <= 0: + return 0, 0 + first_non_system = 1 if messages[0].get("role") == "system" else 0 + return first_non_system, min( + len(messages), + first_non_system + + self.protect_first_n + + _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES, + ) + + def _effective_protect_first_n( + self, + messages: Optional[List[Dict[str, Any]]] = None, + ) -> int: """``protect_first_n`` decayed across compression cycles. ``protect_first_n`` keeps the first N non-system messages verbatim so @@ -3483,9 +3571,24 @@ This compaction should PRIORITISE preserving all information related to the focu once, the early turns are already captured in the handoff summary, so there's no need to keep re-protecting them: decay to 0 (the system prompt is still always protected separately by _protect_head_size). + After a restart, infer that decayed state from handoff summaries in the + resumed-head region; disk-persisted restarts rely on the content prefix, + while the metadata branch covers in-process handoff messages. """ if self.compression_count >= 1 or self._previous_summary: return 0 + if messages and self.protect_first_n > 0: + # Probe only the early handoff shape created by a resumed compacted + # session. Summary-looking tail content should keep normal tail + # semantics and not decay the initial first-compaction protection. + first_non_system, restart_probe_end = self._restart_handoff_probe_bounds( + messages + ) + if any( + self._is_context_summary_message(msg) + for msg in messages[first_non_system:restart_probe_end] + ): + return 0 return self.protect_first_n def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int: @@ -3511,7 +3614,7 @@ This compaction should PRIORITISE preserving all information related to the focu head = 0 if messages and messages[0].get("role") == "system": head = 1 - return head + self._effective_protect_first_n() + return head + self._effective_protect_first_n(messages) def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int: """Pull a compress-end boundary backward to avoid splitting a @@ -3593,6 +3696,8 @@ This compaction should PRIORITISE preserving all information related to the focu msg.get("content") ): continue + if self._is_context_summary_message(msg): + continue if last_any < 0: last_any = i content = msg.get("content") @@ -4059,23 +4164,45 @@ This compaction should PRIORITISE preserving all information related to the focu return messages turns_to_summarize = messages[compress_start:compress_end] + # Snapshot the rehydration state so an aborted attempt below can roll + # it back. The self-heal scan mutates ``_previous_summary`` (populating + # it from a fossil, or discarding a stale cross-session one); if + # summary generation then aborts and returns the transcript unchanged, + # leaving that mutation behind would make the retry — still + # ``compression_count == 0`` but now with a truthy ``_previous_summary`` + # — take the narrow rescan, miss a beyond-window fossil, and discard the + # rehydrated state as cross-session leakage (#57835). + _previous_summary_before_scan = self._previous_summary # A persisted handoff summary can sit in the protected head after a # resume (commonly immediately after the system prompt). Search from - # the first non-system message through the compression window so we can - # rehydrate iterative-summary state without serializing that handoff as - # a new turn. Protected messages after the handoff remain live context, - # so only summarize messages that are both after the handoff and inside - # the current compression window. + # the first non-system message through the compression window. On the + # first compaction after a restart, extend through the full transcript + # so summaries that landed in the protected tail or drifted past the + # decay probe still rehydrate iterative-summary state instead of being + # copied forward as stacked fossils. summary_search_start = 1 if messages and messages[0].get("role") == "system" else 0 - summary_idx, summary_body = self._find_latest_context_summary( + summary_search_end = compress_end + if self.compression_count < 1 and not self._previous_summary: + summary_search_end = len(messages) + summary_search_end = min(len(messages), summary_search_end) + summary_indices: set[int] = set() + summary_idx = None + summary_body = None + tail_start = compress_end + summary_hits = self._find_context_summaries( messages, summary_search_start, - compress_end, + summary_search_end, ) real_user_present = self._transcript_has_real_user_turn(messages) - if summary_idx is not None: - if summary_body and not self._previous_summary: - self._previous_summary = summary_body + if summary_hits: + summary_idx = summary_hits[-1][0] + summary_body = summary_hits[-1][1] + if not self._previous_summary: + summary_bodies = [body for _, body in summary_hits if body] + if summary_bodies: + self._previous_summary = "\n\n".join(summary_bodies) + # Zero-user provenance (#64650) rides on the newest handoff hit. provenance = messages[summary_idx].get( COMPRESSED_SUMMARY_HAS_USER_TURN_KEY ) @@ -4090,7 +4217,42 @@ This compaction should PRIORITISE preserving all information related to the focu self._summary_has_user_turn = not ( summary_body and _NO_USER_TASK_SENTINEL in summary_body ) - turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end] + summary_indices = {idx for idx, _ in summary_hits} + # Summary rows are excluded from the summarizer input (their + # bodies already ride _previous_summary), BUT a merged handoff + # carries genuine prior-tail user content before the delimiter — + # unwrap it (via the strip helper) into the window instead of + # dropping it (#47274 interplay with the multi-fossil scan). + def _window_row(idx: int, msg: Dict[str, Any]): + if idx not in summary_indices: + return msg + stripped = self._strip_context_summary_handoff_message( + _fresh_compaction_message_copy(msg) + ) + return stripped # None for standalone handoffs → dropped + pre_summary_turns = [ + row for idx, msg in enumerate( + messages[compress_start:summary_idx], + start=compress_start, + ) + if (row := _window_row(idx, msg)) is not None + ] + turns_to_summarize = ( + pre_summary_turns + messages[summary_idx + 1:compress_end] + ) + # The newest hit itself may be a merged handoff too — recover its + # prior-tail content the same way. + _newest_stripped = self._strip_context_summary_handoff_message( + _fresh_compaction_message_copy(messages[summary_idx]) + ) + if _newest_stripped is not None: + turns_to_summarize = ( + pre_summary_turns + + [_newest_stripped] + + messages[summary_idx + 1:compress_end] + ) + if summary_idx >= compress_end: + tail_start = summary_idx + 1 elif self._previous_summary: # No handoff summary found in the current messages, but # _previous_summary is non-empty — it was set by a different @@ -4121,7 +4283,7 @@ This compaction should PRIORITISE preserving all information related to the focu self.threshold_percent * 100, self.threshold_tokens, ) - tail_msgs = n_messages - compress_end + tail_msgs = n_messages - tail_start logger.info( "Summarizing turns %d-%d (%d turns), protecting %d head + %d tail messages", compress_start + 1, @@ -4174,6 +4336,11 @@ This compaction should PRIORITISE preserving all information related to the focu telemetry["failure_class"] = "summary_network_failure" else: telemetry["failure_class"] = "summary_generation_aborted" + # Roll back the self-heal rehydration so this aborted attempt is a + # true no-op: the next attempt must re-run the full first-compaction + # scan instead of narrow-rescanning against a half-populated state + # and discarding a legitimately rehydrated fossil (#57835). + self._previous_summary = _previous_summary_before_scan if not self.quiet_mode: if self._last_summary_auth_failure: logger.warning( @@ -4214,8 +4381,8 @@ This compaction should PRIORITISE preserving all information related to the focu # _strip_context_summary_handoff_message() handles both shapes: # standalone handoffs strip to None (dropped), merged handoffs # unwrap to their genuine prior-tail content (preserved). Do NOT - # short-circuit on summary_idx here: a merged handoff carries real - # user content that a blanket skip would silently delete. + # short-circuit on summary_indices here: a merged handoff carries + # real user content that a blanket skip would silently delete. msg = _fresh_compaction_message_copy(messages[i]) if i == 0 and msg.get("role") == "system": existing = msg.get("content") @@ -4246,17 +4413,26 @@ This compaction should PRIORITISE preserving all information related to the focu ) tail_messages: List[Dict[str, Any]] = [] - for i in range(compress_end, n_messages): + # Start at tail_start (not compress_end): the restart-decay scan may + # have advanced it past a summary that sat beyond compress_end + # (#57835). summary_indices rows are already rehydrated; the strip + # helper handles any that remain (standalone → dropped, merged → + # unwrapped to genuine prior-tail content, #47274). + for i in range(max(compress_end, tail_start), n_messages): + if i in summary_indices and i >= tail_start: + # A summary at/after tail_start was already folded into + # _previous_summary; don't re-emit it verbatim. + continue msg = _fresh_compaction_message_copy(messages[i]) stripped = self._strip_context_summary_handoff_message(msg) if stripped is not None: tail_messages.append(stripped) _merge_summary_into_tail = False - last_head_role = compressed[-1].get("role", "user") if compressed else "user" - # NOTE: derive the tail's leading role from tail_messages (post - # handoff-strip), not messages[compress_end] — a stripped stale + # last_head_role reads the assembled (post-strip) head; first_tail_role + # reads the assembled (post-strip) tail_messages — a stripped stale # handoff must not influence alternation-safe role selection. + last_head_role = compressed[-1].get("role", "user") if compressed else "user" first_tail_role = tail_messages[0].get("role", "user") if tail_messages else None # When the only protected head message is the system prompt, the # summary becomes the first *visible* message in the API request @@ -4265,7 +4441,7 @@ This compaction should PRIORITISE preserving all information related to the focu # Anthropic unconditionally rejects requests whose first message # is not role=user, so we must pin the summary to "user" and # prevent the flip logic below from reverting it (#52160). - _force_user_leading = last_head_role == "system" + _force_user_leading = compress_start == 0 or last_head_role == "system" # Zero-user-turn guard (#58753). The #52160 guard above only fires # when the system prompt sits *inside* ``messages`` (the gateway # ``/compress`` path). The main auto-compression path passes the @@ -4299,7 +4475,7 @@ This compaction should PRIORITISE preserving all information related to the focu summary_role = "assistant" # If the chosen role collides with the tail AND flipping wouldn't # collide with the head, flip it. - if first_tail_role and summary_role == first_tail_role: + if first_tail_role is not None and summary_role == first_tail_role: flipped = "assistant" if summary_role == "user" else "user" if flipped != last_head_role and not _force_user_leading: summary_role = flipped @@ -4332,27 +4508,39 @@ This compaction should PRIORITISE preserving all information related to the focu for tail_idx, msg in enumerate(tail_messages): if _merge_summary_into_tail and tail_idx == 0: - # Merge the summary into the first tail message, but place - # the END MARKER at the very end so the model sees an - # unambiguous boundary. Old tail content is preserved as - # reference material BEFORE the summary, clearly delimited - # so it is not mistaken for a new message to respond to. - # Uses _append_text_to_content to safely handle both - # string and multimodal-list content types. - # Fixes ghost-message leakage across compaction boundaries - # where old head messages survived verbatim and appeared - # before the summary. + # Merge the summary into the first (post-strip) tail message. old_content = msg.get("content", "") - suffix = ( - "\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n" - + summary + "\n\n" - + _SUMMARY_END_MARKER - ) - msg["content"] = _append_text_to_content( - _append_text_to_content(old_content, suffix, prepend=False), - _MERGED_PRIOR_CONTEXT_HEADER + "\n", - prepend=True, - ) + if _force_user_leading and summary_role == "user": + # The summary must be part of the first user-visible + # message for Anthropic/Bedrock, but the real tail request + # still has to appear *after* the summary boundary. + prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n" + msg["content"] = _append_text_to_content( + old_content, + prefix, + prepend=True, + ) + else: + # Merge the summary into the first tail message, but place + # the END MARKER at the very end so the model sees an + # unambiguous boundary. Old tail content is preserved as + # reference material BEFORE the summary, clearly delimited + # so it is not mistaken for a new message to respond to. + # Uses _append_text_to_content to safely handle both + # string and multimodal-list content types. + # Fixes ghost-message leakage across compaction boundaries + # where old head messages survived verbatim and appeared + # before the summary. + suffix = ( + "\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n" + + summary + "\n\n" + + _SUMMARY_END_MARKER + ) + msg["content"] = _append_text_to_content( + _append_text_to_content(old_content, suffix, prepend=False), + _MERGED_PRIOR_CONTEXT_HEADER + "\n", + prepend=True, + ) # Mark the merged message so frontends can identify it as # containing a compression summary prefix. msg[COMPRESSED_SUMMARY_METADATA_KEY] = True diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 4c105d4906e6..04206a407211 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -57,6 +57,71 @@ COMPACTION_STATUS = ( f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..." ) +COMPACTION_DONE_STATUS = "✓ Context compaction complete — continuing turn..." + + +def _emit_compaction_done(agent: Any) -> None: + """Emit the structured terminal edge for a started compaction.""" + status_callback = getattr(agent, "status_callback", None) + if not status_callback: + return + try: + status_callback("compacted", COMPACTION_DONE_STATUS) + except Exception: + logger.debug("status_callback error in compaction completion", exc_info=True) + + +# ── Routine compression status templates ──────────────────────────────────── +# Every ROUTINE (non-failure, non-manual-/compress) compression status line the +# agent emits lives here so the gateway noise filter and its tests can couple +# to the real emitted wording instead of hand-copied literals. These are +# suppressed on human-facing chat platforms by _TELEGRAM_NOISY_STATUS_RE +# (gateway/run.py) — when rewording ANY of them, update that regex and the +# pinned data in tests/gateway/test_telegram_noise_filter.py in the same PR. +# Failure notices (⚠ Compression aborted / empty transcript / codex compaction +# failed) and manual /compress feedback (manual_compression_feedback.py) are +# deliberate carve-outs from silence and must NOT be added here. +PRE_API_COMPRESSION_STATUS_TEMPLATE = ( + "📦 Pre-API compression: ~{tokens:,} tokens " + "near the context/output limit. Compacting before the next model call." +) +PREFLIGHT_COMPRESSION_STATUS_TEMPLATE = ( + "📦 Preflight compression: ~{tokens:,} tokens " + ">= {threshold:,} threshold. This may take a moment." +) +IDLE_COMPACTION_STATUS_TEMPLATE = ( + "💤 Resumed after {idle_seconds}s idle — compacting " + "~{tokens:,} tokens before continuing." +) +COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE = ( + "🗜️ Context too large (~{tokens:,} tokens) — compressing ({attempt}/{cap})..." +) +COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE = ( + "🗜️ Compressed {before} → {after} messages, retrying..." +) +COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE = ( + "🗜️ Compressed ~{before:,} → ~{after:,} tokens, retrying..." +) +COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE = ( + "🗜️ Context reduced to {new_ctx:,} tokens (was {old_ctx:,}), retrying..." +) + +# Sample-formatted instances of every routine compression status line, for +# behavioral tests that iterate the ACTUAL emitted wording (formatted from the +# same constants the emission sites use) through the gateway noise filter. +ROUTINE_COMPRESSION_STATUS_SAMPLES = ( + COMPACTION_STATUS, + PRE_API_COMPRESSION_STATUS_TEMPLATE.format(tokens=123456), + PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(tokens=120000, threshold=100000), + IDLE_COMPACTION_STATUS_TEMPLATE.format(idle_seconds=3600, tokens=120000), + COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=250000, attempt=1, cap=3), + COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=30, after=12), + COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=250000, after=120000), + COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format( + new_ctx=120000, old_ctx=250000 + ), +) + def _builtin_memory_prompt_snapshot(agent: Any) -> Optional[Tuple[str, str]]: """Return the built-in memory text that can affect a system prompt. @@ -1025,6 +1090,14 @@ def compress_context( focus_topic, ) agent._emit_status(COMPACTION_STATUS) + _compaction_done_emitted = False + + def _complete_compaction_lifecycle() -> None: + nonlocal _compaction_done_emitted + if _compaction_done_emitted: + return + _compaction_done_emitted = True + _emit_compaction_done(agent) # ── Compression lock ──────────────────────────────────────────────── # Atomic, state.db-backed lock per session_id. Without this, two @@ -1174,12 +1247,14 @@ def compress_context( split_status="aborted", failure_class="lock_contended", ) + _complete_compaction_lifecycle() return messages, _existing_sp _lock_released = False def _release_lock() -> None: """Release the lock keyed on the OLD session_id (before rotation).""" nonlocal _lock_released + _complete_compaction_lifecycle() if _lock_released: return _lock_released = True @@ -1876,6 +1951,15 @@ def _compress_context_via_codex_app_server( except Exception: pass + _compaction_done_emitted = False + + def _complete_compaction_lifecycle() -> None: + nonlocal _compaction_done_emitted + if _compaction_done_emitted: + return + _compaction_done_emitted = True + _emit_compaction_done(agent) + _activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None try: _activity_heartbeat = _CompressionActivityHeartbeat(agent).start() @@ -1883,6 +1967,7 @@ def _compress_context_via_codex_app_server( except BaseException: if _activity_heartbeat is not None: _activity_heartbeat.stop("context compression failed") + _complete_compaction_lifecycle() raise if getattr(result, "interrupted", False) or getattr(result, "error", None): @@ -1907,6 +1992,7 @@ def _compress_context_via_codex_app_server( existing_prompt = getattr(agent, "_cached_system_prompt", None) if not existing_prompt: existing_prompt = agent._build_system_prompt(system_message) + _complete_compaction_lifecycle() return messages, existing_prompt try: @@ -1946,6 +2032,7 @@ def _compress_context_via_codex_app_server( existing_prompt = getattr(agent, "_cached_system_prompt", None) if not existing_prompt: existing_prompt = agent._build_system_prompt(system_message) + _complete_compaction_lifecycle() return messages, existing_prompt @@ -2206,6 +2293,7 @@ def try_shrink_image_parts_in_messages( __all__ = [ "COMPACTION_STATUS", + "COMPACTION_DONE_STATUS", "COMPACTION_STATUS_MARKER", "check_compression_model_feasibility", "replay_compression_warning", diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 83070eccb22e..2b1b57056eea 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -28,7 +28,14 @@ import uuid from typing import Any, Dict, List, Optional from agent.codex_responses_adapter import _summarize_user_message_for_log -from agent.conversation_compression import conversation_history_after_compression +from agent.conversation_compression import ( + COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE, + COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE, + COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE, + COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE, + PRE_API_COMPRESSION_STATUS_TEMPLATE, + conversation_history_after_compression, +) from agent.display import KawaiiSpinner from agent.error_classifier import FailoverReason, classify_api_error from agent.iteration_budget import IterationBudget @@ -103,6 +110,53 @@ _API_CALL_MODULES = frozenset({ }) +def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None: + """Append a provider-safe checkpoint and correction to the live turn. + + Incomplete provider reasoning blocks are not valid replay items (Anthropic + signs them; Responses reasoning items require their following output). + Preserve only what Hermes actually displayed, demoted to ordinary text, + then add the correction as a real user message. This keeps role alternation + valid and leaves every previously cached message byte-for-byte unchanged. + """ + reasoning = str( + getattr(agent, "_current_streamed_reasoning_text", "") or "" + ).strip() + visible = agent._strip_think_blocks( + getattr(agent, "_current_streamed_assistant_text", "") or "" + ).strip() + + checkpoint_parts = ["[This response was interrupted by a user correction.]"] + if reasoning: + checkpoint_parts.extend( + ["Reasoning shown before the interruption:", reasoning] + ) + if visible: + checkpoint_parts.extend( + ["Visible response before the interruption:", visible] + ) + checkpoint = "\n\n".join(checkpoint_parts) + + # The normal live tail is user or tool, so an assistant checkpoint followed + # by the correction preserves strict alternation. If a transport already + # committed an assistant item, attribute the checkpoint inside the user + # correction instead of creating assistant→assistant. + if messages and messages[-1].get("role") == "assistant": + correction = ( + "[Context from the interrupted assistant response]\n" + f"{checkpoint}\n\n" + f"{text}" + ) + messages.append({"role": "user", "content": correction}) + else: + messages.append({"role": "assistant", "content": checkpoint}) + messages.append({"role": "user", "content": text}) + + agent._current_streamed_assistant_text = "" + agent._current_streamed_reasoning_text = "" + agent._stream_needs_break = True + + def _image_error_max_dimension(error: Exception) -> Optional[int]: """Extract a provider-reported image dimension ceiling, if present.""" parts = [] @@ -253,6 +307,19 @@ def _billing_or_entitlement_message( ] return "\n".join(lines) + # Provider-agnostic billing URL derivation (OpenAI, DeepSeek, xAI, Groq, + # OpenRouter, …) so every text surface — CLI, gateway messaging, TUI + # transcript — shows the same actionable link, not just OpenRouter. + try: + from agent.billing_links import build_billing_block + + _link = build_billing_block(provider=provider, base_url=base_url, model=model) + if _link.provider_label: + provider_label = _link.provider_label + billing_url = _link.billing_url + except Exception: + billing_url = None + lines = [ ( f"{provider_label} reported that billing, credits, or account " @@ -260,12 +327,24 @@ def _billing_or_entitlement_message( ), "Add credits or update billing with that provider, then retry.", ] - if base_url_host_matches(str(base_url or ""), "openrouter.ai"): - lines.append("OpenRouter credits: https://openrouter.ai/settings/credits") + if billing_url: + lines.append(f"{provider_label} billing: {billing_url}") lines.append("You can switch providers temporarily with /model --provider .") return "\n".join(lines) +def _billing_block_dict(provider, base_url, model, message="") -> Optional[dict]: + """Best-effort structured billing descriptor (None if billing_links is unavailable).""" + try: + from agent.billing_links import build_billing_block + + return build_billing_block( + provider=provider, base_url=str(base_url), model=model, message=message + ).to_dict() + except Exception: + return None + + def _print_billing_or_entitlement_guidance( agent, *, @@ -733,6 +812,16 @@ def run_conversation( ) while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call: + _redirect_text = agent._drain_pending_redirect() + if _redirect_text: + _apply_active_turn_redirect(agent, messages, _redirect_text) + if isinstance(original_user_message, str): + original_user_message = ( + f"{original_user_message}\n\n" + f"User correction during the turn: {_redirect_text}" + ) + agent._persist_session(messages, conversation_history) + # Reset per-turn checkpoint dedup so each iteration can take one snapshot agent._checkpoint_mgr.new_turn() @@ -1222,8 +1311,9 @@ def run_conversation( max_compression_attempts, ) agent._emit_status( - f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens " - f"near the context/output limit. Compacting before the next model call." + PRE_API_COMPRESSION_STATUS_TEMPLATE.format( + tokens=request_pressure_tokens + ) ) _last_preflight_pressure = request_pressure_tokens messages, active_system_prompt = agent._compress_context( @@ -1554,22 +1644,59 @@ def run_conversation( from hermes_cli.middleware import run_llm_execution_middleware - response = run_llm_execution_middleware( - api_kwargs, - _perform_api_call, - original_request=_original_api_kwargs, - task_id=effective_task_id, - turn_id=turn_id, - api_request_id=api_request_id, - session_id=agent.session_id or "", - platform=agent.platform or "", - model=agent.model, - provider=agent.provider, - base_url=agent.base_url, - api_mode=agent.api_mode, - api_call_count=api_call_count, - middleware_trace=list(_llm_middleware_trace), - ) + _model_request_active = getattr(agent, "_model_request_active", None) + _redirect_lock = getattr(agent, "_pending_redirect_lock", None) + if _redirect_lock is not None: + with _redirect_lock: + if _model_request_active is not None: + _model_request_active.set() + elif _model_request_active is not None: + _model_request_active.set() + _redirect_crossed_response = False + try: + response = run_llm_execution_middleware( + api_kwargs, + _perform_api_call, + original_request=_original_api_kwargs, + task_id=effective_task_id, + turn_id=turn_id, + api_request_id=api_request_id, + session_id=agent.session_id or "", + platform=agent.platform or "", + model=agent.model, + provider=agent.provider, + base_url=agent.base_url, + api_mode=agent.api_mode, + api_call_count=api_call_count, + middleware_trace=list(_llm_middleware_trace), + ) + finally: + if _redirect_lock is not None: + with _redirect_lock: + if _model_request_active is not None: + _model_request_active.clear() + _redirect_crossed_response = bool( + agent._pending_redirect + ) + else: + if _model_request_active is not None: + _model_request_active.clear() + _redirect_crossed_response = agent._has_pending_redirect() + if _redirect_crossed_response: + # The response and redirect can cross on different threads: + # redirect() observed the request as active just before this + # call returned. Discard that now-stale response and rebuild + # from the correction rather than silently losing it. + if thinking_spinner: + thinking_spinner.stop("") + thinking_spinner = None + if agent.thinking_callback: + agent.thinking_callback("") + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + else: + interrupted = True + break api_duration = time.time() - api_start_time @@ -2238,13 +2365,17 @@ def run_conversation( force=True, ) agent._cleanup_task_resources(effective_task_id) - agent._persist_session(messages, conversation_history) _final_response = ( "Stream repeatedly dropped mid tool-call (network); " "the tool was not executed" if _is_stub_stall else "Response truncated due to output length limit" ) + # Prior successful tool batches (or injected tool + # errors) can leave a tool-result tail; this path + # never reaches finalize_turn (#48879 class). + close_interrupted_tool_sequence(messages, _final_response) + agent._persist_session(messages, conversation_history) return { "final_response": _final_response, "messages": messages, @@ -2537,6 +2668,15 @@ def run_conversation( thinking_spinner = None if agent.thinking_callback: agent.thinking_callback("") + if agent._has_pending_redirect(): + # redirect() deliberately used the interrupt machinery to + # cancel only this provider request. Keep its correction + # queued, clear the cancellation bit, and let the outer + # loop rebuild a clean request tail. Never materialize + # incomplete signed/encrypted reasoning items. + if agent.clear_interrupt(preserve_redirect=True): + _retry.restart_with_redirected_messages = True + break api_elapsed = time.time() - api_start_time agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True) interrupted = True @@ -3419,8 +3559,9 @@ def run_conversation( ) if len(messages) < original_len or old_ctx > _reduced_ctx: agent._buffer_status( - f"🗜️ Context reduced to {_reduced_ctx:,} tokens " - f"(was {old_ctx:,}), retrying..." + COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format( + new_ctx=_reduced_ctx, old_ctx=old_ctx + ) ) time.sleep(2) _retry.restart_with_compressed_messages = True @@ -3681,9 +3822,9 @@ def run_conversation( if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95): if len(messages) < original_len: - agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") + agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages))) else: - agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...") + agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens)) time.sleep(2) # Brief pause between compression retries _retry.restart_with_compressed_messages = True break @@ -3901,7 +4042,7 @@ def run_conversation( "failed": True, "compression_exhausted": True, } - agent._buffer_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...") + agent._buffer_status(COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=approx_tokens, attempt=compression_attempts, cap=max_compression_attempts)) original_len = len(messages) original_tokens = estimate_messages_tokens_rough(messages) @@ -3922,9 +4063,9 @@ def run_conversation( if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95) or (new_ctx and new_ctx < old_ctx): if len(messages) < original_len: - agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...") + agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages))) elif new_tokens > 0 and new_tokens < original_tokens * 0.95: - agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...") + agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens)) time.sleep(2) # Brief pause between compression retries _retry.restart_with_compressed_messages = True break @@ -4200,6 +4341,31 @@ def run_conversation( final_response=_policy_response, error_detail=_nonretryable_summary, ) + # Billing walls are the common non-retryable abort: enrich + # the result with the same structured recovery descriptor as + # the max-retries path so every surface (CLI, TUI, desktop) + # renders one consistent billing signal. + if classified.reason == FailoverReason.billing: + _ce_guidance = _billing_or_entitlement_message( + capability="model access", + provider=_provider, + base_url=str(_base), + model=_model, + ) + _ce_final = f"Billing or credits exhausted: {_nonretryable_summary}" + if _ce_guidance: + _ce_final += f"\n\n{_ce_guidance}" + _ce_block = _billing_block_dict(_provider, _base, _model, _ce_guidance) + return { + "final_response": _ce_final, + "messages": messages, + "api_calls": api_call_count, + "completed": False, + "failed": True, + "error": _nonretryable_summary, + "failure_reason": classified.reason.value, + "billing_block": _ce_block, + } return { "final_response": _nonretryable_summary, "messages": messages, @@ -4360,10 +4526,14 @@ def run_conversation( api_kwargs, reason="max_retries_exhausted", error=api_error, ) agent._persist_session(messages, conversation_history) + _billing_block = None if classified.reason == FailoverReason.billing: _final_response = f"Billing or credits exhausted: {_final_summary}" if _billing_guidance: _final_response += f"\n\n{_billing_guidance}" + # Structured recovery descriptor so every surface renders + # the same link + label from one signal (see helper). + _billing_block = _billing_block_dict(_provider, _base, _model, _billing_guidance) else: _final_response = f"API call failed after {max_retries} retries: {_final_summary}" if _is_thinking_timeout: @@ -4403,6 +4573,9 @@ def run_conversation( # different exit code. ``rate_limit`` / ``billing`` here # mean "quota wall, not a task error". "failure_reason": classified.reason.value, + # Present only for billing walls: structured recovery + # descriptor (provider, billing_url, is_nous, message). + "billing_block": _billing_block, } # For rate limits, respect the Retry-After header if present @@ -4485,6 +4658,15 @@ def run_conversation( f"{int(sleep_end - time.time())}s remaining" ) + if _retry.restart_with_redirected_messages: + # The cancelled request produced no valid assistant item. Reuse the + # same logical iteration after the outer loop appends the displayed + # partial context and correction to ``messages``. + api_call_count -= 1 + agent.iteration_budget.refund() + _retry.restart_with_redirected_messages = False + continue + # If the API call was interrupted, skip response processing if interrupted: _turn_exit_reason = "interrupted_during_api_call" @@ -4872,8 +5054,13 @@ def run_conversation( agent._flush_status_buffer() agent._vprint(f"{agent.log_prefix}❌ Max retries (3) for invalid tool calls exceeded. Stopping as partial.", force=True) agent._invalid_tool_retries = 0 - agent._persist_session(messages, conversation_history) _final_response = f"Model generated invalid tool call: {invalid_preview}" + # Prior <3 retries (or an earlier successful tool batch) + # leave a tool-result tail. Closing it here matches + # interrupt aborts (#48879 / #52592) so the next user + # turn is not tool→user for strict providers. + close_interrupted_tool_sequence(messages, _final_response) + agent._persist_session(messages, conversation_history) return { "final_response": _final_response, "messages": messages, @@ -4953,14 +5140,18 @@ def run_conversation( ) agent._invalid_json_retries = 0 agent._cleanup_task_resources(effective_task_id) + _final_response = "Response truncated due to output length limit" + # Same tool-tail close as interrupt / invalid-tool + # exhaustion — this path never reaches finalize_turn. + close_interrupted_tool_sequence(messages, _final_response) agent._persist_session(messages, conversation_history) return { - "final_response": "Response truncated due to output length limit", + "final_response": _final_response, "messages": messages, "api_calls": api_call_count, "completed": False, "partial": True, - "error": "Response truncated due to output length limit", + "error": _final_response, } # Track retries for invalid JSON arguments diff --git a/agent/credential_pool.py b/agent/credential_pool.py index 15b1458860e3..f8109a1aa458 100644 --- a/agent/credential_pool.py +++ b/agent/credential_pool.py @@ -1484,6 +1484,43 @@ class CredentialPool: self._sync_device_code_entry_to_auth_store(updated) return updated + def _codex_quota_restored_upstream(self, entry: PooledCredential) -> bool: + """Live-check whether an exhausted Codex entry's quota reset early. + + A Codex 429 persists a ``last_error_reset_at`` that can be days in + the future (weekly windows), but the upstream window can reopen + before then — the user redeems a banked rate-limit reset via the + Codex CLI / ChatGPT UI, upgrades their plan, or OpenAI resets the + window. Without this check the pool keeps the credential frozen + until the stale timestamp elapses even though the account is + usable (issue #43747). + + Only fires for openai-codex entries frozen by a 429/quota-shaped + error. The underlying probe is throttled per token (5 min) so this + is safe on the hot selection path. + """ + if self.provider != "openai-codex" or entry.last_status != STATUS_EXHAUSTED: + return False + if not auth_mod._is_codex_rate_limit_shaped( + entry.last_error_code, + entry.last_error_reason, + entry.last_error_message, + ): + return False + token = entry.access_token or "" + if not token: + return False + try: + return bool( + auth_mod._probe_codex_quota_restored( + token, + base_url=entry.base_url, + ) + ) + except Exception: + logger.debug("Codex quota-restored probe failed", exc_info=True) + return False + def _entry_needs_refresh(self, entry: PooledCredential) -> bool: if entry.auth_type != AUTH_TYPE_OAUTH: return False @@ -1605,7 +1642,18 @@ class CredentialPool: if entry.last_status == STATUS_EXHAUSTED: exhausted_until = _exhausted_until(entry) if exhausted_until is not None and now < exhausted_until: - continue + # Codex quota windows can reopen EARLY: the user redeems a + # banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades + # their plan, or OpenAI resets the window. The persisted + # ``last_error_reset_at`` can then be days in the future + # while the account is already usable again — a throttled + # live probe of the Codex usage endpoint detects that and + # lifts the stale cooldown (issue #43747). + if not ( + clear_expired + and self._codex_quota_restored_upstream(entry) + ): + continue if clear_expired: cleared = replace( entry, diff --git a/agent/credits_tracker.py b/agent/credits_tracker.py index f2186fb91a56..929bc34d3262 100644 --- a/agent/credits_tracker.py +++ b/agent/credits_tracker.py @@ -316,12 +316,21 @@ def evaluate_credits_notices( active.discard(CREDITS_USAGE_KEY) if target_band is not None: # Belt-and-suspenders: a producer could set subscription_limit_micros - # without subscription_limit_usd. Render "$? cap" rather than "$None cap". + # without subscription_limit_usd. Render "$?" rather than "$None". _cap_usd = state.subscription_limit_usd or "?" _level = current_band[1] # type: ignore[index] (current_band set when target_band set) + # Report absolute dollars used, not a bare "N% used": the percentage is + # only meaningful against a Nous subscription cap (no cap → never fires), + # so dollars are clearer and don't imply a universal %. Used = cap − + # remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band + # change (50 → 75 → 90), not every turn — a snapshot, not a live ticker. + _lim = state.subscription_limit_micros or 0 + _used_micros = max(0, min(_lim, _lim - state.subscription_micros)) + _used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?" + _glyph = "⚠" if _level == "warn" else "•" to_show.append( AgentNotice( - text=f"{'⚠' if _level == 'warn' else '•'} Credits {target_band}% used · ${_cap_usd} cap", + text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap", level=_level, kind=CREDITS_NOTICE_KIND, key=CREDITS_USAGE_KEY, diff --git a/agent/i18n.py b/agent/i18n.py index ef9fd4b06c28..b55b8128c92d 100644 --- a/agent/i18n.py +++ b/agent/i18n.py @@ -32,7 +32,6 @@ from __future__ import annotations import logging import os -import sysconfig import threading from functools import lru_cache from pathlib import Path @@ -92,12 +91,8 @@ def _locales_dir() -> Path: 1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any sealed-packaging system) to point at the installed catalog directory. - 2. ``/locales`` -- source checkouts and ``pip install -e .``, + 2. ``/locales`` -- source checkouts and editable installs, where the working tree sits next to ``agent/``. - 3. ``/locales`` -- pip wheel installs. - setuptools ``data-files`` extracts ``locales/*.yaml`` under the - interpreter's ``data`` scheme; the other schemes are checked as a - safety net for nonstandard layouts. Falling through to the source-style path (even when missing) keeps ``_load_catalog`` error messages informative -- it logs the path it @@ -116,25 +111,6 @@ def _locales_dir() -> Path: # agent/i18n.py -> agent/ -> repo root (source checkout, editable install) source_dir = Path(__file__).resolve().parent.parent / "locales" - if source_dir.is_dir(): - return source_dir - - # pip wheel install: data-files lands under the interpreter data scheme. - # ``data`` (== sys.prefix in a venv) is where setuptools data-files extract - # and is checked first. ``purelib``/``platlib`` (site-packages) are a safety - # net for nonstandard layouts. NOTE: this does NOT cover ``pip install - # --user`` (user scheme, ~/.local/locales) or ``pip install --target`` -- - # both are out of scope; see the plan header. - for scheme in ("data", "purelib", "platlib"): - raw = sysconfig.get_path(scheme) - if not raw: - continue - candidate = Path(raw) / "locales" - if candidate.is_dir(): - return candidate - - # Last resort: return the source-style path so _load_catalog's catalog-missing - # log (logger.debug "i18n catalog missing for %s at %s") stays informative. return source_dir diff --git a/agent/image_routing.py b/agent/image_routing.py index 8cfa085527c4..b5475bb05ce5 100644 --- a/agent/image_routing.py +++ b/agent/image_routing.py @@ -181,6 +181,8 @@ def _supports_vision_override( cfg: Optional[Dict[str, Any]], provider: str, model: str, + *, + requested_provider: str = "", ) -> Optional[bool]: """Resolve user-declared vision capability from config.yaml. @@ -188,9 +190,10 @@ def _supports_vision_override( 1. ``model.supports_vision`` (top-level shortcut for the active model) 2. ``providers..models..supports_vision`` (named custom providers — ``provider`` may be the runtime-resolved - value ``"custom"`` and/or the user-declared name under - ``model.provider``; both are tried. For ``custom:`` syntax, - the stripped ```` is also tried as a provider key.) + value ``"custom"``, the runtime's originally requested provider, + and/or the user-declared name under ``model.provider``; all are + tried. For ``custom:`` syntax, the stripped ```` is also + tried as a provider key.) Returns None when no override is set, so the caller falls through to models.dev. Returns False explicitly only when the user wrote a @@ -210,16 +213,21 @@ def _supports_vision_override( # get rewritten to provider="custom" at runtime # (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the # config still holds the user-declared name under model.provider. Try - # both as candidate provider keys, plus the stripped suffix from - # "custom:" (where is the key under providers:). + # both as candidate provider keys. Either identity may use the + # "custom:" form while providers: is keyed by bare . config_provider = str(model_cfg.get("provider") or "").strip() - # Extract the stripped name from "custom:" if present - stripped_suffix = "" - if config_provider.startswith("custom:"): - stripped_suffix = config_provider[len("custom:"):] + provider_candidates: List[str] = [] + for candidate in (requested_provider, provider, config_provider): + if not candidate: + continue + provider_candidates.append(candidate) + if candidate.startswith("custom:"): + stripped_candidate = candidate[len("custom:"):] + if stripped_candidate: + provider_candidates.append(stripped_candidate) providers_raw = cfg.get("providers") providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {} - for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))): + for p in dict.fromkeys(provider_candidates): entry_raw = providers_cfg.get(p) entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {} models_raw = entry.get("models") @@ -235,28 +243,24 @@ def _supports_vision_override( # may appear as the raw name or "custom:" at runtime). custom_providers = cfg.get("custom_providers") if isinstance(custom_providers, list): - # Build candidate names: the provider value and the config provider - # value, both raw and with "custom:" prefix stripped/added. - candidate_names: set = set() - for p in filter(None, (provider, config_provider)): - candidate_names.add(p) - if p.startswith("custom:"): - candidate_names.add(p[len("custom:"):]) - else: - candidate_names.add(f"custom:{p}") - for entry_raw in custom_providers: - if not isinstance(entry_raw, dict): - continue - entry_name = str(entry_raw.get("name") or "").strip() - if entry_name not in candidate_names: - continue - models_raw = entry_raw.get("models") - models_cfg = models_raw if isinstance(models_raw, dict) else {} - per_model_raw = models_cfg.get(model) - per_model = per_model_raw if isinstance(per_model_raw, dict) else {} - coerced = _coerce_capability_bool(per_model.get("supports_vision")) - if coerced is not None: - return coerced + # Candidate priority matters when the CLI-selected provider differs + # from model.provider. Walk identities first, then config entries, so + # list order cannot let the persisted default shadow the live route. + for candidate in dict.fromkeys(provider_candidates): + candidate_name = candidate.strip().lower() + for entry_raw in custom_providers: + if not isinstance(entry_raw, dict): + continue + entry_name = str(entry_raw.get("name") or "").strip().lower() + if entry_name != candidate_name: + continue + models_raw = entry_raw.get("models") + models_cfg = models_raw if isinstance(models_raw, dict) else {} + per_model_raw = models_cfg.get(model) + per_model = per_model_raw if isinstance(per_model_raw, dict) else {} + coerced = _coerce_capability_bool(per_model.get("supports_vision")) + if coerced is not None: + return coerced return None @@ -376,6 +380,8 @@ def _lookup_supports_vision( provider: str, model: str, cfg: Optional[Dict[str, Any]] = None, + *, + requested_provider: str = "", ) -> Optional[bool]: """Return True/False if we can resolve caps, None if unknown. @@ -383,7 +389,34 @@ def _lookup_supports_vision( (so custom/local models declared as vision-capable don't fall through to text routing in ``auto`` mode), then falls back to models.dev. """ - override = _supports_vision_override(cfg, provider, model) + # Named custom providers are canonicalized to ``provider="custom"`` by + # runtime resolution. The original CLI/config name is carried in the + # context-local main runtime so capability lookup can still select the + # exact custom_providers entry. Require an exact provider+model match: + # background/auxiliary lookups must never borrow another turn's identity. + if not requested_provider: + try: + from agent.auxiliary_client import _runtime_main_value + + runtime_provider = str( + _runtime_main_value("provider") or "" + ).strip().lower() + runtime_model = str(_runtime_main_value("model") or "").strip() + lookup_provider = str(provider or "").strip().lower() + lookup_model = str(model or "").strip() + if runtime_provider == lookup_provider and runtime_model == lookup_model: + requested_provider = str( + _runtime_main_value("requested_provider") or "" + ).strip() + except Exception: + pass + + override = _supports_vision_override( + cfg, + provider, + model, + requested_provider=requested_provider, + ) if override is not None: return override if not provider or not model: @@ -421,6 +454,8 @@ def decide_image_input_mode( provider: str, model: str, cfg: Optional[Dict[str, Any]], + *, + requested_provider: str = "", ) -> str: """Return ``"native"`` or ``"text"`` for the given turn. @@ -428,6 +463,7 @@ def decide_image_input_mode( provider: active inference provider ID (e.g. ``"anthropic"``, ``"openrouter"``). model: active model slug as it would be sent to the provider. cfg: loaded config.yaml dict, or None. When None, behaves as auto. + requested_provider: provider identity before runtime canonicalization. """ mode_cfg = "auto" if isinstance(cfg, dict): @@ -444,7 +480,17 @@ def decide_image_input_mode( # explicit auxiliary.vision config acts as a *fallback* for text-only # main models — it should not preempt native vision on a model that # can natively inspect the pixels (issue #29135). - supports = _lookup_supports_vision(provider, model, cfg) + if requested_provider: + supports = _lookup_supports_vision( + provider, + model, + cfg, + requested_provider=requested_provider, + ) + else: + # Keep the long-standing three-argument call contract for callers and + # tests that replace the capability lookup hook. + supports = _lookup_supports_vision(provider, model, cfg) if supports is True: return "native" if _explicit_aux_vision_override(cfg): diff --git a/agent/onboarding.py b/agent/onboarding.py index c29ea1529fb0..148fbcc9fdae 100644 --- a/agent/onboarding.py +++ b/agent/onboarding.py @@ -52,6 +52,13 @@ def busy_input_hint_gateway(mode: str) -> str: "Send `/busy interrupt` or `/busy queue` to change this, or " "`/busy status` to check. This notice won't appear again." ) + if mode == "redirect": + return ( + "💡 First-time tip — I redirected the current run using your message. " + "Completed work stays in context, and `/stop` still cancels the task. " + "Send `/busy queue` to wait for a separate turn, or `/busy status` " + "to check. This notice won't appear again." + ) return ( "💡 First-time tip — I just interrupted my current task to answer you. " "Send `/busy queue` to queue follow-ups for after the current task instead, " @@ -74,6 +81,12 @@ def busy_input_hint_cli(mode: str) -> str: "after the next tool call. Use /busy interrupt or /busy queue to " "change this. This tip only shows once." ) + if mode == "redirect": + return ( + "(tip) Your correction redirected the current run without discarding " + "completed work. Use /stop to cancel or /busy queue to wait for a " + "separate turn. This tip only shows once." + ) return ( "(tip) Your message interrupted the current run. " "Use /busy queue to queue messages for the next turn instead, " diff --git a/agent/transports/codex_app_server_session.py b/agent/transports/codex_app_server_session.py index 2e1cc64bdcbc..7954ecbf4d52 100644 --- a/agent/transports/codex_app_server_session.py +++ b/agent/transports/codex_app_server_session.py @@ -300,6 +300,8 @@ class CodexAppServerSession: self._client: Optional[CodexAppServerClient] = None self._thread_id: Optional[str] = None self._interrupt_event = threading.Event() + self._active_turn_id: Optional[str] = None + self._active_turn_lock = threading.Lock() # Pending file-change items, keyed by item id. Populated on # item/started for fileChange items; consumed by the approval # bridge when codex sends item/fileChange/requestApproval. The @@ -374,6 +376,8 @@ class CodexAppServerSession: if self._closed: return self._closed = True + with self._active_turn_lock: + self._active_turn_id = None if self._client is not None: try: self._client.close() @@ -395,6 +399,33 @@ class CodexAppServerSession: and unwind. Called by AIAgent's _interrupt_requested path.""" self._interrupt_event.set() + def request_steer(self, text: str) -> bool: + """Append user guidance to the active Codex turn via ``turn/steer``.""" + cleaned = str(text or "").strip() + if not cleaned: + return False + with self._active_turn_lock: + turn_id = self._active_turn_id + thread_id = self._thread_id + client = self._client + if not turn_id or not thread_id or client is None: + return False + try: + response = client.request( + "turn/steer", + { + "threadId": thread_id, + "input": [{"type": "text", "text": cleaned}], + "expectedTurnId": turn_id, + }, + timeout=10, + ) + except (CodexAppServerError, TimeoutError): + logger.debug("turn/steer rejected for active Codex turn", exc_info=True) + return False + accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None + return accepted_turn_id in {None, turn_id} + # ---------- diagnostics ---------- def _format_error_with_stderr( @@ -469,11 +500,18 @@ class CodexAppServerSession: # Subprocess almost certainly unhealthy — retire so the next # turn re-spawns cleanly. result.should_retire = True + self._interrupt_event.clear() return result assert self._client is not None and self._thread_id is not None result.thread_id = self._thread_id - self._interrupt_event.clear() + # Do not clear here: a hard stop can arrive while ensure_started() is + # spawning/initializing the subprocess. Honor it before launching a + # Codex turn instead of erasing the signal. + if self._interrupt_event.is_set(): + result.interrupted = True + self._interrupt_event.clear() + return result projector = CodexEventProjector() user_input_text = _coerce_turn_input_text(user_input) @@ -505,6 +543,7 @@ class CodexAppServerSession: result.error = self._format_error_with_stderr( "turn/start failed", exc ) + self._interrupt_event.clear() return result except TimeoutError as exc: # turn/start hanging is a strong signal the subprocess is wedged. @@ -514,9 +553,12 @@ class CodexAppServerSession: "turn/start timed out", exc ) result.should_retire = True + self._interrupt_event.clear() return result result.turn_id = (ts.get("turn") or {}).get("id") + with self._active_turn_lock: + self._active_turn_id = result.turn_id deadline = time.monotonic() + turn_timeout turn_complete = False # Post-tool watchdog state. last_tool_completion_at is set whenever @@ -741,6 +783,9 @@ class CodexAppServerSession: ) result.should_retire = True + with self._active_turn_lock: + self._active_turn_id = None + self._interrupt_event.clear() return result def compact_thread( diff --git a/agent/turn_context.py b/agent/turn_context.py index d05bd4bacb25..bb02960fb5f5 100644 --- a/agent/turn_context.py +++ b/agent/turn_context.py @@ -26,11 +26,16 @@ from __future__ import annotations import logging import threading +import time import uuid from dataclasses import dataclass from typing import Any, Dict, List, Mapping, Optional -from agent.conversation_compression import conversation_history_after_compression +from agent.conversation_compression import ( + IDLE_COMPACTION_STATUS_TEMPLATE, + PREFLIGHT_COMPRESSION_STATUS_TEMPLATE, + conversation_history_after_compression, +) from agent.iteration_budget import IterationBudget from agent.memory_manager import build_memory_context_block from agent.model_metadata import ( @@ -255,6 +260,40 @@ def _should_run_preflight_estimate( return estimate_messages_tokens_rough(messages) >= threshold_tokens +def _should_idle_compact( + *, + enabled: bool, + idle_after_seconds: int, + idle_gap_seconds: float, + tokens: int, + floor_tokens: int, + cooldown_active: bool, +) -> bool: + """Decide whether an idle-triggered compaction should run this turn. + + Idle compaction is opt-in (``idle_after_seconds <= 0`` disables it). It + fires when a session resumes after a wall-clock gap of at least + ``idle_after_seconds`` since its last activity, so a long-lived thread + that is paused and later resumed compacts its accumulated history up + front instead of re-reading it on every subsequent turn. + + It is orthogonal to the token-threshold trigger: it does NOT require the + context to exceed ``threshold_tokens``. It still skips work when the + context is at or below ``floor_tokens`` (the size compaction would reduce + *to*), so a small idle thread never pays for a summarisation that saves + nothing, and it defers to an active compression-failure cooldown. + + Pure predicate so the policy is unit-testable without a live agent. + """ + if not enabled or idle_after_seconds <= 0: + return False + if idle_gap_seconds < idle_after_seconds: + return False + if cooldown_active: + return False + return tokens > floor_tokens + + @dataclass class TurnContext: """Values produced by the turn prologue and consumed by the turn loop.""" @@ -336,6 +375,7 @@ def build_turn_context( set_runtime_main( getattr(agent, "provider", "") or "", getattr(agent, "model", "") or "", + requested_provider=getattr(agent, "requested_provider", "") or "", base_url=getattr(agent, "base_url", "") or "", api_key=getattr(agent, "api_key", "") or "", api_mode=getattr(agent, "api_mode", "") or "", @@ -584,6 +624,78 @@ def build_turn_context( if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"): agent._pending_cli_user_message = None + # ── Idle-triggered compaction (opt-in; ``idle_compact_after_seconds``) ── + # When a session resumes after a long idle gap, compact the accumulated + # history up front so the rest of the conversation does not keep re-reading + # a large stale context on every turn. This fires on elapsed wall-clock time + # rather than size, so it complements (does not replace) the token-threshold + # preflight below. ``_last_activity_ts`` is the last time this turn loop did + # work; nothing has touched it yet this turn, so it measures the gap since + # the previous turn finished. The cheap gap pre-check gates the (more + # expensive) token estimate, mirroring ``_should_run_preflight_estimate``. + _idle_after = getattr(agent, "compression_idle_compact_after_seconds", 0) + if agent.compression_enabled and _idle_after > 0 and messages: + _idle_gap = time.time() - getattr(agent, "_last_activity_ts", time.time()) + if _idle_gap >= _idle_after: + _compressor = agent.context_compressor + _idle_tokens = estimate_request_tokens_rough( + messages, + system_prompt=active_system_prompt or "", + tools=agent.tools or None, + ) + # Post-compression target size: don't summarise a thread already + # below what compaction would reduce it to. + _idle_floor = int( + _compressor.threshold_tokens * _compressor.summary_target_ratio + ) + _idle_cooldown = getattr( + _compressor, "get_active_compression_failure_cooldown", lambda: None + )() + if _should_idle_compact( + enabled=agent.compression_enabled, + idle_after_seconds=_idle_after, + idle_gap_seconds=_idle_gap, + tokens=_idle_tokens, + floor_tokens=_idle_floor, + cooldown_active=bool(_idle_cooldown), + ): + logger.info( + "Idle compaction: %ss idle >= %ss, ~%s tokens > %s floor " + "(session %s)", + int(_idle_gap), + _idle_after, + f"{_idle_tokens:,}", + f"{_idle_floor:,}", + agent.session_id or "none", + ) + agent._emit_status( + IDLE_COMPACTION_STATUS_TEMPLATE.format( + idle_seconds=int(_idle_gap), tokens=_idle_tokens + ) + ) + _idle_input = messages + messages, active_system_prompt = agent._compress_context( + messages, system_message, approx_tokens=_idle_tokens, + task_id=effective_task_id, + ) + # ``_compress_context`` returns the INPUT list object when it + # skips (per-session lock held by another path, failure + # cooldown, anti-thrash breaker, codex-native routing). Only + # re-baseline + re-anchor after a real compaction — a skip + # must leave the turn's flush baseline and user-message index + # untouched. + if messages is not _idle_input: + conversation_history = conversation_history_after_compression( + agent, messages + ) + # Compaction rebuilt the list, so the index of this turn's + # just-appended user message is stale — re-anchor it the + # same way the preflight path does below. + current_turn_user_idx = reanchor_current_turn_user_idx( + messages, user_message + ) + agent._persist_user_message_idx = current_turn_user_idx + # ── Preflight context compression ── # Gate the (expensive) full token estimate behind a cheap pre-check. # See ``_should_run_preflight_estimate`` for the OR semantics that fix @@ -666,9 +778,10 @@ def build_turn_context( f"{_compressor.context_length:,}", ) agent._emit_status( - f"📦 Preflight compression: ~{_preflight_tokens:,} tokens " - f">= {_compressor.threshold_tokens:,} threshold. " - "This may take a moment." + PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format( + tokens=_preflight_tokens, + threshold=_compressor.threshold_tokens, + ) ) # Preflight passes honor the same configured per-turn cap # (compression.max_attempts) as the loop's compression sites; diff --git a/agent/turn_retry_state.py b/agent/turn_retry_state.py index 3d231fef9ff4..59e343bfeda3 100644 --- a/agent/turn_retry_state.py +++ b/agent/turn_retry_state.py @@ -73,6 +73,10 @@ class TurnRetryState: # was rolled back off ``messages`` and the loop should re-issue the API # call against the newly-activated provider (#32421). restart_with_rebuilt_messages: bool = False + # A user correction cancelled the in-flight provider request. The outer + # loop must append a role-safe checkpoint + user message, rebuild the API + # payload, and retry the same logical iteration. + restart_with_redirected_messages: bool = False def __iter__(self): # Convenience for debugging / tests: iterate (name, value) pairs. diff --git a/agent/verification_evidence.py b/agent/verification_evidence.py index d66a1534045c..c3154378f5ec 100644 --- a/agent/verification_evidence.py +++ b/agent/verification_evidence.py @@ -60,10 +60,12 @@ def _db_path() -> Path: def _connect() -> sqlite3.Connection: + from hermes_state import apply_wal_with_fallback + path = _db_path() path.parent.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(path) - conn.execute("PRAGMA journal_mode=WAL") + apply_wal_with_fallback(conn, db_label="verification_evidence.db") conn.execute("PRAGMA busy_timeout=5000") conn.row_factory = sqlite3.Row _ensure_schema(conn) diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts index fb18b943abda..9a55d9fc8eeb 100644 --- a/apps/desktop/e2e/chat.spec.ts +++ b/apps/desktop/e2e/chat.spec.ts @@ -8,13 +8,10 @@ * Prerequisite: `npm run build` must have been run so dist/ exists. */ -import { test } from './test' +import { expect, test } from './test' -import { - type MockBackendFixture, - setupMockBackend, - waitForAppReady, -} from './fixtures' +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server' import { expectVisualSnapshot } from './visual-snapshot' let fixture: MockBackendFixture | null = null @@ -61,7 +58,7 @@ test.describe('chat interaction with mock backend', () => { return (body.textContent ?? '').includes('Hello, can you hear me?') }, undefined, - { timeout: 15_000 }, + { timeout: 15_000 } ) // Wait for the mock response to appear. The canned reply is: @@ -81,11 +78,61 @@ test.describe('chat interaction with mock backend', () => { return text.includes('mock inference server') || text.includes('boot chain is working') }, undefined, - { timeout: 60_000 }, + { timeout: 60_000 } ) }) test('screenshot of chat with messages', async () => { await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app }) }) + + test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => { + const page = fixture!.page + const composer = page.locator('[contenteditable="true"]').first() + const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]') + const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]') + const speakReplies = page.locator( + '[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]' + ) + + await composer.click() + await composer.type(BLOCKING_CLARIFY_TRIGGER) + await page.keyboard.press('Enter') + await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 }) + + await expect(primary).toHaveAttribute('aria-label', 'Stop') + await expect(primary.locator('span')).toHaveClass(/bg-current/) + + await composer.click() + await composer.type('please answer tersely') + await expect(primary).toHaveAttribute('aria-label', /Steer/) + await expect(dictation).toBeVisible() + await expect(speakReplies).toBeVisible() + await expect(queue).toBeVisible() + await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible() + const controlLabels = await page + .locator('[data-slot="composer-root"] button') + .evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label'))) + const speakRepliesIndex = controlLabels.findIndex( + label => label === 'Read replies aloud' || label === 'Stop reading replies aloud' + ) + expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex) + expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message')) + expect(controlLabels.indexOf('Queue message')).toBeLessThan( + controlLabels.findIndex(label => label?.startsWith('Steer')) + ) + await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') }) + await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible() + + await queue.click() + await expect(primary).toHaveAttribute('aria-label', 'Stop') + await expect(queue).toHaveCount(0) + await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') }) + await expect(page.getByText('1 Queued')).toBeVisible() + + await primary.click() + await expect(page.getByText('1 Queued — paused')).toBeVisible() + await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') }) + }) }) diff --git a/apps/desktop/e2e/correction-session-switch.spec.ts b/apps/desktop/e2e/correction-session-switch.spec.ts new file mode 100644 index 000000000000..dd32caa61e71 --- /dev/null +++ b/apps/desktop/e2e/correction-session-switch.spec.ts @@ -0,0 +1,210 @@ +/** + * Regression coverage for a correction sent during a live response, then a + * warm session switch away and back. The correction is an accepted user turn, + * not an optimistic duplicate of the original prompt, and its relative place + * in the transcript must survive the resume reconciliation. + */ + +import { type TestInfo } from '@playwright/test' + +import { expect, test, type Page } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from './mock-server' + +const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.' +const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.` +const CORRECTION = 'E2E correction must stay after the original prompt.' +const TOOL_STARTED = 'Checking the long-running task before I continue.' +const CORRECTED_REPLY = 'The corrected task finished.' +const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER' +const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.` +const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.` + +async function send(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +async function steer(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + await expect(primary).toHaveAttribute('aria-label', /Steer/) + await primary.click() +} + +async function waitForTranscriptText(page: Page, text: string): Promise { + await page.waitForFunction( + (expected: string) => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + text, + { timeout: 30_000 }, + ) +} + +async function textNodeOccurrences(page: Page, text: string): Promise { + return page.evaluate((expected: string) => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return 0 + + const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) + let count = 0 + while (walker.nextNode()) { + if (walker.currentNode.textContent?.includes(expected)) { + count += 1 + } + } + return count + }, text) +} + +async function transcriptTextOrder(page: Page): Promise { + return page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return [] + + return Array.from(viewport.querySelectorAll('[data-role="message"], [data-message-id]')) + .map(message => message.textContent?.trim() ?? '') + .filter(Boolean) + }) +} + +async function transcriptMessageOrder(page: Page): Promise { + return page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return [] + + return Array.from(viewport.querySelectorAll('[data-role="user"], [data-role="assistant"]')) + .map(message => message.textContent?.trim() ?? '') + .filter(Boolean) + }) +} + +async function openFreshDraft(page: Page, priorSessionText: string): Promise { + await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click() + await page.waitForFunction( + (priorText: string) => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(priorText), + priorSessionText, + { timeout: 15_000 }, + ) +} + +async function openSidebarSession(page: Page, sidebarText: string, expectedTranscriptText: string): Promise { + const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: sidebarText }).first() + await row.waitFor({ state: 'visible', timeout: 30_000 }) + await row.click() + await waitForTranscriptText(page, expectedTranscriptText) +} + +async function reopenOriginalSession(page: Page): Promise { + // A still-running tool has not generated a final title yet, so the sidebar + // retains the source prompt as its provisional session title. + await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT) +} + +async function reopenInferenceSession(page: Page): Promise { + const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: INFERENCE_PROMPT }).first() + await row.waitFor({ state: 'visible', timeout: 30_000 }) + await row.click() + await waitForTranscriptText(page, INFERENCE_PROMPT) +} + +function relevantOrder(messages: string[]): string[] { + return messages.filter(message => message.includes(ORIGINAL_PROMPT) || message.includes(CORRECTION)) +} + +function steerTurnOrder(messages: string[]): string[] { + return messages.flatMap(message => { + if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT] + if (message.includes(CORRECTION)) return [CORRECTION] + if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY] + + return [] + }) +} + +test.describe('correction session switch', () => { + let fixture: MockBackendFixture | null = null + + test.beforeEach(async () => { + fixture = await setupMockBackend({ + mockServer: { holdFirstStreamForPrompt: INFERENCE_SWITCH_TRIGGER }, + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('keeps a live correction in place and does not duplicate its original prompt after switching sessions', async ({}, testInfo: TestInfo) => { + const { page } = fixture! + + // A blank draft does not exercise session hydration. Seed a real second + // session first, matching the observed switch between two saved chats. + await send(page, OTHER_SESSION_PROMPT) + await waitForTranscriptText(page, MOCK_REPLY) + await openFreshDraft(page, OTHER_SESSION_PROMPT) + + await send(page, ORIGINAL_PROMPT) + await waitForTranscriptText(page, TOOL_STARTED) + await waitForTranscriptText(page, ORIGINAL_PROMPT) + + // The historical session redirects while a foreground terminal task is + // running. Use the visible Steer action to cover the real composer path. + await steer(page, CORRECTION) + await waitForTranscriptText(page, CORRECTION) + + const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page)) + expect(orderBeforeSwitch).toEqual([ORIGINAL_PROMPT, CORRECTION]) + expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1) + expect(await textNodeOccurrences(page, CORRECTION)).toBe(1) + await page.screenshot({ path: testInfo.outputPath('correction-before-session-switch.png') }) + + // Reproduce the observed race: switch to another persisted session while + // the foreground tool is live, then return before its redirect settles. + await openSidebarSession(page, MOCK_REPLY, OTHER_SESSION_PROMPT) + await reopenOriginalSession(page) + await page.waitForTimeout(500) + await page.screenshot({ path: testInfo.outputPath('correction-after-warm-resume.png') }) + + expect(relevantOrder(await transcriptTextOrder(page))).toEqual(orderBeforeSwitch) + expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1) + expect(await textNodeOccurrences(page, CORRECTION)).toBe(1) + + await waitForTranscriptText(page, CORRECTED_REPLY) + expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY]) + }) + + test('keeps an inference-time correction visible through a warm session switch', async ({}, testInfo: TestInfo) => { + const { mock, page } = fixture! + + await send(page, OTHER_SESSION_PROMPT) + await waitForTranscriptText(page, MOCK_REPLY) + await openFreshDraft(page, OTHER_SESSION_PROMPT) + + await send(page, INFERENCE_PROMPT) + await mock.waitForHeldStream() + await waitForTranscriptText(page, INFERENCE_PROMPT) + + await send(page, INFERENCE_CORRECTION) + await waitForTranscriptText(page, INFERENCE_CORRECTION) + + await openSidebarSession(page, MOCK_REPLY, OTHER_SESSION_PROMPT) + await reopenInferenceSession(page) + + expect(await textNodeOccurrences(page, INFERENCE_PROMPT)).toBe(1) + expect(await textNodeOccurrences(page, INFERENCE_CORRECTION)).toBe(1) + await page.screenshot({ path: testInfo.outputPath('inference-correction-after-warm-resume.png') }) + + mock.releaseHeldStream() + await waitForTranscriptText(page, MOCK_REPLY) + }) +}) \ No newline at end of file diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 93acc6f86236..1dabc674d043 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -27,7 +27,7 @@ import * as path from 'node:path' import { _electron, type ElectronApplication, type Page } from '@playwright/test' -import { startMockServer } from './mock-server' +import { startMockServer, type MockServerOptions } from './mock-server' import { installErrorBannerGuard } from './test' const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') @@ -140,15 +140,30 @@ export function createSandbox(prefix: string): Sandbox { * 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. + * + * @param extraDisplayConfig optional YAML lines appended to the `display:` + * section, used by the interim-message e2e test. + * @param extraConfig optional top-level YAML sections for a test scenario. + * @param modelContextLength optional primary-model context limit. */ -export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void { +export function writeMockProviderConfig( + hermesHome: string, + mockUrl: string, + extraDisplayConfig?: string, + extraConfig?: string, + modelContextLength?: number, +): void { const configPath = path.join(hermesHome, 'config.yaml') + const displaySection = extraDisplayConfig + ? `\ndisplay:\n${extraDisplayConfig}\n` + : '' + const config = `# Auto-generated by E2E test fixtures model: default: mock-model provider: mock -providers: +${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers: mock: api: ${mockUrl}/v1 name: Mock @@ -157,7 +172,7 @@ providers: models: mock-model: {} context_length: 4096 -` +${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}` fs.writeFileSync(configPath, config, 'utf8') } @@ -318,11 +333,25 @@ export async function launchDesktop( export interface MockBackendFixture { app: ElectronApplication page: Page + mock: Awaited> mockUrl: string sandbox: Sandbox cleanup: () => Promise } +export interface MockBackendOptions { + /** + * Optional YAML lines to inject under the `display:` section of the + * generated config.yaml. Used by the interim-message e2e test to toggle + * `display.interim_assistant_messages`. + */ + extraDisplayConfig?: string + /** Additional top-level config.yaml sections for an E2E scenario. */ + extraConfig?: string + /** Override the mock model's context window for compression scenarios. */ + modelContextLength?: number +} + /** * Set up a full mock-backend E2E environment: * 1. Start the mock inference server @@ -330,13 +359,23 @@ export interface MockBackendFixture { * 3. Launch the desktop app * 4. Return handles for test interaction */ -export async function setupMockBackend(): Promise { +export interface MockBackendOptions { + mockServer?: MockServerOptions +} + +export async function setupMockBackend(options: MockBackendOptions = {}): Promise { // 1. Start mock server - const mock = await startMockServer() + const mock = await startMockServer(options.mockServer) // 2. Create sandbox + write config const sandbox = createSandbox('mock') - writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeMockProviderConfig( + sandbox.hermesHome, + mock.url, + options.extraDisplayConfig, + options.extraConfig, + options.modelContextLength, + ) writeEnvFile(sandbox.hermesHome) // 3. Build env + launch @@ -346,6 +385,7 @@ export async function setupMockBackend(): Promise { return { app, page, + mock, mockUrl: mock.url, sandbox, cleanup: async () => { diff --git a/apps/desktop/e2e/interim-messages.spec.ts b/apps/desktop/e2e/interim-messages.spec.ts new file mode 100644 index 000000000000..2f6da013912b --- /dev/null +++ b/apps/desktop/e2e/interim-messages.spec.ts @@ -0,0 +1,215 @@ +/** + * E2E test for the interim-assistant-message preservation fix (#65919). + * + * Reproduces the bug across all three layers (agent core → tui_gateway → + * desktop renderer): when the agent emits assistant text alongside a tool + * call, then completes the turn with a *different* final answer, the + * interim text must survive in the transcript — not be wiped when + * message.complete replaces the streaming bubble. + * + * The mock server walks through a multi-turn script when it sees the + * trigger keyword: + * + * Turn 1: "Let me start by planning the approach." + todo tool_call + * Turn 2: "Now checking the details before answering." + todo tool_call + * Turn 3: (no text) + todo tool_call → NO interim (no visible text) + * Turn 4: "Found something interesting worth noting." + todo tool_call + * Turn 5: "All done! Here is the complete summary..." (final, stop) + * + * Two describe blocks exercise the config flag both ways: + * + * display.interim_assistant_messages: true (default) + * → ALL interim texts AND the final text must be visible in the + * transcript. + * + * display.interim_assistant_messages: false + * → only the final text is visible (no message.interim events emitted, + * so all streamed interim text is replaced at message.complete). + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { INTERIM_TEXTS, restartMockServer } from './mock-server' + +// ─── Helpers ────────────────────────────────────────────────────────── + +/** Unique trigger keyword the mock server detects to switch to the script. */ +const TRIGGER = 'E2E_INTERIM_TRIGGER' + +/** + * Send a message and wait for BOTH the user's message and the agent's + * final response to appear in the transcript. Returns when the final text + * is visible, which means message.complete has fired and the transcript + * has settled. + */ +async function sendInterimMessage(page: Page): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type(TRIGGER, { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's trigger message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'), + undefined, + { timeout: 15_000 }, + ) + + // Wait for the agent's FINAL response (last turn). This means + // message.complete has fired and the transcript is settled. + await page.waitForFunction( + (finalText) => (document.body.textContent ?? '').includes(finalText), + INTERIM_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // Give the renderer a moment to settle any final state updates + // (hydration, session refresh) before asserting. + await page.waitForTimeout(2000) +} + +/** + * Count how many times `text` appears as distinct text in the chat transcript + * (excluding the session sidebar, whose session-preview label shows the + * first streamed text as a title). + * + * The desktop app renders the transcript inside a + * `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react). + * The session sidebar's preview labels live outside that container, so + * scoping the DOM walk to the viewport cleanly excludes them. + */ +async function countTranscriptMessagesContaining(page: Page, text: string): Promise { + return page.evaluate( + (search) => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) { + return 0 + } + + let count = 0 + const walker = document.createTreeWalker( + viewport, + NodeFilter.SHOW_ELEMENT, + { + acceptNode: (node) => { + const el = node as HTMLElement + const directText = el.textContent ?? '' + if (!directText.includes(search)) { + return NodeFilter.FILTER_SKIP + } + // Only count leaf-ish elements to avoid double-counting. + const hasChildWithText = Array.from(el.children).some( + (child) => (child.textContent ?? '').includes(search), + ) + if (hasChildWithText) { + return NodeFilter.FILTER_SKIP + } + return NodeFilter.FILTER_ACCEPT + }, + }, + ) + while (walker.nextNode()) { + count++ + } + return count + }, + text, + ) +} + +// ─── Flag ON: interim_assistant_messages = true (default) ───────────── + +test.describe('interim assistant messages — flag ON (default)', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('all interim texts survive alongside the final response', async () => { + const page = fixture.page + await sendInterimMessage(page) + + // Every interim text (turns with visible text + tool calls) must be + // present in the transcript as its own sealed message — NOT wiped by + // message.complete. + for (const interimText of INTERIM_TEXTS.interims) { + await expect + .poll( + () => countTranscriptMessagesContaining(page, interimText), + { timeout: 15_000, message: `interim text "${interimText}" should be visible` }, + ) + .toBeGreaterThanOrEqual(1) + } + + // The final text must also be visible. + await expect + .poll( + () => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText), + { timeout: 15_000, message: 'final text should be visible' }, + ) + .toBeGreaterThanOrEqual(1) + }) +}) + +// ─── Flag OFF: interim_assistant_messages = false ──────────────────── + +test.describe('interim assistant messages — flag OFF', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend({ + extraDisplayConfig: ' interim_assistant_messages: false', + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('only the final response is visible; all interim texts are wiped', async () => { + const page = fixture.page + await sendInterimMessage(page) + + // The final text must be visible. + await expect + .poll( + () => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText), + { timeout: 15_000, message: 'final text should be visible' }, + ) + .toBeGreaterThanOrEqual(1) + + // NONE of the interim texts should be visible — with the flag off, + // the tui_gateway never installs interim_assistant_callback, so no + // message.interim events are emitted. All streamed interim text is + // accumulated into the streaming bubble and replaced by + // message.complete. + for (const interimText of INTERIM_TEXTS.interims) { + const count = await countTranscriptMessagesContaining(page, interimText) + expect( + count, + `interim text "${interimText}" should NOT be visible when flag is off`, + ).toBe(0) + } + }) +}) diff --git a/apps/desktop/e2e/large-session-resume.spec.ts b/apps/desktop/e2e/large-session-resume.spec.ts new file mode 100644 index 000000000000..7445d823238d --- /dev/null +++ b/apps/desktop/e2e/large-session-resume.spec.ts @@ -0,0 +1,235 @@ +import { spawnSync } from 'node:child_process' +import * as path from 'node:path' + +import { type TestInfo } from '@playwright/test' + +import { expect, test, type ElectronApplication, type Page } from './test' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + type Sandbox, + waitForAppReady, + writeEnvFile, + writeMockProviderConfig, +} from './fixtures' +import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') +const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py') +const SESSION_TITLE = 'E2E large persisted session' +const EXPECTED_TEXT = 'E2E persisted user message 52' +const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume' + +interface SeededFixture { + app: ElectronApplication + mock: MockServer + mockUrl: string + page: Page + sandbox: Sandbox + cleanup: () => Promise +} + +interface PaintState { + bursts: number + timeline: Array<{ mutations: number; time: number }> +} + +async function setupSeededDesktop(mockServer?: MockServerOptions): Promise { + const mock = await startMockServer(mockServer) + const sandbox = createSandbox('large-session') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + + const seeded = spawnSync('python3', [SEED_SCRIPT, path.join(sandbox.hermesHome, 'state.db')], { + cwd: REPO_ROOT, + encoding: 'utf8', + env: { ...process.env, PYTHONPATH: REPO_ROOT }, + }) + if (seeded.status !== 0) { + throw new Error(`large-session seed failed:\n${seeded.stdout}\n${seeded.stderr}`) + } + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + + return { + app, + mock, + mockUrl: mock.url, + page, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +function sessionRow(page: Page) { + return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first() +} + +async function openSeededSession(page: Page): Promise { + const row = sessionRow(page) + await row.waitFor({ state: 'visible', timeout: 60_000 }) + await row.click() + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + EXPECTED_TEXT, + { timeout: 30_000 }, + ) +} + +async function openNewSession(page: Page): Promise { + const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first() + await button.waitFor({ state: 'visible', timeout: 10_000 }) + await button.click() + await page.waitForFunction( + expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + EXPECTED_TEXT, + { timeout: 15_000 }, + ) +} + +async function submitPrompt(page: Page, prompt: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(prompt, { delay: 2 }) + await page.keyboard.press('Enter') + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + prompt, + { timeout: 15_000 }, + ) +} + +async function startPaintObserver(page: Page): Promise { + await page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> } + ;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state + if (!viewport) return + + let additions = 0 + let flushTimer: ReturnType | undefined + new MutationObserver(records => { + additions += records.reduce( + (count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0), + 0, + ) + if (additions === 0) return + if (flushTimer) clearTimeout(flushTimer) + flushTimer = setTimeout(() => { + state.bursts += 1 + state.timeline.push({ mutations: additions, time: Date.now() }) + additions = 0 + }, 30) + }).observe(viewport, { childList: true, subtree: true }) + }) +} + +async function paintState(page: Page): Promise { + const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints) + expect(state, 'paint observer should attach to the thread viewport').toBeDefined() + return state! +} + +async function textNodeOccurrences(page: Page, expected: string): Promise { + return page.evaluate(text => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return 0 + + const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT) + let count = 0 + while (walker.nextNode()) { + if (walker.currentNode.textContent?.includes(text)) { + count += 1 + } + } + return count + }, expected) +} + +async function reloadIntoColdRenderer(fixture: SeededFixture): Promise { + await fixture.page.reload() + await waitForAppReady(fixture, 120_000) + await openNewSession(fixture.page) +} + +async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise { + await openSeededSession(page) + await page.waitForTimeout(1_000) + await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false }) + + const paints = await paintState(page) + expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1) + // A warm session first restores its retained view, then reconciles it with the + // authoritative transcript. That is bounded at two builds; a third paint was + // the old eager-prefetch + runtime-rebuild regression. A cold restore has one. + expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2) +} + +test.describe('large session resume', () => { + let fixture: SeededFixture | null = null + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { + fixture = await setupSeededDesktop() + await waitForAppReady(fixture, 120_000) + + await startPaintObserver(fixture.page) + await assertUnchangedResume(fixture.page, testInfo) + }) + + test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => { + // Known RED: a rapid warm resume rebuilds the transcript three times + // (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the + // regression visible without making unrelated desktop work fail CI. + test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild') + + fixture = await setupSeededDesktop() + await waitForAppReady(fixture, 120_000) + + await openSeededSession(fixture.page) + await openNewSession(fixture.page) + await startPaintObserver(fixture.page) + await assertUnchangedResume(fixture.page, testInfo) + }) + + for (const resumeKind of ['fast', 'cold'] as const) { + test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => { + fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT }) + await waitForAppReady(fixture, 120_000) + + await openSeededSession(fixture.page) + await submitPrompt(fixture.page, BACKGROUND_PROMPT) + await fixture.mock.waitForHeldStream() + await openNewSession(fixture.page) + + if (resumeKind === 'cold') { + await reloadIntoColdRenderer(fixture) + } + + await openSeededSession(fixture.page) + fixture.mock.releaseHeldStream() + await fixture.page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + MOCK_REPLY, + { timeout: 60_000 }, + ) + await fixture.page.waitForTimeout(300) + await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false }) + + expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1) + expect(await textNodeOccurrences(fixture.page, MOCK_REPLY), 'the completed assistant reply should appear once').toBe(1) + }) + } +}) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts index 680cba30e7af..93c0ce12a570 100644 --- a/apps/desktop/e2e/mock-server.ts +++ b/apps/desktop/e2e/mock-server.ts @@ -15,17 +15,250 @@ */ import http from 'node:http' +import type { ServerResponse } 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.' +export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.' + +export interface MockServerOptions { + /** Pause the matching stream after its first token for session-switch E2E coverage. */ + holdFirstStreamForPrompt?: string + /** Pause the first completion whose request JSON contains this text. */ + holdFirstCompletionContaining?: string +} + +export interface MockServer { + port: number + url: string + receivedPrompts: string[] + waitForHeldStream: () => Promise + waitForHeldCompletion: () => Promise + releaseHeldStream: () => void + heldCompletionCount: () => number + close: () => Promise +} + +// ─── Multi-turn interim script ───────────────────────────────────────── +// +// When the user's message contains the trigger keyword, the mock server +// walks through a scripted sequence of responses that exercise the +// interim-assistant-message fix (#65919) across several patterns: +// +// 1. text + single tool_call → should produce an interim message +// 2. text + single tool_call → another interim message +// 3. no text + tool_call → NO interim (no visible text alongside tools) +// 4. text + single tool_call → another interim message +// 5. final answer (stop) → message.complete, different from all interims +// +// Each "turn" is one API call. The agent executes the tool after each +// tool_calls response, then re-calls the API, advancing to the next turn. + +export interface ScriptedTurn { + /** Assistant text content to stream. Empty string = no visible text. */ + text: string + /** Tool calls to emit. Empty array = final turn (finish_reason: stop). */ + toolCalls?: Array<{ + name: string + args: Record + }> +} + +const INTERIM_SCRIPT: ScriptedTurn[] = [ + { + text: 'Let me start by planning the approach.', + toolCalls: [{ name: 'todo', args: { todos: [{ id: '1', content: 'Plan', status: 'in_progress' }] } }], + }, + { + text: 'Now checking the details before answering.', + toolCalls: [{ name: 'todo', args: { todos: [{ id: '2', content: 'Check details', status: 'in_progress' }] } }], + }, + { + // No visible text alongside this tool call — should NOT produce an + // interim message. The agent fires _emit_interim_assistant_message + // but _interim_assistant_visible_text returns "" so it's a no-op. + text: '', + toolCalls: [{ name: 'todo', args: { todos: [{ id: '3', content: 'Silent step', status: 'completed' }] } }], + }, + { + text: 'Found something interesting worth noting.', + toolCalls: [{ name: 'todo', args: { todos: [{ id: '4', content: 'Note finding', status: 'completed' }] } }], + }, + { + // Final answer — different from all interim texts. + text: 'All done! Here is the complete summary of what I found.', + }, +] + +/** Per-server request counter so we can walk through the script turns. */ +let _scriptIndex = 0 + +/** Per-server counter for the sidebar-states script (independent from _scriptIndex). */ +let _sidebarScriptIndex = 0 + +/** Per-server counter for the cross-session sidebar script. */ +let _sidebarCrossIndex = 0 + +/** Per-server counter for the queue-stop script. */ +let _queueStopIndex = 0 + +/** Per-server counter for the correction/session-switch script. */ +let _correctionSwitchIndex = 0 + +/** User messages received by the mock, for E2E assertions on real submits. */ +const _receivedUserTexts: string[] = [] + +/** Reset the script indices (called between tests via restartMockServer). */ +function resetScriptIndex(): void { + _scriptIndex = 0 + _sidebarScriptIndex = 0 + _sidebarCrossIndex = 0 + _queueStopIndex = 0 + _correctionSwitchIndex = 0 + _receivedUserTexts.length = 0 +} + +/** Return the user prompts the real backend submitted to this mock server. */ +export function receivedUserTexts(): readonly string[] { + return _receivedUserTexts +} + +// ─── Sidebar-states script ───────────────────────────────────────────── +// +// A separate trigger (E2E_SIDEBAR_TRIGGER) exercises the desktop sidebar's +// background-process and subagent states. The mock returns tool_calls that +// the agent executes for real — `terminal(background=true)` spawns a real +// (but trivial) background process, and `delegate_task` spawns a real +// subagent that calls the mock server and gets the canned reply. +// +// Turn 1: text + terminal(bg=true) + delegate_task → tools execute +// Turn 2: final answer → message.complete, dot transitions + +const SIDEBAR_SCRIPT: ScriptedTurn[] = [ + { + text: 'Let me run a background task and delegate some work.', + toolCalls: [ + { + name: 'terminal', + args: { + command: 'echo "background process output" && sleep 1 && echo "done"', + background: true, + notify_on_complete: true, + }, + }, + { + name: 'delegate_task', + args: { + goal: 'Summarize the test results', + context: 'This is a test subagent for the sidebar states E2E test.', + }, + }, + ], + }, + { + text: 'All tasks complete. The background process finished and the subagent returned its summary.', + }, +] + +// ─── Sidebar cross-session script ────────────────────────────────────── +// +// E2E_SIDEBAR_CROSS trigger uses a longer background process (sleep 5) so +// the "background running" dot is visible long enough for the test to: +// 1. See the background dot while the subagent runs. +// 2. Open a different session and see session A's dot transition to +// "finished unread" when the background process completes. + +const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = [ + { + text: 'Starting a long background task and delegating work.', + toolCalls: [ + { + name: 'terminal', + args: { + command: 'echo "long bg output" && sleep 5 && echo "finished"', + background: true, + notify_on_complete: true, + }, + }, + { + name: 'delegate_task', + args: { + goal: 'Analyze cross-session state', + context: 'Testing that the background dot updates across sessions.', + }, + }, + ], + }, + { + text: 'Both tasks are running in the background now.', + }, +] + +const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [ + { + text: 'Starting a task that will keep this turn active.', + toolCalls: [{ name: 'clarify', args: { question: 'Keep working?', choices: ['Yes', 'No'] } }], + }, + { text: 'The paused task completed.' }, +] + +// The reported correction arrived while a foreground tool was still running. +// Keep that boundary open long enough for the renderer to redirect the turn, +// then let the next model request complete normally. +const CORRECTION_SWITCH_SCRIPT: ScriptedTurn[] = [ + { + text: 'Checking the long-running task before I continue.', + toolCalls: [{ name: 'terminal', args: { command: 'sleep 5' } }], + }, + { text: 'The corrected task finished.' }, +] + +export const CORRECTION_SWITCH_TRIGGER = 'E2E_CORRECTION_SWITCH_TRIGGER' + +/** + * A marker that makes the mock emit a real blocking clarify tool call. Tests + * use it to hold a turn open while exercising busy-composer interactions. + */ +export const BLOCKING_CLARIFY_TRIGGER = 'E2E_BLOCKING_CLARIFY_TRIGGER' +export const BLOCKING_CLARIFY_QUESTION = 'Keep this test turn running?' + +const BLOCKING_CLARIFY_TURN: ScriptedTurn = { + text: '', + toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }], +} + +function includesBlockingClarifyTrigger(value: unknown): boolean { + if (typeof value === 'string') { + return value.includes(BLOCKING_CLARIFY_TRIGGER) + } + + if (Array.isArray(value)) { + return value.some(includesBlockingClarifyTrigger) + } + + if (value && typeof value === 'object') { + return Object.values(value).some(includesBlockingClarifyTrigger) + } + + return false +} /** * Start the mock server on an ephemeral port. * - * @returns a handle with `port`, `url`, and `close()`. + * @returns a handle with `port`, `url`, received user prompts, and `close()`. */ -export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise }> { +export function startMockServer(options: MockServerOptions = {}): Promise { return new Promise((resolve, reject) => { + const receivedPrompts: string[] = [] + let resolveHeldStreamStarted: (() => void) | null = null + let releaseHeldStream: (() => void) | null = null + let heldCompletionCount = 0 + const heldStreamStarted = new Promise(resolveHeld => { + resolveHeldStreamStarted = resolveHeld + }) + const heldStreamReleased = new Promise(resolveRelease => { + releaseHeldStream = resolveRelease + }) 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. @@ -75,88 +308,128 @@ export function startMockServer(): Promise<{ port: number; url: string; close: ( // malformed JSON — treat as non-streaming with defaults } + const lastUserMessage = [...(parsed.messages ?? [])] + .reverse() + .find((message: { role?: unknown }) => message?.role === 'user') + + if (typeof lastUserMessage?.content === 'string') { + receivedPrompts.push(lastUserMessage.content) + } + const stream = parsed.stream === true const model = parsed.model || 'mock-model' + const holdThisCompletion = Boolean( + options.holdFirstCompletionContaining && + heldCompletionCount === 0 && + JSON.stringify(parsed).includes(options.holdFirstCompletionContaining), + ) + + // Detect the interim-message test trigger: the user's message + // contains a specific keyword. The mock walks through the + // INTERIM_SCRIPT turns in sequence. + // + // The trigger keyword is chosen so normal chat tests (which send + // "Hello, can you hear me?" etc.) never hit this path. + const messages: any[] = Array.isArray(parsed.messages) ? parsed.messages : [] + const lastUserMsg = [...messages].reverse().find(m => m?.role === 'user') + const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : '' + if (userText) { + _receivedUserTexts.push(userText) + } + const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER') + const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER') + const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS') + const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER') + const isCorrectionSwitchTrigger = messages.some( + message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER), + ) + + if (includesBlockingClarifyTrigger(parsed.messages)) { + if (stream) { + streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN) + } else { + nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN) + } + return + } + + if (isQueueStopTrigger) { + const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1] + _queueStopIndex++ + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } + + if (isCorrectionSwitchTrigger) { + const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1] + _correctionSwitchIndex++ + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } + + if (isSidebarCrossTrigger) { + const turn = SIDEBAR_CROSS_SCRIPT[_sidebarCrossIndex] ?? SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1] + _sidebarCrossIndex++ + + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } + + if (isSidebarTrigger) { + const turn = SIDEBAR_SCRIPT[_sidebarScriptIndex] ?? SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1] + _sidebarScriptIndex++ + + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } + + if (isInterimTrigger) { + const turn = INTERIM_SCRIPT[_scriptIndex] ?? INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1] + _scriptIndex++ + if (stream) { + streamScriptedTurn(res, model, turn) + } else { + nonStreamingScriptedTurn(res, model, turn) + } + return + } 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, - }, - }), + const holdThisStream = Boolean( + options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' && + lastUserMessage.content.includes(options.holdFirstStreamForPrompt), ) + streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => { + if (holdThisCompletion) { + heldCompletionCount++ + } + resolveHeldStreamStarted?.() + return heldStreamReleased + } : undefined) + } else { + if (holdThisCompletion) { + heldCompletionCount++ + resolveHeldStreamStarted?.() + void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY)) + } else { + nonStreamingTextResponse(res, model, MOCK_REPLY) + } } }) @@ -187,6 +460,11 @@ export function startMockServer(): Promise<{ port: number; url: string; close: ( resolve({ port, url, + receivedPrompts, + waitForHeldStream: () => heldStreamStarted, + waitForHeldCompletion: () => heldStreamStarted, + releaseHeldStream: () => releaseHeldStream?.(), + heldCompletionCount: () => heldCompletionCount, close: () => new Promise((resolveClose, rejectClose) => { server.close((err) => { @@ -201,3 +479,238 @@ export function startMockServer(): Promise<{ port: number; url: string; close: ( }) }) } + +// ─── Response helpers ────────────────────────────────────────────────── + +/** SSE chunk shape for a streaming chat completion. */ +function sseChunk(model: string, delta: Record, finishReason: string | null = null): string { + return `data: ${JSON.stringify({ + id: 'mock-completion', + object: 'chat.completion.chunk', + created: 0, + model, + choices: [{ index: 0, delta, finish_reason: finishReason }], + })}\n\n` +} + +/** + * Stream a plain text response (no tool calls) as SSE, finishing with + * `finish_reason: "stop"`. This is the default canned-reply path. + */ +function streamTextResponse( + res: ServerResponse, + model: string, + text: string, + waitForRelease?: () => Promise, +): void { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + const words = text.split(' ') + let i = 0 + + const sendChunk = (): void => { + if (i >= words.length) { + res.write(sseChunk(model, {}, 'stop')) + res.write('data: [DONE]\n\n') + res.end() + return + } + + const word = i === 0 ? words[i] : ' ' + words[i] + res.write(sseChunk(model, { content: word })) + i++ + if (waitForRelease && i === 1) { + waitForRelease().then(() => setTimeout(sendChunk, 20)) + return + } + setTimeout(sendChunk, 20) + } + + sendChunk() +} + +/** Non-streaming plain text response. */ +function nonStreamingTextResponse(res: ServerResponse, model: string, text: string): void { + 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: text }, + finish_reason: 'stop', + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }), + ) +} + +/** + * Stream a single scripted turn: first the text content (word by word), + * then a chunk carrying the tool_calls (if any), with the appropriate + * finish_reason. + * + * If the turn has no text and no tool calls, it's an empty final response. + * If it has text but no tool calls, it's a final answer (finish_reason: stop). + * If it has tool calls (with or without text), finish_reason is "tool_calls". + */ +function streamScriptedTurn( + res: ServerResponse, + model: string, + turn: ScriptedTurn, +): void { + res.writeHead(200, { + 'Content-Type': 'text/event-stream', + 'Cache-Control': 'no-cache', + Connection: 'keep-alive', + }) + + const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0 + const finishReason = hasToolCalls ? 'tool_calls' : 'stop' + + // If there's no text to stream, go straight to the tool_calls / finish. + if (!turn.text) { + if (hasToolCalls) { + res.write( + sseChunk(model, { + tool_calls: turn.toolCalls!.map((tc, idx) => ({ + index: idx, + id: `call_e2e_${_scriptIndex}_${idx}`, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })), + }, finishReason), + ) + } else { + res.write(sseChunk(model, {}, finishReason)) + } + res.write('data: [DONE]\n\n') + res.end() + return + } + + // Stream the text word by word, then emit tool_calls if present. + const words = turn.text.split(' ') + let i = 0 + + const sendChunk = (): void => { + if (i >= words.length) { + // All text streamed — emit tool_calls if present, then finish. + if (hasToolCalls) { + res.write( + sseChunk(model, { + tool_calls: turn.toolCalls!.map((tc, idx) => ({ + index: idx, + id: `call_e2e_${_scriptIndex}_${idx}`, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })), + }, finishReason), + ) + } else { + res.write(sseChunk(model, {}, finishReason)) + } + res.write('data: [DONE]\n\n') + res.end() + return + } + + const word = i === 0 ? words[i] : ' ' + words[i] + res.write(sseChunk(model, { content: word })) + i++ + setTimeout(sendChunk, 20) + } + + sendChunk() +} + +/** Non-streaming version of a scripted turn. */ +function nonStreamingScriptedTurn( + res: ServerResponse, + model: string, + turn: ScriptedTurn, +): void { + const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0 + const finishReason = hasToolCalls ? 'tool_calls' : 'stop' + + const message: Record = { role: 'assistant' } + if (turn.text) { + message.content = turn.text + } + if (hasToolCalls) { + message.tool_calls = turn.toolCalls!.map((tc, idx) => ({ + id: `call_e2e_${_scriptIndex}_${idx}`, + type: 'function', + function: { name: tc.name, arguments: JSON.stringify(tc.args) }, + })) + } + + res.writeHead(200, { 'Content-Type': 'application/json' }) + res.end( + JSON.stringify({ + id: 'mock-completion', + object: 'chat.completion', + created: 0, + model, + choices: [{ index: 0, message, finish_reason: finishReason }], + usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 }, + }), + ) +} + +/** + * Restart the mock server's script index so each test starts from turn 0. + * Call this between tests that use the interim trigger. + */ +export function restartMockServer(): void { + resetScriptIndex() +} + +/** + * The interim script's text constants, exported for test assertions. + * Each entry is the visible text of one turn. Turns with empty text + * produce no interim message and are excluded from this list. + */ +export const INTERIM_TEXTS = { + /** All interim texts that should appear as sealed messages when the flag is ON. */ + interims: INTERIM_SCRIPT + .filter((t) => t.text && t.toolCalls) + .map((t) => t.text), + /** The final answer text. */ + finalText: INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1].text, + /** Text that should NOT produce an interim (empty-text tool turn). */ + silentTurnIndex: INTERIM_SCRIPT.findIndex((t) => !t.text && t.toolCalls), +} as const + +/** The sidebar-states script's text constants, exported for test assertions. */ +export const SIDEBAR_TEXTS = { + /** The interim text from turn 1 (alongside tool calls). */ + interimText: SIDEBAR_SCRIPT[0].text, + /** The final answer text. */ + finalText: SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1].text, + /** The background process command (for asserting process.list entries). */ + bgCommand: 'echo "background process output" && sleep 1 && echo "done"', + /** The subagent's goal (for asserting subagent panel state). */ + subagentGoal: 'Summarize the test results', +} as const + +/** The cross-session sidebar script's text constants. */ +export const SIDEBAR_CROSS_TEXTS = { + /** The interim text from turn 1. */ + interimText: SIDEBAR_CROSS_SCRIPT[0].text, + /** The final answer text. */ + finalText: SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1].text, + /** The longer background process command (sleep 5). */ + bgCommand: 'echo "long bg output" && sleep 5 && echo "finished"', + /** The subagent's goal. */ + subagentGoal: 'Analyze cross-session state', +} as const diff --git a/apps/desktop/e2e/queue-turn-boundary.spec.ts b/apps/desktop/e2e/queue-turn-boundary.spec.ts new file mode 100644 index 000000000000..2308d321a553 --- /dev/null +++ b/apps/desktop/e2e/queue-turn-boundary.spec.ts @@ -0,0 +1,119 @@ +/** + * A queued prompt must remain local until the current inference turn settles. + * + * Hold the first streamed reply open after its first token. This gives the + * composer a live, busy turn while the user queues a follow-up, then lets us + * assert against the mock provider's real request log before and after the + * held turn completes. + */ + +import { expect, test, type Page } from './test' + +import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures' +import { MOCK_REPLY } from './mock-server' + +const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE' +const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED' +const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION' + +async function send(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 15_000 }) + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +async function steer(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Enter') +} + +async function queue(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + + await composer.click() + await composer.type(text, { delay: 5 }) + await page.keyboard.press('Control+Enter') +} + +async function transcriptMessageOrder(page: Page): Promise { + return page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return [] + + return Array.from(viewport.querySelectorAll('[data-role="user"], [data-role="assistant"]')) + .map(message => message.textContent?.trim() ?? '') + .filter(Boolean) + }) +} + +function steerTurnOrder(messages: string[]): string[] { + return messages.flatMap(message => { + if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT] + if (message.includes(STEER_PROMPT)) return [STEER_PROMPT] + if (message.includes(MOCK_REPLY)) return [MOCK_REPLY] + + return [] + }) +} + +test.describe('queued prompt turn boundary', () => { + let fixture: MockBackendFixture | null = null + + test.beforeEach(async () => { + fixture = await setupMockBackend({ + mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT } + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterEach(async () => { + await fixture?.cleanup() + fixture = null + }) + + test('submits a queued prompt only after the active turn completes', async () => { + const { mock, page } = fixture! + + await send(page, ACTIVE_PROMPT) + await mock.waitForHeldStream() + await queue(page, QUEUED_PROMPT) + await expect(page.getByText('1 Queued')).toBeVisible() + + // The mock keeps the active SSE stream open, so a queued prompt has no + // completed-turn boundary that could legitimately drain it. Wait past the + // queue retry interval and assert the provider saw only the active turn. + await page.waitForTimeout(1_000) + expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0) + await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT) + + mock.releaseHeldStream() + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + MOCK_REPLY, + { timeout: 60_000 } + ) + await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1) + }) + + test('places a steer prompt before the reply it redirects', async () => { + const { mock, page } = fixture! + + await send(page, ACTIVE_PROMPT) + await mock.waitForHeldStream() + await steer(page, STEER_PROMPT) + mock.releaseHeldStream() + + await page.waitForFunction( + expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected), + MOCK_REPLY, + { timeout: 60_000 } + ) + + expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY]) + }) +}) \ No newline at end of file diff --git a/apps/desktop/e2e/scripts/seed_large_session.py b/apps/desktop/e2e/scripts/seed_large_session.py new file mode 100644 index 000000000000..dc0ef750a691 --- /dev/null +++ b/apps/desktop/e2e/scripts/seed_large_session.py @@ -0,0 +1,52 @@ +#!/usr/bin/env python3 +"""Seed a deterministic, tool-free large session into an isolated state.db.""" + +import sys +from pathlib import Path + +repo_root = Path(__file__).resolve().parents[3] +sys.path.insert(0, str(repo_root)) + +from hermes_state import SessionDB # noqa: E402 + +SESSION_ID = "e2e-large-session" +SESSION_TITLE = "E2E large persisted session" + + +def main() -> None: + if len(sys.argv) != 2: + raise SystemExit(f"usage: {sys.argv[0]} ") + + messages = [] + for index in range(53): + role = "user" if index % 2 == 0 else "assistant" + content = ( + f"E2E persisted user message {index}: audit the compatibility matrix" + if role == "user" + else f"E2E persisted assistant reply {index}: recorded the audit result" + ) + messages.append({"role": role, "content": content, "timestamp": 1_700_000_000 + index}) + + database = SessionDB(db_path=Path(sys.argv[1])) + result = database.import_sessions( + [ + { + "id": SESSION_ID, + "source": "desktop", + "model": "mock-model", + "started_at": 1_700_000_000, + "title": SESSION_TITLE, + "cwd": str(repo_root), + "system_prompt": "", + "messages": messages, + } + ] + ) + database.close() + + if not result.get("ok") or result.get("imported") != 1: + raise SystemExit(f"failed to seed large session: {result}") + + +if __name__ == "__main__": + main() diff --git a/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts new file mode 100644 index 000000000000..f15b8d0befcf --- /dev/null +++ b/apps/desktop/e2e/session-compression-and-queue-stop.spec.ts @@ -0,0 +1,146 @@ +/** + * E2E coverage for session compression, which rotates a live backend session. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server' + +async function send(page: Page, text: string, delay = 15): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(text, { delay }) + await page.keyboard.press('Enter') +} + +async function pasteAndSend(page: Page, text: string): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await page.keyboard.insertText(text) + await page.keyboard.press('Enter') +} + + +async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise { + await page.waitForFunction( + expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false, + text, + { timeout }, + ) +} + +test.describe('session compression', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('compresses an existing session and accepts a follow-up turn on its continuation', async () => { + const { page } = fixture + const reply = 'Hello from the mock inference server! The full boot chain is working.' + + // Three completed exchanges leave a compressible middle after the + // compressor's protected head/tail boundaries. + await send(page, 'E2E_COMPRESSION_FIRST') + await waitForTranscript(page, reply) + await send(page, 'E2E_COMPRESSION_SECOND') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1) + await send(page, 'E2E_COMPRESSION_THIRD') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1) + + // Commit the command before typing its argument. This waits for the async + // completion request on cold CI workers, then uses the composer's own + // keyboard accept path to replace the `/compress` trigger with a command + // chip. Clicking a later completion after typing the argument can insert a + // second command token (for example `//compress ...`) as plain text. + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type('/compress', { delay: 15 }) + await page.getByText('/compress').first().waitFor({ state: 'visible' }) + await page.keyboard.press('Enter') + await composer.type(' preserve the three test turns', { delay: 15 }) + await page.keyboard.press('Enter') + await expect + .poll( + () => page.locator('[data-slot="aui_thread-viewport"]').textContent(), + { timeout: 90_000 }, + ) + .toMatch(/Compressed|No changes from compression/) + + // Compression rotates the agent's live session id. A post-compression + // ordinary turn proves the desktop's runtime binding followed that child. + await send(page, 'E2E_COMPRESSION_FOLLOW_UP') + await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1) + await waitForTranscript(page, reply) + await page.screenshot({ path: 'test-results/session-compression-continuation.png' }) + }) +}) + +test.describe('session compression in progress', () => { + let fixture: MockBackendFixture + + test.beforeAll(async () => { + fixture = await setupMockBackend({ + modelContextLength: 64_000, + extraConfig: `compression: + threshold_tokens: 22000 + protect_first_n: 0 + protect_last_n: 1 +auxiliary: + compression: + provider: custom + model: mock-model`, + mockServer: { + holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.', + } + }) + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => { + const { page } = fixture + const queued = 'E2E_QUEUED_DURING_COMPACTION' + + // A normal message crosses the tiny configured context budget. The mock + // blocks only the resulting summary request, so these assertions run + // during automatic compaction rather than a slash-command path. + await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5)) + await waitForTranscript(page, MOCK_REPLY) + await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5)) + await waitForTranscript(page, MOCK_REPLY) + await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(500)) + await fixture.mock.waitForHeldCompletion() + await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible() + + const primary = page.locator('[data-slot="composer-root"] button[type="submit"]') + await expect(primary).toHaveAttribute('aria-label', 'Queue message') + + await send(page, queued) + await expect(page.getByText('1 Queued')).toBeVisible() + expect(fixture.mock.heldCompletionCount()).toBe(1) + expect(receivedUserTexts()).not.toContain(queued) + await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') }) + + fixture.mock.releaseHeldStream() + await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1) + expect(fixture.mock.heldCompletionCount()).toBe(1) + }) +}) diff --git a/apps/desktop/e2e/sidebar-states.spec.ts b/apps/desktop/e2e/sidebar-states.spec.ts new file mode 100644 index 000000000000..051efda35436 --- /dev/null +++ b/apps/desktop/e2e/sidebar-states.spec.ts @@ -0,0 +1,252 @@ +/** + * E2E tests for desktop sidebar states — background processes, subagents, + * and session dot transitions. + * + * The mock server returns scripted tool_calls that the agent executes for + * real (trivial commands + real subagent delegations). The tests assert the + * sidebar states driven by real gateway events. + * + * Prerequisite: `npm run build` must have been run so dist/ exists. + */ + +import { expect, test, type Page } from '@playwright/test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' +import { SIDEBAR_CROSS_TEXTS, SIDEBAR_TEXTS, restartMockServer } from './mock-server' + +/** Background-running dot aria-label (from i18n en.ts). */ +const BG_DOT_LABEL = 'Background task running' +/** Finished-unread dot aria-label. */ +const UNREAD_DOT_LABEL = 'Finished — unread' + +/** Send a message and wait for the final response to appear. */ +async function sendMessageAndWait( + page: Page, + trigger: string, + finalText: string, + timeout = 90_000, +): Promise { + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type(trigger, { delay: 20 }) + await page.keyboard.press('Enter') + + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_'), + undefined, + { timeout: 15_000 }, + ) + + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + finalText, + { timeout }, + ) +} + +// ──────────────────────────────────────────────────────────────────────── +// Test 1: background process + subagent appear in sidebar during turn +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — background process and subagent', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('background process dot appears and disappears, subagent runs, final answer visible', async () => { + const page = fixture.page + + await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText) + + // The background process (sleep 1) should have shown a "Background task + // running" dot at some point during the turn. We try to catch it; if + // the process was too fast, that's OK — the real assertion is that the + // final answer appeared and the dot is gone afterward. + try { + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 15_000, message: 'background dot should appear' }, + ) + .toBeGreaterThan(0) + } catch { + // sleep 1 may have finished before we polled — not a failure. + } + + // After the turn completes and auto-dismiss fires, the background dot + // should be gone. + await page.waitForTimeout(8000) + const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0) + + // Evidence: capture the final state — no background dot, final answer visible. + await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' }) + + // The final answer text must be in the transcript. + const viewportText = await page + .locator('[data-slot="aui_thread-viewport"]') + .textContent() + expect(viewportText).toContain(SIDEBAR_TEXTS.finalText) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 2: subagent running shows background dot too (longer bg process) +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — subagent and background dot coexist', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('background dot visible while subagent runs', async () => { + const page = fixture.page + + // Start the turn but DON'T wait for the final answer yet — we want + // to assert the background dot is visible WHILE the subagent runs. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'), + undefined, + { timeout: 15_000 }, + ) + + // The background process (sleep 5) should show a "Background task + // running" dot while the subagent is also running. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should appear while subagent runs' }, + ) + .toBeGreaterThan(0) + + // Evidence: the background dot is visible while the subagent runs. + await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' }) + + // Now wait for the final answer to appear. + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // After the turn + auto-dismiss, the background dot should be gone. + await page.waitForTimeout(8000) + const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgCount, 'background dot should be gone after process exits').toBe(0) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 3: cross-session — dot updates when viewing a different session +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — cross-session dot transition', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('background dot transitions to finished when viewing another session', async () => { + const page = fixture.page + + // Start a turn with a long background process (sleep 5). + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the background dot to appear. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should appear' }, + ) + .toBeGreaterThan(0) + + // Wait for the final answer (turn completes, but bg process still running). + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // The background dot should still be visible (sleep 5 hasn't finished yet, + // or auto-dismiss hasn't fired). + const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0) + + // Evidence: bg dot visible on session A while its turn is done but the + // background process hasn't exited yet. + await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' }) + + // Create a new session (click "New session" button). + await page.locator('button:has-text("New session")').first().click() + await page.waitForTimeout(2000) + + // Now wait for the background process to finish (sleep 5 + auto-dismiss). + // The session A dot should transition away from "background running". + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should disappear after process finishes' }, + ) + .toBe(0) + + // The original session should show a "finished unread" indicator (green dot) + // since its turn completed while we were in a different session. This is an + // event-driven transition, so wait for it instead of sampling the DOM right + // after the running dot disappears. + await expect + .poll( + () => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'original session should show finished-unread dot' }, + ) + .toBeGreaterThan(0) + + // Evidence: the green "finished unread" dot on the original session after + // switching to a new session — the cross-session dot transition. + await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' }) + }) +}) diff --git a/apps/desktop/e2e/submit-drift.spec.ts b/apps/desktop/e2e/submit-drift.spec.ts new file mode 100644 index 000000000000..a3c447c9c53e --- /dev/null +++ b/apps/desktop/e2e/submit-drift.spec.ts @@ -0,0 +1,75 @@ +/** + * Regression coverage for #69578: harmless route-token churn during a send + * must not make the desktop silently drop the prompt before prompt.submit. + */ + +import { test, expect } from './test' + +import { + type MockBackendFixture, + setupMockBackend, + waitForAppReady, +} from './fixtures' + +const PROMPT = 'E2E route token drift must still submit this prompt.' + +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('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => { + const { page, mock } = fixture! + const composer = page.locator('[contenteditable="true"]').first() + + await composer.click() + await composer.type(PROMPT, { delay: 10 }) + + // The submit pipeline snapshots the route synchronously, then awaits session + // creation. Keep changing only the query string of whichever chat route is + // current. Before #69578, comparing the raw route token treated this as a + // user chat switch and aborted before prompt.submit. + await page.evaluate(() => { + let revision = 0 + const interval = window.setInterval(() => { + const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new' + window.location.hash = `${pathname}?e2e-route-churn=${revision++}` + }, 1) + + ;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => { + window.clearInterval(interval) + } + }) + + try { + await page.keyboard.press('Enter') + + await expect + .poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 }) + .toBe(true) + + await page.waitForFunction( + prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false, + PROMPT, + { timeout: 15_000 }, + ) + await page.waitForFunction( + () => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false, + undefined, + { timeout: 60_000 }, + ) + } finally { + await page.evaluate(() => { + ;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.() + }) + } + + await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') }) +}) \ No newline at end of file diff --git a/apps/desktop/e2e/tile-unread-bug.spec.ts b/apps/desktop/e2e/tile-unread-bug.spec.ts new file mode 100644 index 000000000000..cd87a2eb6814 --- /dev/null +++ b/apps/desktop/e2e/tile-unread-bug.spec.ts @@ -0,0 +1,210 @@ +/** + * E2E tests for the tile-unread bug — two scenarios: + * + * 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is + * NOT visible on screen. When it finishes, the green "unread" dot IS + * correct — the user isn't looking at it. This test PASSES. + * + * 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the + * workspace zone opens as a split tile, visible on screen at the same time + * as the main session. When it finishes, it should NOT get the green + * "unread" dot — the user is looking right at it. This test FAILS until + * the fix in session-states.ts:174 lands (the unread check only compares + * against $selectedStoredSessionId and ignores $sessionTiles). + * + * 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 { SIDEBAR_CROSS_TEXTS, restartMockServer } from './mock-server' + +/** Finished-unread dot aria-label. */ +const UNREAD_DOT_LABEL = 'Finished — unread' +/** Background-running dot aria-label. */ +const BG_DOT_LABEL = 'Background task running' + +/** Locate a session's sidebar row by its preview text. */ +function sessionRow(page: import('@playwright/test').Page, text: string) { + return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first() +} + +/** Common setup: start a turn with a sleep 5 bg process + subagent, wait for + * the turn to complete, then switch to a new session so the first session is + * no longer $selectedStoredSessionId (required before opening a tile). */ +async function startTurnAndSwitchAway(page: import('@playwright/test').Page) { + // Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent. + const composer = page.locator('[contenteditable="true"]').first() + await composer.waitFor({ state: 'visible', timeout: 10_000 }) + await composer.click() + await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 }) + await page.keyboard.press('Enter') + + // Wait for the user's message to appear. + await page.waitForFunction( + () => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'), + undefined, + { timeout: 15_000 }, + ) + + // Wait for the background dot — confirms the turn is running. + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should appear' }, + ) + .toBeGreaterThan(0) + + // Wait for the turn to complete (final answer visible). + await page.waitForFunction( + (text) => (document.body.textContent ?? '').includes(text), + SIDEBAR_CROSS_TEXTS.finalText, + { timeout: 90_000 }, + ) + + // The background dot should still be visible (sleep 5 hasn't finished). + const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count() + expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0) + + // Switch to a new session — session A is no longer $selectedStoredSessionId. + // This is required: openSessionTile bails if the session is already selected. + await page.locator('button:has-text("New session")').first().click() + await page.waitForTimeout(2000) +} + +/** Wait for the background process to finish (sleep 5 + auto-dismiss). */ +async function waitForBgProcessToFinish(page: import('@playwright/test').Page) { + await expect + .poll( + () => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(), + { timeout: 30_000, message: 'background dot should disappear after process finishes' }, + ) + .toBe(0) +} + +// ──────────────────────────────────────────────────────────────────────── +// Test 1: TAB (not visible) — unread dot IS correct (PASSES) +// ──────────────────────────────────────────────────────────────────────── + +test.describe('sidebar states — tab (hidden) unread is correct', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('session opened as a tab (not visible) correctly gets unread dot', async () => { + const page = fixture.page + + await startTurnAndSwitchAway(page) + + // Evidence: session A is in the background (bg dot in sidebar). + await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' }) + + // ⌃-click opens the session as a TAB (center dock = stacked, not visible + // unless it's the active tab). The session is NOT on screen. + const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText) + await row.click({ modifiers: ['Control'] }) + await page.waitForTimeout(2000) + + // Evidence: the tab is open but the session is not visible on screen. + await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' }) + + await waitForBgProcessToFinish(page) + + // A tab that's not the active tab IS hidden — the unread dot is correct. + // The user is NOT looking at it, so marking it "unread" is right. + const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count() + expect(unreadCount, 'hidden tab should be marked unread').toBeGreaterThan(0) + + await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' }) + }) +}) + +// ──────────────────────────────────────────────────────────────────────── +// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix) +// ──────────────────────────────────────────────────────────────────────── + +test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => { + test.describe.configure({ mode: 'serial' }) + + let fixture: MockBackendFixture + + test.beforeAll(async () => { + restartMockServer() + fixture = await setupMockBackend() + await waitForAppReady(fixture, 120_000) + }) + + test.afterAll(async () => { + await fixture?.cleanup() + }) + + test('session visible in a split tile does NOT get unread dot when it finishes', async () => { + const page = fixture.page + + await startTurnAndSwitchAway(page) + + // Evidence: session A is in the background (bg dot in sidebar). + await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' }) + + // Drag the session row from the sidebar to the right edge of the workspace + // zone to create a SPLIT (side-by-side) tile. This triggers the real + // startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path. + const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText) + const rowBox = await row.boundingBox() + expect(rowBox, 'session row must be visible').not.toBeNull() + + // Find the workspace zone — the main chat area. We drop on its right edge. + const workspace = page.locator('[data-session-anchor="workspace"]') + const wsBox = await workspace.boundingBox() + expect(wsBox, 'workspace zone must be visible').not.toBeNull() + + // Drag from the session row to the right edge of the workspace. + // The drag-session's subZonePosition resolves a right-edge drop as 'right' + // (a split dock), not 'center' (which would be a composer link). + await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2) + await page.mouse.down() + // Move in steps so the drag-session's pointermove handler tracks the + // position and resolves the drop zone (a single jump can miss the + // threshold/engage logic). + const targetX = wsBox!.x + wsBox!.width - 20 + const targetY = wsBox!.y + wsBox!.height / 2 + const steps = 10 + for (let i = 1; i <= steps; i++) { + const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps) + const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps) + await page.mouse.move(x, y) + await page.waitForTimeout(30) + } + await page.mouse.up() + await page.waitForTimeout(2000) + + // Evidence: the split tile is now open side-by-side — both sessions visible. + await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' }) + + await waitForBgProcessToFinish(page) + + // THE BUG: the session visible in the split tile should NOT have the green + // "finished unread" dot — the user is looking right at it. This assertion + // FAILS until the fix in session-states.ts:174 lands. + const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count() + expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0) + + // Evidence: the green dot should NOT be here — this screenshot shows the bug. + await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' }) + }) +}) diff --git a/apps/desktop/e2e/warm-resume-jitter.spec.ts b/apps/desktop/e2e/warm-resume-jitter.spec.ts new file mode 100644 index 000000000000..f53241269f39 --- /dev/null +++ b/apps/desktop/e2e/warm-resume-jitter.spec.ts @@ -0,0 +1,484 @@ +/** + * E2E regression: warm-route resume must not re-render the transcript more + * than once. + * + * When a session is already in the runtime-id cache (the "warm" path in + * `resumeSession()`), clicking its sidebar row should paint the transcript + * exactly once. Before the fix, the warm cache painted via + * `syncSessionStateToView`, then the `session.activate` RPC returned a + * reconciled message list with different message object references, causing + * `syncSessionStateToView` to fire a second `setMessages` — a visual + * flicker as the transcript DOM was updated. + * + * This test pre-seeds a 32-message session into state.db, boots the app, + * clicks the session (cold resume — populates the warm cache), navigates + * away to a new chat, then clicks back (warm resume). Two detectors run: + * + * 1. A MutationObserver counts additive DOM mutation bursts (childList + * additions). More than 1 burst = the transcript was repainted. + * + * 2. A 2ms innerHTML-length poll counts "reconciles" — DOM content changes + * that happen AFTER the initial paint, while messages are already on + * screen. This catches the case where React reconciles by key without + * adding/removing nodes (same keys → in-place prop update → no + * MutationObserver burst), but `$messages` was still set twice. + * + * The test passes when bursts === 1 AND reconciles === 0. + * + * Prerequisite: `npm run build` must have been run so 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 { expect, test } from './test' + +import { + type MockBackendFixture, + waitForAppReady, + createSandbox, + writeMockProviderConfig, + writeEnvFile, + buildAppEnv, + launchDesktop, +} from './fixtures' +import { startMockServer } from './mock-server' + +const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..') +const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..') +const SEED_SCRIPT = path.join(DESKTOP_ROOT, 'e2e', 'scripts', 'seed_session_db.py') +const SESSION_TITLE = 'E2E Warm Resume Jitter Test' +const SESSION_ID = 'e2e-warm-resume-session' +/** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */ +const MESSAGE_COUNT = 32 +/** Seeded PRNG so the generated content is deterministic across runs. */ +const RNG_SEED = 42 + +/** Mulberry32 — tiny deterministic PRNG. */ +function mulberry32(seed: number): () => number { + let a = seed + return () => { + a |= 0 + a = (a + 0x6d2b79f5) | 0 + let t = Math.imul(a ^ (a >>> 15), 1 | a) + t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t + return ((t ^ (t >>> 14)) >>> 0) / 4294967296 + } +} + +/** Generate ~40 chars of gibberish from a seeded PRNG. */ +function gibberish(rng: () => number): string { + const len = 30 + Math.floor(rng() * 20) + let s = '' + for (let i = 0; i < len; i++) { + s += String.fromCharCode(97 + Math.floor(rng() * 26)) + } + return s +} + +/** First user message — used as a wait target in the test. */ +const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED)) + +/** + * Generate a session fixture with MESSAGE_COUNT messages (user/assistant + * pairs) of seeded gibberish — just role + content, enough for SessionDB + * to import and the transcript to render. Written to a temp file for the + * seed script. + */ +function generateSessionFixture(fixturePath: string): void { + const rng = mulberry32(RNG_SEED) + const messages: Array<{ role: string; content: string }> = [] + + for (let i = 0; i < MESSAGE_COUNT / 2; i++) { + messages.push({ role: 'user', content: gibberish(rng) }) + messages.push({ role: 'assistant', content: gibberish(rng) }) + } + + const session = { + id: SESSION_ID, + source: 'cli', + model: 'mock-model', + system_prompt: '', + started_at: 1721692800.0, + message_count: MESSAGE_COUNT, + title: SESSION_TITLE, + cwd: '/tmp', + archived: 0, + rewind_count: 0, + compression_fallback_streak: 0, + messages, + } + + fs.writeFileSync(fixturePath, JSON.stringify(session), 'utf8') +} + +/** Resolve the python binary from the nix devshell (falls back to python3). */ +function findPython(): string { + const result = spawnSync('which', ['python'], { encoding: 'utf8' }) + if (result.status === 0 && result.stdout.trim()) { + return result.stdout.trim() + } + return 'python3' +} + +/** + * Set up a mock-backend sandbox with a pre-seeded session in state.db. + * + * Unlike the shared `setupMockBackend()`, this variant seeds the DB + * BEFORE launching the app so the session appears in the sidebar on first + * load — exercising the real `resumeSession()` cold path without needing + * to send a message first. + */ +async function setupSeededMockBackend(): Promise { + // 1. Start mock server + const mock = await startMockServer() + + // 2. Create sandbox + write config + const sandbox = createSandbox('warm-seed') + writeMockProviderConfig(sandbox.hermesHome, mock.url) + writeEnvFile(sandbox.hermesHome) + + // 3. Pre-seed state.db: generate a fixture JSON to a temp file, then + // run the seed script to import it into state.db BEFORE launching. + const stateDbPath = path.join(sandbox.hermesHome, 'state.db') + const fixturePath = path.join(os.tmpdir(), `hermes-e2e-warm-resume-${Date.now()}.json`) + generateSessionFixture(fixturePath) + const python = findPython() + const seedResult = spawnSync( + python, + [SEED_SCRIPT, stateDbPath, fixturePath], + { + cwd: REPO_ROOT, + env: { ...process.env, PYTHONPATH: REPO_ROOT }, + encoding: 'utf8', + timeout: 30_000, + }, + ) + fs.unlinkSync(fixturePath) + + if (seedResult.status !== 0) { + throw new Error( + `Failed to seed state.db:\nstdout: ${seedResult.stdout}\nstderr: ${seedResult.stderr}`, + ) + } + + // 4. Build env + launch + const env = buildAppEnv(sandbox) + const { app, page } = await launchDesktop(env) + + return { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } +} + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + fixture = await setupSeededMockBackend() + await waitForAppReady(fixture!, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +/** + * Install a MutationObserver + text-content poll on the thread viewport + * to detect re-renders after the initial paint. Returns nothing — call + * `readRenderCount` to stop and collect results. + * + * - MutationObserver: counts additive childList bursts (5ms coalescing). + * - Text-content poll: counts "reconciles" — first-message text changes + * after the initial paint, catching key-based reconciles that don't + * add/remove nodes. + */ +async function installRenderCounter(page: import('@playwright/test').Page): Promise { + await page.evaluate(() => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) { + throw new Error('Thread viewport not found before warm resume') + } + + const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 } + ;(window as unknown as { __RENDER_COUNT__: typeof state }).__RENDER_COUNT__ = state + + let currentBatch = 0 + let flushTimer: ReturnType | null = null + + const flush = () => { + flushTimer = null + if (currentBatch > 0 && !state.stopped) { + state.bursts += 1 + state.timeline.push(currentBatch) + currentBatch = 0 + } + } + + const observer = new MutationObserver(records => { + if (state.stopped) return + let batchAdded = 0 + for (const record of records) { + state.mutations += 1 + if (record.type === 'childList' && record.addedNodes.length > 0) { + batchAdded += 1 + } + } + if (batchAdded > 0) { + currentBatch += batchAdded + if (flushTimer) clearTimeout(flushTimer) + flushTimer = setTimeout(flush, 5) + } + }) + + observer.observe(viewport, { + childList: true, + subtree: true, + attributes: false, + characterData: false, + }) + + // Poll the first message's text content every 2ms. The MutationObserver + // only catches childList additions; React may reconcile by key without + // adding/removing nodes (same keys → in-place prop update → no childList + // mutation). The poll catches this by detecting text content changes in + // the first message after the initial paint. Metadata-only changes (model + // name, busy indicator) don't affect message text, so they don't produce + // false positives. + const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport + let lastFirstMsgText = '' + let hasMessages = false + const pollInterval = setInterval(() => { + if (state.stopped) { + clearInterval(pollInterval) + return + } + const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]') + const firstMsgText = firstMsg?.textContent ?? '' + if (firstMsgText && firstMsgText !== lastFirstMsgText) { + if (hasMessages) { + state.reconciles = (state.reconciles ?? 0) + 1 + } + lastFirstMsgText = firstMsgText + hasMessages = true + } + }, 2) + }) +} + +/** Stop the render counter and return the recorded burst/reconcile counts. */ +async function readRenderCount(page: import('@playwright/test').Page): Promise<{ + bursts: number + mutations: number + timeline: number[] + reconciles: number +} | null> { + return page.evaluate(() => { + type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number } + const w = window as unknown as { __RENDER_COUNT__?: RenderCount } + const rc = w.__RENDER_COUNT__ + if (rc) { + rc.stopped = true + } + return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null + }) +} + +/** Assert the render counter shows exactly one paint with no re-renders. */ +function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void { + expect(result, 'MutationObserver should have recorded render data').toBeTruthy() + expect( + result!.bursts, + `Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` + + `Mutation timeline: ${JSON.stringify(result!.timeline)}.`, + ).toBe(1) + expect( + result!.reconciles, + `Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` + + `This means the warm-route resume re-rendered the transcript after the initial paint ` + + `— the "warm resume jitter" bug is present.`, + ).toBe(0) +} + +test('warm-route resume paints transcript exactly once (no jitter)', async ({}, testInfo) => { + const page = fixture!.page + + // Wait for the sidebar to populate with our seeded session. + const sessionRow = page + .locator('[data-slot="sidebar"] button') + .filter({ hasText: SESSION_TITLE }) + .first() + await sessionRow.waitFor({ state: 'visible', timeout: 60_000 }) + + // Step 1: Cold resume — click the session row to load it. + // This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId). + await sessionRow.click() + + // Wait for the transcript to appear — the first user message text confirms + // the cold-path prefetch painted. + await page.waitForFunction( + (text: string) => + document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ?? + false, + FIRST_USER_MSG, + { timeout: 30_000 }, + ) + + // Wait for the session to fully settle (cold-path RPC + reconciliation). + await page.waitForTimeout(2_000) + + // Step 2: Navigate away to a new chat — this does NOT evict the warm cache. + const newSessionButton = page + .locator('[data-slot="sidebar"] button[aria-label="New session"]') + .first() + await newSessionButton.click() + + // Wait for the new-chat empty state. + await page.waitForFunction( + (firstMsg: string) => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return false + const text = viewport.textContent ?? '' + return !text.includes(firstMsg) + }, + FIRST_USER_MSG, + { timeout: 15_000 }, + ) + + await page.waitForTimeout(500) + + // Step 3: Install render counter, click back (warm resume), wait, assert. + await installRenderCounter(page) + await sessionRow.click() + + await page.waitForFunction( + (text: string) => + document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ?? + false, + FIRST_USER_MSG, + { timeout: 30_000 }, + ) + + // Wait for at least 1 burst, then settle. + await page.waitForFunction( + () => { + const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } } + return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0) + }, + undefined, + { timeout: 10_000 }, + ) + await page.waitForTimeout(2_000) + + const result = await readRenderCount(page) + await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') }) + assertNoJitter(result) +}) + +test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => { + test.fixme( + true, + 'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).', + ) + + const page = fixture!.page + const { mock } = fixture! + + // Wait for the sidebar to populate with our seeded session. + const sessionRow = page + .locator('[data-slot="sidebar"] button') + .filter({ hasText: SESSION_TITLE }) + .first() + await sessionRow.waitFor({ state: 'visible', timeout: 60_000 }) + + // Step 1: Cold resume — populate the warm cache. + await sessionRow.click() + await page.waitForFunction( + (text: string) => + document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ?? + false, + FIRST_USER_MSG, + { timeout: 30_000 }, + ) + await page.waitForTimeout(2_000) + + // Step 2: Send a message — triggers inference via the mock server. + const PROMPT = 'E2E post-inference warm resume test prompt' + const composer = page.locator('[contenteditable="true"]').first() + await composer.click() + await composer.type(PROMPT, { delay: 10 }) + await page.keyboard.press('Enter') + + // Wait for the mock response to appear in the transcript, confirming + // the turn completed and message.complete fired (which updates the warm + // cache via updateSessionState). + await page.waitForFunction( + () => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + return viewport?.textContent?.includes('mock inference server') ?? false + }, + undefined, + { timeout: 60_000 }, + ) + // Extra settle for message.complete → updateSessionState → cache write. + await page.waitForTimeout(2_000) + + // Verify the prompt was received by the mock server. + expect(mock.receivedPrompts).toContain(PROMPT) + + // Step 3: Navigate away — the warm cache retains the updated messages. + const newSessionButton = page + .locator('[data-slot="sidebar"] button[aria-label="New session"]') + .first() + await newSessionButton.click() + await page.waitForFunction( + (prompt: string) => { + const viewport = document.querySelector('[data-slot="aui_thread-viewport"]') + if (!viewport) return false + return !(viewport.textContent ?? '').includes(prompt) + }, + PROMPT, + { timeout: 15_000 }, + ) + await page.waitForTimeout(500) + + // Step 4: Install render counter, click back (warm resume), wait, assert. + await installRenderCounter(page) + await sessionRow.click() + + // Wait for the transcript to reappear — the warm cache should already + // have the completed turn (updated by message.complete events). + await page.waitForFunction( + (text: string) => + document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ?? + false, + FIRST_USER_MSG, + { timeout: 30_000 }, + ) + + // Wait for at least 1 burst, then settle. + await page.waitForFunction( + () => { + const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } } + return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0) + }, + undefined, + { timeout: 10_000 }, + ) + await page.waitForTimeout(2_000) + + const result = await readRenderCount(page) + await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') }) + assertNoJitter(result) +}) diff --git a/apps/desktop/e2e/worktree-branch-status.spec.ts b/apps/desktop/e2e/worktree-branch-status.spec.ts new file mode 100644 index 000000000000..a3d5dac217e1 --- /dev/null +++ b/apps/desktop/e2e/worktree-branch-status.spec.ts @@ -0,0 +1,94 @@ +import { execFileSync } from 'node:child_process' +import * as fs from 'node:fs' +import * as path from 'node:path' + +import { test, expect } from './test' + +import { + buildAppEnv, + createSandbox, + launchDesktop, + writeEnvFile, + writeMockProviderConfig, + type MockBackendFixture, + waitForAppReady, +} from './fixtures' +import { startMockServer } from './mock-server' + +const BRANCH_NAME = 'e2e-composer-branch' + +function createGitRepo(root: string): string { + const repo = path.join(root, 'repo') + + fs.mkdirSync(repo, { recursive: true }) + execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo }) + execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo }) + execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo }) + fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8') + execFileSync('git', ['add', 'README.md'], { cwd: repo }) + execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo }) + + return repo +} + +function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void { + writeMockProviderConfig(hermesHome, mockUrl) + fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8') + writeEnvFile(hermesHome) +} + +let fixture: MockBackendFixture | null = null + +test.beforeAll(async () => { + const sandbox = createSandbox('worktree-branch-status') + const repo = createGitRepo(sandbox.root) + const mock = await startMockServer() + + configureRepoCwd(sandbox.hermesHome, mock.url, repo) + + const { app, page } = await launchDesktop(buildAppEnv(sandbox)) + fixture = { + app, + page, + mock, + mockUrl: mock.url, + sandbox, + cleanup: async () => { + await app.close().catch(() => undefined) + await mock.close() + sandbox.cleanup() + }, + } + + await waitForAppReady(fixture, 120_000) +}) + +test.afterAll(async () => { + await fixture?.cleanup() + fixture = null +}) + +test('creating a branch with ctrl-shift-b updates the composer git-status branch', async ({}, testInfo) => { + const page = fixture!.page + const codingRow = page.locator('.coding-status-bar') + const composer = page.locator('[contenteditable="true"]').first() + + await composer.click() + await composer.type('create a repo-backed e2e session', { delay: 2 }) + await page.keyboard.press('Enter') + await page.waitForFunction( + prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt), + 'create a repo-backed e2e session', + { timeout: 15_000 }, + ) + await expect(codingRow).toContainText('main') + await page.keyboard.press('Control+Shift+B') + + const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first() + await expect(branchInput).toBeVisible() + await branchInput.fill(BRANCH_NAME) + await page.getByRole('button', { name: 'New worktree' }).click() + + await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 }) + await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') }) +}) diff --git a/apps/desktop/electron/backend-probes.test.ts b/apps/desktop/electron/backend-probes.test.ts index 43b39aafd862..bddc61152f80 100644 --- a/apps/desktop/electron/backend-probes.test.ts +++ b/apps/desktop/electron/backend-probes.test.ts @@ -12,7 +12,12 @@ import path from 'node:path' import { test } from 'vitest' -import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes' +import { + canImportHermesCli, + hermesRuntimeImportProbe, + shouldTrustHermesOverride, + verifyHermesCli +} from './backend-probes' // Resolve the host's own Node binary -- guaranteed to be on disk and // runnable. We use it as both a stand-in for "a python that doesn't @@ -51,6 +56,15 @@ test('hermes runtime import probe checks config dependencies', () => { assert.match(probe, /\bimport hermes_cli\.config\b/) }) +test('explicit Hermes override is authoritative', () => { + assert.equal(shouldTrustHermesOverride('/nix/store/abc/bin/hermes'), true) +}) + +test('empty Hermes override is not authoritative', () => { + assert.equal(shouldTrustHermesOverride(''), false) + assert.equal(shouldTrustHermesOverride(undefined), false) +}) + test('verifyHermesCli returns false when command is falsy', () => { assert.equal(verifyHermesCli(''), false) assert.equal(verifyHermesCli(null), false) diff --git a/apps/desktop/electron/backend-probes.ts b/apps/desktop/electron/backend-probes.ts index 0617bddde701..f196f5a7b772 100644 --- a/apps/desktop/electron/backend-probes.ts +++ b/apps/desktop/electron/backend-probes.ts @@ -104,6 +104,16 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record 0 +} + function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) { if (!hermesCommand) { return false @@ -123,4 +133,4 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) { } } -export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli } +export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verifyHermesCli } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 7bf789ccf118..3856cd2abc22 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -34,7 +34,7 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child' import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command' import { createBackendConnectionState } from './backend-connection-state' import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env' -import { canImportHermesCli, verifyHermesCli } from './backend-probes' +import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes' import { waitForDashboardPortAnnouncement } from './backend-ready' import { shouldLatchBackendStartFailure } from './backend-start-failure' import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform' @@ -80,19 +80,6 @@ import { installEmbedReferer } from './embed-referer' import { createEventDeduper } from './event-dedupe' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' -import { runNativeLogin } from './native-oauth-login' -import { - nativeRefreshUrl, - parseTokenResponse, - resolveLoginStrategy, - tokenNeedsRefresh, - type NativeTokenSet -} from './native-oauth' -import { - oauthSessionIsLive, - resolveJsonBody, - resolveOauthRestAuth -} from './native-auth-decisions' import { scanGitRepos } from './git-repo-scan' import { fileDiffVsHead, @@ -129,6 +116,15 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' +import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions' +import { + nativeRefreshUrl, + type NativeTokenSet, + parseTokenResponse, + resolveLoginStrategy, + tokenNeedsRefresh +} from './native-oauth' +import { runNativeLogin } from './native-oauth-login' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' @@ -3568,7 +3564,11 @@ function resolveHermesBackend(backendArgs) { // and lets the resolver fall through to step 6 / bootstrap. const shellForProbe = isCommandScript(hermesCommand) - if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) { + // HERMES_DESKTOP_HERMES is an explicit deployment override (used by + // the Nix wrapper), not a discovered PATH candidate. It must not fall + // through to the install-script bootstrap if the optional probe times + // out under load; the pinned backend is the only valid runtime there. + if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) { return ( unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || { label: `existing Hermes CLI at ${hermesCommand}`, @@ -5851,6 +5851,7 @@ async function ensureNativeAccessToken(baseUrl: string): Promise { refresh_token: tokens.refreshToken, provider: tokens.provider }, { timeoutMs: 10_000 } ) + const rotated = parseTokenResponse(body) _storeNativeTokens(baseUrl, rotated) @@ -5883,6 +5884,7 @@ async function mintGatewayWsTicket(baseUrl) { timeoutMs: 8_000, bearer: nativeAt })) as any + const ticket = body?.ticket if (!ticket || typeof ticket !== 'string') { @@ -6459,10 +6461,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon // RFC 8252 flow) counts as connected too — otherwise a completed native // sign-in shows "not connected" in Settings. The authoritative liveness // check is the ws-ticket mint in resolveRemoteBackend at actual connect time. - remoteOauthConnected = oauthSessionIsLive( - hasNativeSession(remoteUrl), - await hasLiveOauthSession(remoteUrl) - ) + remoteOauthConnected = oauthSessionIsLive(hasNativeSession(remoteUrl), await hasLiveOauthSession(remoteUrl)) } catch { remoteOauthConnected = false } @@ -8955,6 +8954,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => postJson: (url, body, opts) => postJsonNoAuth(url, body, opts), rememberLog }) + _storeNativeTokens(baseUrl, tokens) return { ok: true, baseUrl, connected: true } @@ -8977,6 +8977,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) => ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => { const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : '' await clearOauthSession(baseUrl || undefined) + // Also drop any native (RFC 8252) bearer tokens for this gateway so a // logout clears BOTH auth shapes. if (baseUrl) { @@ -8986,9 +8987,7 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) = // Report against the SAME liveness notion the Settings indicator uses // (AT-or-RT cookie, or a native token) so a logout that left any session // behind is reflected as still-connected rather than silently signed-out. - const connected = baseUrl - ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) - : false + const connected = baseUrl ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) : false return { ok: true, connected } }) diff --git a/apps/desktop/electron/native-auth-decisions.test.ts b/apps/desktop/electron/native-auth-decisions.test.ts index 6343c4d1c1cc..09f648c87192 100644 --- a/apps/desktop/electron/native-auth-decisions.test.ts +++ b/apps/desktop/electron/native-auth-decisions.test.ts @@ -10,11 +10,7 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { - oauthSessionIsLive, - resolveJsonBody, - resolveOauthRestAuth -} from './native-auth-decisions' +import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions' // --- 1. body encoding (guards the double-JSON.stringify 422) --- diff --git a/apps/desktop/electron/native-auth-decisions.ts b/apps/desktop/electron/native-auth-decisions.ts index f85f8c59a575..d76746f669a3 100644 --- a/apps/desktop/electron/native-auth-decisions.ts +++ b/apps/desktop/electron/native-auth-decisions.ts @@ -44,9 +44,7 @@ export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: bo return hasNativeToken || hasCookieSession } -export type OauthRestAuth = - | { kind: 'bearer'; token: string } - | { kind: 'cookie' } +export type OauthRestAuth = { kind: 'bearer'; token: string } | { kind: 'cookie' } /** * Decide how an oauth-mode REST request authenticates: prefer the native diff --git a/apps/desktop/electron/native-oauth-login.test.ts b/apps/desktop/electron/native-oauth-login.test.ts index 23b7e9c7275c..6d1651bc715f 100644 --- a/apps/desktop/electron/native-oauth-login.test.ts +++ b/apps/desktop/electron/native-oauth-login.test.ts @@ -22,14 +22,18 @@ function makeFakeServerFactory(port = 51234) { const createServer: any = (handler: any) => { state.handler = handler const server: any = new EventEmitter() + server.listen = (_port: number, _host: string, cb: () => void) => { state.listening = true cb() } + server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port }) + server.close = () => { state.closed = true } + state.server = server return server diff --git a/apps/desktop/electron/native-oauth-login.ts b/apps/desktop/electron/native-oauth-login.ts index 20d53d10ab7e..6179e30fab3a 100644 --- a/apps/desktop/electron/native-oauth-login.ts +++ b/apps/desktop/electron/native-oauth-login.ts @@ -32,10 +32,10 @@ import { buildNativeAuthorizeUrl, generatePkcePair, generateState, + type NativeTokenSet, nativeTokenUrl, parseLoopbackCallback, - parseTokenResponse, - type NativeTokenSet + parseTokenResponse } from './native-oauth' // Loopback login must complete inside this window (user opens browser, @@ -90,6 +90,7 @@ export async function runNativeLogin( return new Promise((resolve, reject) => { let settled = false let timer: NodeJS.Timeout | null = null + const server = createServer((req, res) => { // Only the callback path carries the code; any other path (favicon, // etc.) still gets the friendly page so the browser tab looks sane. @@ -179,6 +180,7 @@ export async function runNativeLogin( } const redirectUri = `http://127.0.0.1:${addr.port}/callback` + const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, { challenge, redirectUri, diff --git a/apps/desktop/electron/native-oauth.test.ts b/apps/desktop/electron/native-oauth.test.ts index 2f3da633ccd3..58d863c4b1a6 100644 --- a/apps/desktop/electron/native-oauth.test.ts +++ b/apps/desktop/electron/native-oauth.test.ts @@ -34,6 +34,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => { assert.equal(pair.method, 'S256') // Verifier length within RFC 7636 range (43–128). assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128) + // Challenge must be the base64url SHA-256 of the verifier. const expected = createHash('sha256') .update(pair.verifier, 'ascii') @@ -41,6 +42,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => { .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, '') + assert.equal(pair.challenge, expected) // No padding / URL-unsafe chars. assert.doesNotMatch(pair.verifier, /[+/=]/) @@ -91,6 +93,7 @@ test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => { state: 'STATE', provider: 'nous' }) + const parsed = new URL(url) assert.equal(parsed.origin, 'https://gw.example.com') @@ -108,6 +111,7 @@ test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix redirectUri: 'http://127.0.0.1:1/cb', state: 'S' }) + const parsed = new URL(url) assert.equal(parsed.pathname, '/hermes/auth/native/authorize') @@ -128,10 +132,7 @@ test('parseLoopbackCallback returns the code on a state match', () => { }) test('parseLoopbackCallback throws on state mismatch (CSRF)', () => { - assert.throws( - () => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), - /state mismatch/i - ) + assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i) }) test('parseLoopbackCallback surfaces a gateway error param', () => { diff --git a/apps/desktop/electron/native-oauth.ts b/apps/desktop/electron/native-oauth.ts index 9e8bc6fdbd29..691cd32caca6 100644 --- a/apps/desktop/electron/native-oauth.ts +++ b/apps/desktop/electron/native-oauth.ts @@ -86,10 +86,7 @@ export function statusSupportsNativeFlow(statusBody: any): boolean { * (e.g. a corporate proxy that blocks loopback). Precedence written down here, * in one place, as a pure function — per the desktop "observable ladder" rule. */ -export function resolveLoginStrategy( - statusBody: any, - opts: { forceEmbedded?: boolean } = {} -): 'native' | 'embedded' { +export function resolveLoginStrategy(statusBody: any, opts: { forceEmbedded?: boolean } = {}): 'native' | 'embedded' { if (opts.forceEmbedded) { return 'embedded' } @@ -109,6 +106,7 @@ export function buildNativeAuthorizeUrl( ): string { const parsed = new URL(baseUrl) const prefix = parsed.pathname.replace(/\/+$/, '') + const q = new URLSearchParams({ code_challenge: params.challenge, code_challenge_method: 'S256', @@ -145,10 +143,7 @@ export function nativeRefreshUrl(baseUrl: string): string { * `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch * throws rather than proceeding. */ -export function parseLoopbackCallback( - requestUrl: string, - expectedState: string -): { code: string } { +export function parseLoopbackCallback(requestUrl: string, expectedState: string): { code: string } { // requestUrl is the path+query the loopback server received, e.g. // "/callback?code=...&state=...". Resolve against a dummy origin to parse. const parsed = new URL(requestUrl, 'http://127.0.0.1') @@ -203,7 +198,11 @@ export function parseTokenResponse(body: any): NativeTokenSet { * before use. `skewSeconds` refreshes slightly early to avoid a race where * the token expires in flight (mirrors the server's 60s cookie floor). */ -export function tokenNeedsRefresh(tokens: Pick, nowSeconds: number, skewSeconds = 60): boolean { +export function tokenNeedsRefresh( + tokens: Pick, + nowSeconds: number, + skewSeconds = 60 +): boolean { if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) { // Unknown expiry ⇒ treat as needing refresh so we validate before use. return true diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 743269c10476..643126546507 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -11,15 +11,19 @@ "node": "^20.19.0 || >=22.12.0" }, "scripts": { + "clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron", + "clean:e2e":"tsc --build tsconfig.e2e.json --clean", + "clean:renderer":"tsc --build tsconfig.json --clean ", + "clean:electron":"tsc --build tsconfig.electron.json --clean", "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 .", - "profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", + "dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174", + "dev:electron": "tsc --build tsconfig.electron.json && 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": "tsc --build tsconfig.electron.json && 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 .", + "profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .", "start": "npm run build && electron .", - "prebuild": "tsc -b . --clean", + "prebuild": "npm run clean", "build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs", "postbuild": "node scripts/assert-dist-built.mjs", "prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs", @@ -51,9 +55,9 @@ "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", - "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" + "test:e2e": "npm run build && playwright test e2e/", + "test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list", + "test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots" }, "dependencies": { "@assistant-ui/react": "^0.14.23", diff --git a/apps/desktop/src/app/chat/close-tab.ts b/apps/desktop/src/app/chat/close-tab.ts index d5a5a0bcae14..098e59108aea 100644 --- a/apps/desktop/src/app/chat/close-tab.ts +++ b/apps/desktop/src/app/chat/close-tab.ts @@ -2,17 +2,25 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals' import { closeWorkspaceTab } from '@/components/pane-shell/tree/store' import { isFocusWithin } from '@/lib/keybinds/combo' import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview' +import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states' /** * ⌘W — close the tab of the context you're in, by precedence: * 1. a focused terminal → its active terminal tab, * 2. right-rail tabs (live preview and/or file peeks), * 3. the MAIN zone → its active tab (a session tile stacked into the workspace). + * 4. the MAIN (workspace) tab itself, when session tabs are stacked with it: + * the workspace can't close, so ⌘W shifts the NEXT session tab into main + * (loads it as the primary + drops its now-redundant tile). * Returns false when nothing closes, so ⌘W is a no-op — it never closes the * window (a bare workspace stays put). Shared by the keyboard path (Win/Linux) * and the macOS menu-accelerator IPC. + * + * `loadSessionIntoWorkspace` carries the app's route-based "load this session + * into main" (the two call sites have router access); omitting it disables the + * step-4 promotion (⌘W stays the pre-existing no-op on the main tab). */ -export function closeActiveTab(): boolean { +export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean { if (isFocusWithin('[data-terminal]')) { closeActiveTerminal() @@ -28,5 +36,27 @@ export function closeActiveTab(): boolean { return closeActiveRightRailTab() } - return closeWorkspaceTab() + // A closeable main-zone tab (a session tile that's the active tab) closes + // outright; the uncloseable workspace tab returns false and falls through. + if (closeWorkspaceTab()) { + return true + } + + // The main (workspace) tab is active and can't be closed — but if session + // tabs are stacked with it, ⌘W shifts the next one into the main tab: drop + // its tile (the session stays alive, no busy-close prompt) and load it into + // main. Order matters — close the tile FIRST so the selection homes to the + // workspace instead of re-fronting the tile. + if (loadSessionIntoWorkspace) { + const next = nextSessionTileForWorkspace() + + if (next) { + closeSessionTile(next) + loadSessionIntoWorkspace(next) + + return true + } + } + + return false } diff --git a/apps/desktop/src/app/chat/composer/controls.test.tsx b/apps/desktop/src/app/chat/composer/controls.test.tsx new file mode 100644 index 000000000000..90d38d274954 --- /dev/null +++ b/apps/desktop/src/app/chat/composer/controls.test.tsx @@ -0,0 +1,79 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ChatBarState } from '@/app/chat/composer/types' +import { I18nProvider } from '@/i18n' + +import { ComposerControls } from './controls' + +vi.mock('./model-pill', () => ({ ModelPill: () => null })) + +const state: ChatBarState = { + model: { canSwitch: false, model: '', provider: '' }, + tools: { enabled: false, label: '' }, + voice: { active: false, enabled: false } +} + +function renderControls(overrides: Partial> = {}) { + return render( + + + + ) +} + +async function expectShortcutTooltip(label: string, shortcut: string) { + fireEvent.pointerMove(screen.getByLabelText(label), { pointerType: 'mouse' }) + + const tooltip = await screen.findByRole('tooltip') + + expect(tooltip.textContent).toContain(label) + expect(tooltip.textContent).toContain(shortcut) +} + +afterEach(() => { + cleanup() +}) + +describe('ComposerControls shortcut tooltips', () => { + it('shows Enter for Send', async () => { + renderControls() + + await expectShortcutTooltip('Send', '↵') + }) + + it('shows Enter for Steer', async () => { + renderControls({ busy: true, busyAction: 'steer' }) + + await expectShortcutTooltip('Steer the current run', '↵') + }) + + it('shows Ctrl+Enter for Queue', async () => { + renderControls({ busy: true, busyAction: 'queue' }) + + await expectShortcutTooltip('Queue message', 'Ctrl+↵') + }) +}) diff --git a/apps/desktop/src/app/chat/composer/controls.tsx b/apps/desktop/src/app/chat/composer/controls.tsx index 7852ee81ef56..996b962f5448 100644 --- a/apps/desktop/src/app/chat/composer/controls.tsx +++ b/apps/desktop/src/app/chat/composer/controls.tsx @@ -1,11 +1,9 @@ import { Button } from '@/components/ui/button' import { Codicon } from '@/components/ui/codicon' -import { KbdCombo } from '@/components/ui/kbd' -import { Tip } from '@/components/ui/tooltip' +import { Tip, TipKeybindLabel } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons' -import { formatCombo } from '@/lib/keybinds/combo' import { cn } from '@/lib/utils' import type { ConversationStatus } from './hooks/use-voice-conversation' @@ -42,7 +40,6 @@ export function ComposerControls({ autoSpeak, busy, busyAction, - canSteer, canSubmit, compactModelPill = false, conversation, @@ -51,13 +48,12 @@ export function ComposerControls({ state, voiceStatus, onDictate, - onSteer, + onQueue, onToggleAutoSpeak }: { autoSpeak: boolean busy: boolean - busyAction: 'queue' | 'stop' - canSteer: boolean + busyAction: 'steer' | 'queue' | 'stop' canSubmit: boolean compactModelPill?: boolean conversation: ConversationProps @@ -66,50 +62,39 @@ export function ComposerControls({ state: ChatBarState voiceStatus: VoiceStatus onDictate: () => void - onSteer: () => void + onQueue: () => void onToggleAutoSpeak: () => void }) { const { t } = useI18n() const c = t.composer - const steerCombo = formatCombo('mod+enter') - const steerLabel = `${c.steer} (${steerCombo})` - - const steerTip = ( - - {c.steer} - - - ) if (conversation.active) { return } const showVoicePrimary = !busy && !hasComposerPayload + const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop return (
- {/* While the agent runs and the user is typing, steer takes over the mic's - slot rather than crowding the row with an extra button. */} - {canSteer ? ( - + + + {busyAction === 'steer' ? ( + }> - ) : ( - - )} - + ) : null} {showVoicePrimary ? ( + )} +
+ ) +} + function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) { if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) { return @@ -143,22 +185,15 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B - {presets.map(preset => ( - - ))} + setAmount(value)} + options={presets.map(preset => ({ id: preset.amount, label: preset.label }))} + value={amount} + /> - @@ -283,43 +319,20 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa value: 0 } - const width = Math.round(resolvedBar.value * 100) - const isEmpty = resolvedBar.value === 0 - const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0 + // Plain shared primitive — no bespoke track chrome. Only the fill tone carries + // billing meaning: destructive when over-limit, green for healthy remaining + // credits, muted otherwise. Color rides the sanctioned `fillClassName` override. + const isOk = resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup') return ( -
- {showDangerNub &&
} -
0 ? 4 : undefined, - width: `${width}%` - }} - /> -
+ destructive={resolvedBar.state === 'danger'} + fillClassName={resolvedBar.state === 'danger' ? undefined : isOk ? 'bg-(--ui-green)' : 'bg-muted-foreground/45'} + fillStyle={{ minWidth: resolvedBar.value > 0 ? 4 : undefined }} + size="lg" + value={resolvedBar.value} + /> ) } @@ -351,53 +364,10 @@ function UsageRow({ row }: { row: BillingUsageRowView }) { ) } -function UsageRefreshRow({ - fixtureName, - isFetching, - onRefresh, - updatedAt -}: { - fixtureName?: BillingFixtureSelection - isFetching: boolean - onRefresh: () => void - updatedAt: number -}) { - const [now, setNow] = useState(() => Date.now()) - - useEffect(() => { - const interval = window.setInterval(() => setNow(Date.now()), 30_000) - - return () => window.clearInterval(interval) - }, []) - - if (fixtureName && fixtureName !== 'live') { - return ( -
- fixture: {fixtureName} -
- ) - } - - return ( -
- Updated {formatUsageUpdatedAgo(updatedAt, now)} - - - -
- ) -} - +// DEV-only preview switcher: swaps the whole page onto a canned fixture so every +// billing state can be reviewed without a matching live account. Marked with a +// wrench + "preview" so it never reads as a shipping control (it's compiled out of +// production builds entirely). function BillingFixtureSelect({ onValueChange, value @@ -406,23 +376,27 @@ function BillingFixtureSelect({ value: BillingFixtureSelection }) { return ( - +
+ + preview + +
) } @@ -446,6 +420,34 @@ function BillingHeader({ ) } +// Loading shape for the billing overview: three summary cards over the Plan / +// Payment & credits / Usage sections. Rendered under the real header. +function BillingSkeleton() { + return ( + <> +
+
+ {[0, 1, 2].map(i => ( +
+ + +
+ ))} +
+
+ {[0, 1, 2].map(section => ( +
+ +
+ + +
+
+ ))} + + ) +} + function BillingSettingsContent({ fixtureName, onFixtureChange @@ -460,19 +462,30 @@ function BillingSettingsContent({ // fixture short-circuit here. const billingState = useBillingState() const subscriptionState = useSubscriptionState() + + // First load keeps the page's shape via a skeleton instead of flashing "—" + // summary cards (background refetches leave `isPending` false, so no flicker). + if (billingState.isPending) { + return ( + + + + + ) + } + const billingResult = billingState.data const subscriptionResult = subscriptionState.data const view = deriveBillingView(billingResult, subscriptionResult) const billing = billingResult?.ok ? billingResult.data : undefined - const usageUpdatedAt = oldestUpdatedAt(billingState.dataUpdatedAt, subscriptionState.dataUpdatedAt) - const usageIsFetching = billingState.isFetching || subscriptionState.isFetching - - const refreshUsage = () => { - void Promise.all([billingState.refetch(), subscriptionState.refetch()]) - } const { paymentRow, refillRow, topupRow } = view + // The payment method rides in the section header (right-aligned) — the + // "Payment & credits" title already names it, so a full labelled row would just + // repeat "Payment method". The stacked rows are the remaining money controls. + const accountRows = [topupRow, refillRow].filter((row): row is BillingAccountRowView => row !== undefined) + // Gate the plans sub-view on the SAME capability that renders the in-app button // (`plan.action`): a team / non-changer deep-linking `bview=plans` must never // reach a grid of live Choose buttons — it falls back to the overview. @@ -491,59 +504,42 @@ function BillingSettingsContent({ -
-
+ {view.notice && } + +
+
{view.summary.map(item => ( ))}
- {view.notice && } - {view.plan && ( -
- + setSubView('plans')} plan={view.plan} /> -
+ )} - {paymentRow && ( -
- - -
- )} - - {topupRow && ( -
- - -
- )} - - {refillRow && ( -
- - -
+ {(paymentRow || accountRows.length > 0) && ( + : undefined} + icon={CreditCard} + title="Payment & credits" + > + {accountRows.map(row => ( + + ))} + )} {view.usageRows.length > 0 && ( - <> - -
+ +
{view.usageRows.map(row => ( ))} -
- +
)} { @@ -586,9 +582,3 @@ export function BillingSettings() { return } - -function oldestUpdatedAt(...timestamps: number[]): number { - const populated = timestamps.filter(timestamp => timestamp > 0) - - return populated.length > 0 ? Math.min(...populated) : Date.now() -} diff --git a/apps/desktop/src/app/settings/billing/plans-view.tsx b/apps/desktop/src/app/settings/billing/plans-view.tsx index 440d43b6869e..b0d1fd6b7032 100644 --- a/apps/desktop/src/app/settings/billing/plans-view.tsx +++ b/apps/desktop/src/app/settings/billing/plans-view.tsx @@ -79,7 +79,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl return (
void; tiers: ))}
) : ( -
+
No plans are available to change to right now.
)} 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 c36ca4033284..a132e057db96 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 @@ -147,11 +147,14 @@ describe('deriveBillingView', () => { expect(buyCredits).toMatchObject({ action: { disabled: true, label: 'Buy' }, - description: - '💳 No saved card for terminal charges yet. Set one up on the portal ' + - "(one-time credit buys don't save a reusable card)." + // The no-card blocker is explained once by the page-level notice, not + // duplicated (emoji and all) into the row description. + description: 'A single charge on your card, added to your balance today.' }) + expect(buyCredits?.description).not.toContain('💳') expect(buyCredits?.chips?.map(chip => chip.disabled)).toEqual([true, true, true]) + // The page still leads with the warn banner naming the blocker + fix. + expect(view.notice).toMatchObject({ title: 'No payment method on file', tone: 'warn' }) }) it('derives a calm logged-out card with no account or usage rows', () => { 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 da02f441ebe0..606495d27e75 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -11,10 +11,22 @@ export const EMPTY_BILLING_VALUE = '—' export const FALLBACK_PORTAL_BILLING_URL = 'https://portal.nousresearch.com/billing' export const FALLBACK_PORTAL_URL = 'https://portal.nousresearch.com' +// The billing endpoint is the authoritative source of truth for balance / cap / +// plan — the inference `x-nous-credits-*` headers are best-effort and can drift +// out of sync (notably in team/org accounts where another member's spend moves +// the shared balance without ever touching THIS client's headers). So the page +// never trusts a cache: `staleTime: 0` + `refetchOnMount: 'always'` force a +// fresh fetch every time it opens or regains focus, and it keeps polling every +// 30s while mounted (react-query only ticks an active observer; it pauses when +// the window is backgrounded — refetchIntervalInBackground defaults to false). +// A `credits.*` notice crossing additionally invalidates ['billing','state'] to +// pull the change in immediately rather than waiting for the next poll tick. const BILLING_QUERY_OPTIONS = { + refetchInterval: 30_000, + refetchOnMount: 'always', refetchOnWindowFocus: true, retry: false, - staleTime: 30_000 + staleTime: 0 } as const export interface BillingSummaryItemView { @@ -30,6 +42,8 @@ export interface BillingNoticeView { } message: string title: string + /** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */ + tone?: 'info' | 'warn' } export interface BillingRowActionView { @@ -207,7 +221,7 @@ export function deriveBillingView( const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending) return { - notice: undefined, + notice: noCardNotice(billing), paymentRow: paymentMethodRow(billing), plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending), refillRow: autoReloadRow(billing), @@ -276,26 +290,6 @@ export function formatBillingDate(value?: null | string): string { return fmtDate.format(date) } -export function formatUsageUpdatedAgo(updatedAt: number, now: number): string { - const elapsedSeconds = Math.max(0, Math.floor((now - updatedAt) / 1000)) - - if (elapsedSeconds < 1) { - return 'just now' - } - - if (elapsedSeconds < 60) { - return `${elapsedSeconds}s ago` - } - - const elapsedMinutes = Math.floor(elapsedSeconds / 60) - - if (elapsedMinutes < 60) { - return `${elapsedMinutes}m ago` - } - - return `${Math.floor(elapsedMinutes / 60)}h ago` -} - function emptySummary(): BillingSummaryItemView[] { return [ { label: 'Balance', value: EMPTY_BILLING_VALUE }, @@ -311,7 +305,24 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView { return { action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined, message: resolved.message, - title: resolved.title + title: resolved.title, + tone: 'warn' + } +} + +// A logged-in account with no card can't buy credits or manage auto-refill, and +// every one of those controls disables silently — so lead the page with a single +// warn banner that names the blocker and links straight to the fix. +function noCardNotice(billing: BillingStateResponse): BillingNoticeView | undefined { + if (billing.card) { + return undefined + } + + return { + action: { label: 'Add card ↗', url: billing.portal_url ?? FALLBACK_PORTAL_BILLING_URL }, + message: 'Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.', + title: 'No payment method on file', + tone: 'warn' } } @@ -530,17 +541,19 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView const card = billing.card if (!card) { + // No card → a single "Add payment method" link, the way every other app does + // it. The reason (buys/auto-refill are blocked) already leads the page as a + // notice, so the row stays a bare call-to-action with no redundant status text. return { - action: { label: 'Update ↗', url: portalUrl }, - description: 'Add a payment method on the portal before buying top-up credits.', + action: { label: 'Add payment method', url: portalUrl }, + description: '', id: 'payment_method', - title: 'Payment method', - value: 'No card on file' + title: 'Payment method' } } return { - action: { label: 'Update ↗', url: portalUrl }, + action: { label: 'Update', url: portalUrl }, description: 'Manage the card used for top-ups and subscription renewals.', id: 'payment_method', title: 'Payment method', @@ -550,14 +563,13 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView function buyCreditsRow(billing: BillingStateResponse): BillingAccountRowView { if (!billing.card) { + // The no-card blocker is already spelled out by the page-level warn banner + // (noCardNotice); repeating it here — emoji and all — just clutters the row, + // so keep the plain "what buying does" line and let the controls sit disabled. return { action: { disabled: true, label: 'Buy' }, chips: billing.charge_presets.map(amount => ({ disabled: true, label: formatMoney(amount) })), - description: resolveRefusal({ - kind: 'no_payment_method', - message: '', - portalUrl: billing.portal_url ?? undefined - }).message, + description: 'A single charge on your card, added to your balance today.', id: 'buy_credits', title: 'Buy credits now' } diff --git a/apps/desktop/src/app/settings/config-settings.tsx b/apps/desktop/src/app/settings/config-settings.tsx index 1d8a2de0127f..18fa33d744aa 100644 --- a/apps/desktop/src/app/settings/config-settings.tsx +++ b/apps/desktop/src/app/settings/config-settings.tsx @@ -21,7 +21,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, ToggleRow } from './primitives' +import { EmptyState, SettingsContent, SettingsSkeleton, 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 @@ -264,8 +264,8 @@ export function ConfigSettings({ ) } - // Model keeps its shape via a skeleton (its catalog fetch is the slow part); - // other sections are quick config/schema reads, so a light loader is fine. + // Every section keeps its shape via a skeleton; model gets its bespoke one + // (its catalog fetch is the slow part), the rest the shared field rhythm. if (activeSectionId === 'model') { return ( @@ -276,7 +276,7 @@ export function ConfigSettings({ ) } - return + return } const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields diff --git a/apps/desktop/src/app/settings/constants.ts b/apps/desktop/src/app/settings/constants.ts index 055f72ac19a1..2ecdfb1e7e44 100644 --- a/apps/desktop/src/app/settings/constants.ts +++ b/apps/desktop/src/app/settings/constants.ts @@ -247,10 +247,11 @@ export const ENUM_OPTIONS: Record = { 'code_execution.mode': ['project', 'strict'], 'context.engine': ['compressor', 'default', 'custom'], 'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'], - // Built-in memory is not a provider plugin: the empty sentinel renders as - // "Built-in only" and a legacy literal `builtin` value is only kept visible - // via enumOptionsFor's current-value passthrough (#49513). - 'memory.provider': ['', 'honcho', 'hindsight'], + // NOTE: memory.provider is intentionally NOT listed here. Its options are + // discovery-driven and served by the backend config schema (merged + // per-request in web_server._schema_with_dynamic_provider_options), so + // config-field consumes schema.options directly — a static list here would + // shadow that and hide user-installed/pip providers (#49513). // Terminal execution backends — kept in sync with the dispatch ladder in // tools/terminal_tool.py::_create_environment (local/docker/singularity/ // modal/daytona/ssh). Remote backends need extra env (image, tokens, host). diff --git a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx index 5943b1a79189..bea02e2bce79 100644 --- a/apps/desktop/src/app/settings/custom-endpoints-settings.tsx +++ b/apps/desktop/src/app/settings/custom-endpoints-settings.tsx @@ -16,7 +16,7 @@ import { cn } from '@/lib/utils' import { notify, notifyError } from '@/store/notifications' import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes' -import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives' +import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives' interface CustomEndpointsSettingsProps { onConfigSaved?: () => void @@ -218,7 +218,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C } if (loading) { - return + return } const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean))) diff --git a/apps/desktop/src/app/settings/gateway-settings.tsx b/apps/desktop/src/app/settings/gateway-settings.tsx index 008bd44d2ead..91e1a1178450 100644 --- a/apps/desktop/src/app/settings/gateway-settings.tsx +++ b/apps/desktop/src/app/settings/gateway-settings.tsx @@ -27,7 +27,7 @@ import { notify, notifyError } from '@/store/notifications' import { $profiles, refreshActiveProfile } from '@/store/profile' import { CONTROL_TEXT } from './constants' -import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives' +import { EmptyState, ListRow, Pill, SettingsContent, SettingsSkeleton } from './primitives' import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection' type Mode = 'local' | 'remote' | 'cloud' | 'ssh' @@ -985,7 +985,14 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = { } if (loading) { - return + return ( + + ) } if (!window.hermesDesktop?.getConnectionConfig) { diff --git a/apps/desktop/src/app/settings/helpers.test.ts b/apps/desktop/src/app/settings/helpers.test.ts index 33aa771d32b5..860c89cb31f0 100644 --- a/apps/desktop/src/app/settings/helpers.test.ts +++ b/apps/desktop/src/app/settings/helpers.test.ts @@ -30,18 +30,12 @@ describe('settings helpers', () => { expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy() }) - it('lists the desktop memory provider options in their declared order', () => { - const options = enumOptionsFor('memory.provider', '', {}) - - // Built-in memory is not a provider plugin; the empty sentinel is the - // only built-in-shaped entry (#49513). - expect(options).toEqual(['', 'honcho', 'hindsight']) - }) - - it('keeps a legacy literal builtin value visible as the current selection', () => { - const options = enumOptionsFor('memory.provider', 'builtin', {}) - - expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin']) + it('does not shadow the backend schema options for memory.provider', () => { + // memory.provider options are discovery-driven and served by the backend + // config schema (merged per-request); enumOptionsFor must return undefined + // so config-field consumes schema.options instead of a stale static list. + expect(enumOptionsFor('memory.provider', '', {})).toBeUndefined() + expect(enumOptionsFor('memory.provider', 'honcho', {})).toBeUndefined() }) describe('isExternalMemoryProvider', () => { diff --git a/apps/desktop/src/app/settings/helpers.ts b/apps/desktop/src/app/settings/helpers.ts index 8882f98e63ad..13be2c6f0bf7 100644 --- a/apps/desktop/src/app/settings/helpers.ts +++ b/apps/desktop/src/app/settings/helpers.ts @@ -113,7 +113,7 @@ export function inferFieldSchema(value: unknown): ConfigFieldSchema { return { type: 'string' } } -// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal. +// Backend schema omits some declared keys; config presence is the availability signal. export function sectionFieldEntries( schema: Record, config: HermesConfigRecord diff --git a/apps/desktop/src/app/settings/keys-settings.tsx b/apps/desktop/src/app/settings/keys-settings.tsx index 15f33ca7b603..b180689a841d 100644 --- a/apps/desktop/src/app/settings/keys-settings.tsx +++ b/apps/desktop/src/app/settings/keys-settings.tsx @@ -6,7 +6,7 @@ import type { EnvVarInfo } from '@/types/hermes' import { CredentialKeyCard, credentialPlaceholder, credentialRowLabel } from './credential-key-ui' import { useEnvCredentials } from './env-credentials' import { asText } from './helpers' -import { LoadingState, SettingsContent } from './primitives' +import { SettingsContent, SettingsSkeleton } from './primitives' import { useDeepLinkHighlight } from './use-deep-link-highlight' // Sub-views surfaced as sidebar subnav under Tools & Keys (see settings/index.tsx). @@ -64,7 +64,7 @@ export function KeysSettings({ view }: KeysSettingsProps) { }, [vars]) if (!vars) { - return + return } const visible = groups.filter(g => g.category === view) diff --git a/apps/desktop/src/app/settings/pet-settings.tsx b/apps/desktop/src/app/settings/pet-settings.tsx index 6c2932cd545f..c02b6619375e 100644 --- a/apps/desktop/src/app/settings/pet-settings.tsx +++ b/apps/desktop/src/app/settings/pet-settings.tsx @@ -8,6 +8,7 @@ import { ConfirmDialog } from '@/components/ui/confirm-dialog' import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog' import { Input } from '@/components/ui/input' import { SegmentedControl } from '@/components/ui/segmented-control' +import { Skeleton } from '@/components/ui/skeleton' import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' @@ -139,7 +140,21 @@ export function PetSettings() { {/* Fixed-height scroll area so filtering never grows/shrinks the page (no layout thrash); the grid scrolls inside it. */}
- {pets.length === 0 ? ( + {status === 'loading' && pets.length === 0 ? ( + // First load keeps the grid's shape rather than flashing the + // "unreachable" copy before the gallery has even arrived. +
+ {Array.from({ length: 6 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ) : pets.length === 0 ? (

{copy.unreachable}

diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 4e55a2f9f4b9..2cc671f24ca2 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -1,8 +1,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 { Skeleton } from '@/components/ui/skeleton' import { Switch } from '@/components/ui/switch' import { triggerHaptic } from '@/lib/haptics' import type { IconComponent } from '@/lib/icons' @@ -28,16 +28,53 @@ export function Pill({ tone = 'muted', children }: { tone?: keyof typeof PILL_VA return {children} } -export function SectionHeading({ icon: Icon, title, meta }: { icon: IconComponent; title: string; meta?: string }) { +export function SectionHeading({ + aside, + icon: Icon, + meta, + title +}: { + // Right-aligned trailing content on the heading row (e.g. a compact status + + // action), so a single-item section needn't repeat its own label as a row. + aside?: ReactNode + icon: IconComponent + meta?: string + title: string +}) { return (
- + {title} {meta && {meta}} + {aside &&
{aside}
}
) } +// A titled section: heading + body with the shared vertical rhythm. Keeps the +// heading and its content welded together so pages stop hand-rolling +// `
` at every call site. +export function SettingsSection({ + aside, + children, + icon, + meta, + title +}: { + aside?: ReactNode + children: ReactNode + icon: IconComponent + meta?: string + title: string +}) { + return ( +
+ + {children} +
+ ) +} + export function NavLink({ icon: Icon, label, @@ -143,16 +180,60 @@ export function ToggleRow({ ) } -// 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 }) { +// Skeleton primitives mirroring the settings layout rhythm — a loading page keeps +// its shape (like ModelSettings) instead of collapsing to a centered spinner. +export function SectionHeadingSkeleton() { return ( - +
+ + +
+ ) +} + +export function ListRowSkeleton({ wide = false }: { wide?: boolean }) { + return ( +
+
+
+ + +
+ {!wide && } +
+
+ ) +} + +// A full settings page in its loading shape: an optional leading search field +// over one or more sections, each an optional heading above a run of rows. +// ``. +export function SettingsSkeleton({ + search = false, + sections = [{ rows: 4 }] +}: { + search?: boolean + sections?: { heading?: boolean; rows: number }[] +}) { + return ( + + {search && } + {sections.map((section, i) => ( +
0 && 'mt-6')} key={i}> + {section.heading && } +
+ {Array.from({ length: section.rows }, (_, r) => ( + + ))} +
+
+ ))} +
) } diff --git a/apps/desktop/src/app/settings/providers-settings.tsx b/apps/desktop/src/app/settings/providers-settings.tsx index 8bf5cf65db09..9fb69784ec44 100644 --- a/apps/desktop/src/app/settings/providers-settings.tsx +++ b/apps/desktop/src/app/settings/providers-settings.tsx @@ -28,7 +28,7 @@ import { isKeyVar, ProviderKeyRows } from './credential-key-ui' import { CustomEndpointsSettings } from './custom-endpoints-settings' import { SettingsCategoryHeading, useEnvCredentials } from './env-credentials' import { providerGroup, providerMeta, providerPriority } from './helpers' -import { LoadingState, SettingsContent } from './primitives' +import { SettingsContent, SettingsSkeleton } from './primitives' // The embedded terminal (and thus the "run disconnect command" path) only // exists in the Electron desktop shell, not the web dashboard. @@ -431,7 +431,7 @@ export function ProvidersSettings({ } if (!vars) { - return + return } const hasOauth = oauthProviders.length > 0 diff --git a/apps/desktop/src/app/settings/sessions-settings.tsx b/apps/desktop/src/app/settings/sessions-settings.tsx index 06df90e6bace..9102014b8ed8 100644 --- a/apps/desktop/src/app/settings/sessions-settings.tsx +++ b/apps/desktop/src/app/settings/sessions-settings.tsx @@ -12,7 +12,7 @@ import { untombstoneSessions } from '@/store/projects' import { applyConfiguredDefaultProjectDir, ensureDefaultWorkspaceCwd, setSessions } from '@/store/session' import type { SessionInfo } from '@/types/hermes' -import { EmptyState, ListRow, LoadingState, SectionHeading, SettingsContent } from './primitives' +import { EmptyState, ListRow, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives' import { useDeepLinkHighlight } from './use-deep-link-highlight' const ARCHIVED_FETCH_LIMIT = 200 @@ -107,7 +107,7 @@ export function SessionsSettings() { }) if (loading) { - return + return } return ( diff --git a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx index fcd22440c7b4..a633e892affa 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.test.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.test.tsx @@ -211,6 +211,31 @@ describe('ToolsetConfigPanel', () => { await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'ElevenLabs')) }) + it('serializes provider selection while a previous choice is pending', async () => { + let resolveSelection: (value: { name: string; ok: boolean; provider: string }) => void = () => undefined + selectToolsetProvider.mockImplementationOnce( + () => + new Promise(resolve => { + resolveSelection = resolve + }) + ) + + const { ToolsetConfigPanel } = await import('./toolset-config-panel') + render() + + const edge = await screen.findByRole('button', { name: /Microsoft Edge TTS/ }) + const elevenlabs = screen.getByRole('button', { name: /ElevenLabs/ }) + fireEvent.click(edge) + + await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'Microsoft Edge TTS')) + expect(elevenlabs.hasAttribute('disabled')).toBe(true) + fireEvent.click(elevenlabs) + expect(selectToolsetProvider).toHaveBeenCalledTimes(1) + + resolveSelection({ name: 'tts', ok: true, provider: 'Microsoft Edge TTS' }) + await waitFor(() => expect(elevenlabs.hasAttribute('disabled')).toBe(false)) + }) + it('shows a backend model catalog for image_gen and persists a pick', async () => { getToolsetConfig.mockResolvedValue( config({ @@ -917,8 +942,13 @@ describe('ToolsetConfigPanel', () => { const { ToolsetConfigPanel } = await import('./toolset-config-panel') render() - // Expand the keyed provider so its env row renders. - fireEvent.click(await screen.findByRole('button', { name: /ElevenLabs/ })) + // Expand the keyed provider so its env row renders. Wait for the + // selection to commit: on a freshly loaded panel this races the default + // provider initializer, and user intent must win that race. + const elevenLabs = await screen.findByRole('button', { name: /ElevenLabs/ }) + fireEvent.click(elevenLabs) + await waitFor(() => expect(elevenLabs.getAttribute('aria-pressed')).toBe('true')) + const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ }) fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' }) diff --git a/apps/desktop/src/app/settings/toolset-config-panel.tsx b/apps/desktop/src/app/settings/toolset-config-panel.tsx index 5a60964f2114..f3cee6d3b4dc 100644 --- a/apps/desktop/src/app/settings/toolset-config-panel.tsx +++ b/apps/desktop/src/app/settings/toolset-config-panel.tsx @@ -490,6 +490,9 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi const [activeProvider, setActiveProvider] = useState(null) // Live per-key set/unset state, seeded from the endpoint then patched locally. const [envState, setEnvState] = useState>({}) + // Default-provider selection and a user click race just after config arrives: + // a stale initialization effect must never replace an explicit choice. + const providerChoiceClaimedRef = useRef(false) // Guard the Nous Portal sign-in poll loop against unmount/state updates. const mountedRef = useRef(true) @@ -535,7 +538,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi // panel highlighted the first keyless provider (e.g. Nous Portal) even when // the user had already selected another (e.g. DuckDuckGo). useEffect(() => { - if (activeProvider || providers.length === 0) { + if (providerChoiceClaimedRef.current || activeProvider || providers.length === 0) { return } @@ -545,10 +548,19 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi providers.find(p => providerConfigured(p, envState)) ?? providers[0] + // Claim before enqueueing the state update. Effects can run with a stale + // activeProvider closure after a user click, so state alone is too late to + // protect that choice. + providerChoiceClaimedRef.current = true setActiveProvider(selected.name) }, [activeProvider, providers, envState, cfg]) async function handleSelect(provider: ToolProvider) { + if (selecting !== null) { + return + } + + providerChoiceClaimedRef.current = true setActiveProvider(provider.name) setSelecting(provider.name) @@ -730,6 +742,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi 'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50', isActive && 'bg-accent/40' )} + disabled={selecting !== null} onClick={() => void handleSelect(provider)} type="button" > diff --git a/apps/desktop/src/app/shell/context-usage-panel.test.tsx b/apps/desktop/src/app/shell/context-usage-panel.test.tsx new file mode 100644 index 000000000000..28d72e861a9e --- /dev/null +++ b/apps/desktop/src/app/shell/context-usage-panel.test.tsx @@ -0,0 +1,93 @@ +import { act, cleanup, render, waitFor } from '@testing-library/react' +import { useState } from 'react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import type { ContextBreakdown, UsageStats } from '@/types/hermes' + +import { ContextUsagePanel } from './context-usage-panel' + +const initialUsage: UsageStats = { + calls: 1, + context_max: 272_000, + context_percent: 47, + context_used: 128_200, + input: 0, + output: 0, + total: 0 +} + +const breakdown: ContextBreakdown = { + categories: [{ color: 'teal', id: 'conversation', label: 'Conversation', tokens: 241_400 }], + context_max: 272_000, + context_percent: 89, + context_used: 241_400, + estimated_total: 286_600, + model: 'test-model' +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('ContextUsagePanel', () => { + it('publishes once without refetching when publication recreates the callback', async () => { + const requestGateway = vi.fn().mockResolvedValue(breakdown) + const published = vi.fn() + const renderedUsage: UsageStats[] = [] + + function Harness() { + const [currentUsage, setCurrentUsage] = useState(initialUsage) + renderedUsage.push(currentUsage) + + return ( + { + published(snapshot) + setCurrentUsage(current => ({ ...current, ...snapshot })) + }} + requestGateway={requestGateway} + sessionId="runtime-1" + /> + ) + } + + render() + + await waitFor(() => { + expect(published).toHaveBeenCalledWith({ + context_max: 272_000, + context_percent: 89, + context_used: 241_400 + }) + expect(renderedUsage.at(-1)?.context_used).toBe(241_400) + }) + await act(async () => {}) + + expect(requestGateway).toHaveBeenCalledTimes(1) + expect(requestGateway).toHaveBeenCalledWith('session.context_breakdown', { session_id: 'runtime-1' }) + }) + + it('refetches when the session or gateway requester changes', async () => { + const firstGateway = vi.fn().mockResolvedValue(breakdown) + const secondGateway = vi.fn().mockResolvedValue(breakdown) + + const { rerender } = render( + + ) + + await waitFor(() => expect(firstGateway).toHaveBeenCalledTimes(1)) + + rerender() + + await waitFor(() => { + expect(firstGateway).toHaveBeenCalledTimes(2) + expect(firstGateway).toHaveBeenLastCalledWith('session.context_breakdown', { session_id: 'runtime-2' }) + }) + + rerender() + + await waitFor(() => expect(secondGateway).toHaveBeenCalledTimes(1)) + }) +}) diff --git a/apps/desktop/src/app/shell/context-usage-panel.tsx b/apps/desktop/src/app/shell/context-usage-panel.tsx index 5a243c0913f7..8689e8f29f3f 100644 --- a/apps/desktop/src/app/shell/context-usage-panel.tsx +++ b/apps/desktop/src/app/shell/context-usage-panel.tsx @@ -1,4 +1,4 @@ -import { useEffect, useMemo, useState } from 'react' +import { useEffect, useMemo, useRef, useState } from 'react' import { useI18n } from '@/i18n' import { compactNumber } from '@/lib/format' @@ -7,15 +7,23 @@ import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types interface ContextUsagePanelProps { currentUsage: UsageStats + onUsageSnapshot?: (usage: Pick) => void requestGateway: (method: string, params?: Record) => Promise sessionId: string | null } -export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: ContextUsagePanelProps) { +export function ContextUsagePanel({ + currentUsage, + onUsageSnapshot, + requestGateway, + sessionId +}: ContextUsagePanelProps) { const { t } = useI18n() const copy = t.shell.statusbar.contextUsagePanel const [breakdown, setBreakdown] = useState(null) const [loading, setLoading] = useState(false) + const onUsageSnapshotRef = useRef(onUsageSnapshot) + onUsageSnapshotRef.current = onUsageSnapshot useEffect(() => { if (!sessionId) { @@ -32,6 +40,11 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C .then(data => { if (!cancelled) { setBreakdown(data) + onUsageSnapshotRef.current?.({ + context_max: data.context_max, + context_percent: data.context_percent, + context_used: data.context_used + }) } }) .catch(() => { diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index f66e40c4aafe..62e469e8a082 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -1,5 +1,5 @@ import { useStore } from '@nanostores/react' -import { useMemo } from 'react' +import { useCallback, useMemo } from 'react' import type { CommandCenterSection } from '@/app/command-center' import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store' @@ -27,7 +27,8 @@ import { $sessions, $sessionStartedAt, $turnStartedAt, - sessionMatchesStoredId + sessionMatchesStoredId, + setCurrentUsage } from '@/store/session' import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states' import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents' @@ -40,7 +41,7 @@ import { $updateStatus, openUpdateOverlayFor } from '@/store/updates' -import type { StatusResponse } from '@/types/hermes' +import type { StatusResponse, UsageStats } from '@/types/hermes' import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes' import type { StatusbarItem } from '../statusbar-controls' @@ -144,6 +145,14 @@ export function useStatusbarItems({ const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage]) const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage]) + + const publishContextUsage = useCallback( + (snapshot: Pick) => { + setCurrentUsage(current => ({ ...current, ...snapshot })) + }, + [] + ) + const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway) const gatewayMenuContent = useMemo( @@ -455,7 +464,12 @@ export function useStatusbarItems({ menuAlign: 'end', menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0', menuContent: ( - + ), title: copy.openContextUsage, variant: 'menu' @@ -496,6 +510,7 @@ export function useStatusbarItems({ contextUsage, copy, currentUsage, + publishContextUsage, requestGateway, sessionStartedAt, gatewayState, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index 3f8da4144334..01ada56e9357 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -1,7 +1,7 @@ import type * as React from 'react' import type { ChatMessage } from '@/lib/chat-messages' -import type { UsageStats } from '@/types/hermes' +import type { SessionMessage, UsageStats } from '@/types/hermes' export interface ContextSuggestion { text: string @@ -53,6 +53,31 @@ export interface BrowserManageResponse { messages?: string[] } +/** Response from the `session.compress` RPC. `messages` is the post-compress + * history (same shape `session.resume` returns via `_history_to_messages`), + * so the desktop can replace its transcript from it rather than leaving stale + * bubbles on screen. `summary` carries the "compressed N → M messages" line. */ +export interface SessionCompressResponse { + host_ack?: { + output?: string + } + info?: { + title?: string + usage?: Partial + } + messages?: SessionMessage[] + removed?: number + status?: string + summary?: { + aborted?: boolean + headline?: string + noop?: boolean + note?: null | string + token_line?: string + } + usage?: Partial +} + export interface SessionSteerResponse { // 'queued' == accepted into the live turn's steer slot (injected at the next // tool-result boundary); 'rejected' == no live tool window, caller queues. @@ -60,6 +85,11 @@ export interface SessionSteerResponse { text?: string } +export interface SessionRedirectResponse { + status?: 'redirected' | 'queued' | 'rejected' + text?: string +} + export interface SessionTitleResponse { title?: string // True when the session row isn't persisted yet and the title was queued diff --git a/apps/desktop/src/app/updates-overlay.tsx b/apps/desktop/src/app/updates-overlay.tsx index 0ae671535b0a..2ad94f8ab380 100644 --- a/apps/desktop/src/app/updates-overlay.tsx +++ b/apps/desktop/src/app/updates-overlay.tsx @@ -13,6 +13,7 @@ import { } from '@/components/ui/dialog' import { ErrorIcon, ErrorState } from '@/components/ui/error-state' import { Loader } from '@/components/ui/loader' +import { Progress } from '@/components/ui/progress' import type { DesktopUpdateCommit, DesktopUpdateStage, DesktopUpdateStatus } from '@/global' import { useI18n } from '@/i18n' import { buildCommitChangelog, type CommitGroup } from '@/lib/commit-changelog' @@ -396,15 +397,12 @@ function ApplyingView({ apply, isBackend }: { apply: UpdateApplyState; isBackend ) : null}
-
-
-
+ {recentLog.length > 1 ? (
diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx index cf9e35450191..7e0daf7ef34a 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.test.tsx @@ -1,14 +1,28 @@ import type { ToolCallMessagePartProps } from '@assistant-ui/react' -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import type { ReactNode } from 'react' import { afterEach, describe, expect, it, vi } from 'vitest' +import { onComposerInsertRequest } from '@/app/chat/composer/focus' import { I18nProvider } from '@/i18n' +import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify' +import { $gateway } from '@/store/gateway' +import { $activeSessionId } from '@/store/session' import { ClarifyTool, readClarifyResult } from './clarify-tool' +// The live pending card only renders while its message is running. Force that so +// keyboard-navigation tests can exercise ClarifyToolPending directly. +vi.mock('@assistant-ui/react', () => ({ + useAuiState: () => true +})) + afterEach(() => { cleanup() + clearClarifyRequest() + $activeSessionId.set(null) + $gateway.set(null) + vi.clearAllMocks() }) function renderClarify(ui: ReactNode) { @@ -39,6 +53,40 @@ function settledClarifyProps( } } +function liveClarifyProps(choices = ['staging', 'production']): ToolCallMessagePartProps { + const args = { choices, question: 'Which deployment target?' } + + return { + addResult: vi.fn(), + args, + argsText: JSON.stringify(args), + isError: false, + respondToApproval: vi.fn(), + result: undefined, + resume: vi.fn(), + status: { type: 'running' }, + toolCallId: 'clarify-live', + toolName: 'clarify', + type: 'tool-call' + } +} + +function renderLiveClarify() { + const request = vi.fn().mockResolvedValue({ ok: true }) + + $activeSessionId.set('session-1') + $gateway.set({ request } as never) + setClarifyRequest({ + choices: ['staging', 'production'], + question: 'Which deployment target?', + requestId: 'request-1', + sessionId: 'session-1' + }) + renderClarify() + + return request +} + describe('readClarifyResult', () => { it('reads question + user_response from the tool JSON payload', () => { expect( @@ -114,4 +162,179 @@ describe('ClarifyTool settled view', () => { expect(screen.getByText('Anything else?')).toBeTruthy() expect(screen.getByText('Skipped')).toBeTruthy() }) + + it('keeps the original choices visible and clickable after a skip', async () => { + const inserts: string[] = [] + + const stop = onComposerInsertRequest(detail => { + inserts.push(detail.text) + }) + + try { + renderClarify( + + ) + + // The skip label renders AND the original options are still on screen. + expect(screen.getByText('Skipped')).toBeTruthy() + const group = document.querySelector('[data-clarify-late-choices]') + expect(group).toBeTruthy() + expect(screen.getByText('staging')).toBeTruthy() + expect(screen.getByText('prod')).toBeTruthy() + + // Picking one drafts a quoted follow-up into the composer. The insert + // bus defers dispatch by a macrotask, so flush one tick. + fireEvent.click(screen.getByText('prod')) + await new Promise(resolve => window.setTimeout(resolve, 0)) + + expect(inserts).toHaveLength(1) + expect(inserts[0]).toContain('Which deployment target?') + expect(inserts[0]).toContain('prod') + } finally { + stop() + } + }) + + it('does not render late choices on an answered clarify', () => { + renderClarify( + + ) + + expect(document.querySelector('[data-clarify-late-choices]')).toBeNull() + }) + + it('does not render late choices for a free-text (no-choice) skip', () => { + renderClarify( + + ) + + expect(document.querySelector('[data-clarify-late-choices]')).toBeNull() + }) +}) + +describe('ClarifyTool keyboard navigation', () => { + it('cycles through choices and Other with the arrow keys', () => { + renderLiveClarify() + + const staging = screen.getByRole('button', { name: /staging/ }) + const production = screen.getByRole('button', { name: /production/ }) + const other = screen.getByPlaceholderText(/Other/) + + expect(staging.getAttribute('data-highlighted')).toBe('true') + expect(staging.getAttribute('aria-current')).toBe('true') + expect(staging.getAttribute('aria-keyshortcuts')).toBe('A 1') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(production.getAttribute('data-highlighted')).toBe('true') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true') + expect(other.getAttribute('aria-current')).toBe('true') + expect(other.getAttribute('aria-keyshortcuts')).toBe('C 3') + + fireEvent.keyDown(window, { key: 'ArrowDown' }) + expect(staging.getAttribute('data-highlighted')).toBe('true') + + fireEvent.keyDown(window, { key: 'ArrowUp' }) + expect(other.closest('label')?.getAttribute('data-highlighted')).toBe('true') + }) + + it('selects by number and confirms the answer with Enter', async () => { + const request = renderLiveClarify() + + fireEvent.keyDown(window, { key: '2' }) + fireEvent.keyDown(window, { key: 'Enter' }) + + await waitFor(() => { + expect(request).toHaveBeenCalledWith('clarify.respond', { + answer: 'production', + request_id: 'request-1' + }) + }) + }) + + it('focuses Other when its number is pressed and leaves typing keys alone', () => { + renderLiveClarify() + + const other = screen.getByPlaceholderText(/Other/) + + fireEvent.keyDown(window, { key: '3' }) + expect(document.activeElement).toBe(other) + + fireEvent.change(other, { target: { value: 'canary' } }) + fireEvent.keyDown(window, { key: 'ArrowUp' }) + expect(document.activeElement).toBe(other) + expect((other as HTMLTextAreaElement).value).toBe('canary') + }) + + it('does not intercept keyboard events while an action button has focus', () => { + const request = renderLiveClarify() + const skip = screen.getByRole('button', { name: 'Skip' }) + + skip.focus() + + expect(fireEvent.keyDown(window, { key: 'Enter' })).toBe(true) + expect(fireEvent.keyDown(window, { key: 'ArrowDown' })).toBe(true) + expect(request).not.toHaveBeenCalled() + }) +}) + +describe('ClarifyTool pending marker', () => { + it('marks a live choices card so type-to-focus yields its shortcut keys', () => { + renderLiveClarify() + + // The marker is what `composerFocusBlockedBySurface` keys off of, so the + // global type-to-focus listener stands down and A/B/C… + 1-9 + Enter reach + // the card instead of the composer. + expect(document.querySelector('[data-clarify-choices]')).toBeTruthy() + }) + + it('does not mark a free-text (no-choice) pending card', () => { + $activeSessionId.set('session-1') + $gateway.set({ request: vi.fn().mockResolvedValue({ ok: true }) } as never) + setClarifyRequest({ + choices: null, + question: 'Anything else?', + requestId: 'request-1', + sessionId: 'session-1' + }) + + const args = { question: 'Anything else?' } + renderClarify( + + ) + + // No shortcuts → nothing to protect → composer type-to-focus stays live. + expect(document.querySelector('[data-clarify-choices]')).toBeNull() + }) }) diff --git a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx index 72b6fbbdfa81..6f1442e2cfed 100644 --- a/apps/desktop/src/components/assistant-ui/clarify-tool.tsx +++ b/apps/desktop/src/components/assistant-ui/clarify-tool.tsx @@ -13,16 +13,18 @@ import { useState } from 'react' +import { requestComposerFocus, requestComposerInsert } from '@/app/chat/composer/focus' import { useSessionView } from '@/app/chat/session-view' import { ToolFallback } from '@/components/assistant-ui/tool/fallback' import { Button } from '@/components/ui/button' import { Kbd } from '@/components/ui/kbd' import { Textarea } from '@/components/ui/textarea' +import { Tip } from '@/components/ui/tooltip' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { CircleLetterA, Loader2, MessageQuestion } from '@/lib/icons' import { cn } from '@/lib/utils' -import { clearClarifyRequest, sessionClarifyRequest } from '@/store/clarify' +import { clearClarifyRequest, normalizeChoices, sessionClarifyRequest, warnDroppedChoices } from '@/store/clarify' import { $gateway } from '@/store/gateway' import { notifyError } from '@/store/notifications' @@ -52,11 +54,18 @@ function stringField(row: Record, ...keys: string[]): string | function readClarifyArgs(args: unknown): ClarifyArgs { const row = parseMaybeObject(args) - const choices = Array.isArray(row.choices) ? row.choices.filter((c): c is string => typeof c === 'string') : null + const rawChoices = row.choices + const choices = normalizeChoices(rawChoices) + + const question = stringField(row, 'question') + + if (rawChoices != null && choices.length === 0 && question) { + warnDroppedChoices('tool_args', question, rawChoices) + } return { - question: stringField(row, 'question'), - choices: choices && choices.length > 0 ? choices : null + question, + choices: choices.length > 0 ? choices : null } } @@ -125,6 +134,60 @@ function KeyBadge({ char, preview, selected }: { char: string; preview?: boolean ) } +/** A letter-badged option row. Shared by the live pending card (where a click + * selects an answer) and the settled skip card (where a click drafts a + * follow-up), so both stay visually identical. */ +function ChoiceButton({ + active = false, + char, + choice, + disabled, + keyShortcuts, + onClick, + selected = false, + title +}: { + active?: boolean + char: string + choice: string + disabled?: boolean + keyShortcuts?: string + onClick: () => void + selected?: boolean + title?: string +}) { + // `Tip` is the repo's themed replacement for native `title=` (a native + // tooltip on a + + ) +} + export const ClarifyTool = (props: ToolCallMessagePartProps) => { // Answered → settled Q&A (ToolFallback collapsed the answer away). if (props.result !== undefined) { @@ -156,6 +219,22 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) { const error = fromResult.error const skipped = !error && answer !== undefined && !answer.trim() const answerText = error || (skipped ? copy.skipped : (answer ?? '').trim()) + const choices = fromArgs.choices ?? [] + + // A skipped (timed-out) clarify keeps its choices on screen and actionable. + // The blocking request is long gone — the tool already returned empty — so a + // pick can't resolve it retroactively. Instead it drafts a quoted follow-up + // into the composer (Enter sends; if the agent is mid-turn it queues like + // any other prompt). Without this the card collapsed to just "Skipped" and + // the options were unrecoverable. + const followUp = useCallback( + (choice: string) => { + requestComposerInsert(copy.lateAnswer(question, choice), { mode: 'block' }) + requestComposerFocus() + triggerHaptic('selection') + }, + [copy, question] + ) return ( @@ -178,6 +257,20 @@ function ClarifyToolSettled({ args, result }: ToolCallMessagePartProps) {

) : null} + {skipped && choices.length > 0 ? ( +
+ {choices.map((choice, index) => ( + followUp(choice)} + title={copy.lateAnswerTip} + /> + ))} +

{copy.lateAnswerHint}

+
+ ) : null}
) } @@ -217,6 +310,9 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const [draft, setDraft] = useState('') const [submitting, setSubmitting] = useState(false) const [selectedChoice, setSelectedChoice] = useState(null) + // The keyboard cursor. Indices 0..choices.length-1 are the options; the + // trailing index (=== choices.length) is the "Other" free-text row. + const [activeIndex, setActiveIndex] = useState(0) const [otherFocused, setOtherFocused] = useState(false) const textareaRef = useRef(null) @@ -265,12 +361,31 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { // confirms with Continue (or Enter from the field). const pendingAnswer = selectedChoice ?? (trimmedDraft || null) - const selectChoice = useCallback((choice: string) => { + const selectChoice = useCallback((choice: string, index: number) => { // Picking a choice and typing are mutually exclusive answers. setDraft('') setSelectedChoice(choice) + setActiveIndex(index) }, []) + // Keep the cursor in range when the choice set changes (never past "Other"). + useEffect(() => { + setActiveIndex(index => Math.min(index, choices.length)) + }, [choices.length]) + + const moveActive = useCallback( + (delta: number) => { + const itemCount = choices.length + 1 + + // Arrow navigation is a move, not a pick — clear any staged answer so the + // cursor and the selection can't disagree. + setDraft('') + setSelectedChoice(null) + setActiveIndex(index => (index + delta + itemCount) % itemCount) + }, + [choices.length] + ) + const submitAnswer = useCallback(() => { if (selectedChoice !== null) { void respond(selectedChoice) @@ -283,6 +398,27 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { } }, [respond, selectedChoice, trimmedDraft]) + const activateActive = useCallback(() => { + // A staged answer (picked choice or typed text) wins — confirm it. + if (pendingAnswer) { + submitAnswer() + + return + } + + // Otherwise act on the highlighted row: a choice responds immediately, and + // the trailing "Other" row focuses the free-text field. + const choice = choices[activeIndex] + + if (choice) { + void respond(choice) + + return + } + + textareaRef.current?.focus() + }, [activeIndex, choices, pendingAnswer, respond, submitAnswer]) + const handleTextareaKey = useCallback( (event: KeyboardEvent) => { if (event.nativeEvent.isComposing) { @@ -305,10 +441,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { [submitAnswer] ) - // Letter shortcuts: A/B/C… pick the matching option, the trailing letter jumps - // into "Other", and Enter confirms the current pick. Stands down whenever a - // field is focused (you're typing, not navigating) so it never eats keystrokes - // meant for the composer or the Other box. + // Arrow keys move a visual cursor, 1-9 and A/B/C… pick directly, and Enter + // confirms the current answer (or acts on the highlighted row). Stands down + // whenever a focusable control (a field, a choice button, the action bar) is + // focused, so it never eats keystrokes meant for the composer, the Other box, + // or a button the user tabbed to. useEffect(() => { if (!ready || !hasChoices || submitting) { return @@ -321,7 +458,32 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { const active = document.activeElement as HTMLElement | null - if (active && (active.tagName === 'INPUT' || active.tagName === 'TEXTAREA' || active.isContentEditable)) { + if ( + active && + (active.isContentEditable || active.matches('a[href], button, input, select, textarea, [role="button"]')) + ) { + return + } + + if (event.key === 'ArrowDown' || event.key === 'ArrowUp') { + event.preventDefault() + moveActive(event.key === 'ArrowDown' ? 1 : -1) + + return + } + + if (/^[1-9]$/.test(event.key)) { + const index = Number(event.key) - 1 + + if (index < choices.length) { + event.preventDefault() + selectChoice(choices[index], index) + } else if (index === choices.length) { + event.preventDefault() + setActiveIndex(index) + textareaRef.current?.focus() + } + return } @@ -332,25 +494,26 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { if (index < choices.length) { event.preventDefault() - selectChoice(choices[index]) + selectChoice(choices[index], index) } else if (index === choices.length) { event.preventDefault() + setActiveIndex(index) textareaRef.current?.focus() } return } - if (event.key === 'Enter' && pendingAnswer) { + if (event.key === 'Enter') { event.preventDefault() - submitAnswer() + activateActive() } } window.addEventListener('keydown', onKeyDown) return () => window.removeEventListener('keydown', onKeyDown) - }, [choices, hasChoices, pendingAnswer, ready, selectChoice, submitAnswer, submitting]) + }, [activateActive, choices, hasChoices, moveActive, ready, selectChoice, submitting]) if (loading) { return ( @@ -375,7 +538,11 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { } return ( - + // `data-clarify-choices` marks the panel as owning printable/Enter keys + // while its A/B/C… shortcuts are live, so the global type-to-focus listener + // (`composerFocusBlockedBySurface`) stands down and the letters reach this + // card instead of being redirected into the composer. +
{question} @@ -385,31 +552,40 @@ function ClarifyToolPending({ args }: ToolCallMessagePartProps) { {hasChoices ? (
{choices.map((choice, index) => ( - + keyShortcuts={`${letterFor(index)} ${index + 1}`} + onClick={() => selectChoice(choice, index)} + selected={selectedChoice === choice} + /> ))} -