Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fe9dc06071 | ||
|
|
6f5608bed3 | ||
|
|
dc4d991373 | ||
|
|
dcdb9b25e4 | ||
|
|
a22d2918d6 |
@@ -97,6 +97,9 @@ 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
|
||||
|
||||
@@ -39,9 +39,6 @@ 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
|
||||
|
||||
@@ -5,32 +5,24 @@ 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.
|
||||
|
||||
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.
|
||||
Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set —
|
||||
this happens on fork PRs where repo secrets are unavailable. The fallback
|
||||
ensures classification, timings, and review comments still work on
|
||||
forks (with the lower GITHUB_TOKEN rate limit).
|
||||
|
||||
Composite actions cannot access 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.
|
||||
Composite actions cannot access the secrets context directly, so the
|
||||
calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY
|
||||
as inputs. When both are empty (fork PRs), the fallback fires.
|
||||
|
||||
inputs:
|
||||
client-id:
|
||||
description: GitHub App Client ID. Pass vars.APP_CLIENT_ID from the calling workflow.
|
||||
description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
private-key:
|
||||
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
|
||||
required: false
|
||||
default: ''
|
||||
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:
|
||||
@@ -59,8 +51,6 @@ 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
|
||||
|
||||
+35
-12
@@ -9,10 +9,6 @@ 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:
|
||||
@@ -50,15 +46,22 @@ 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:
|
||||
github-token: ${{ github.token }}
|
||||
# The get-app-token composite action falls back to GITHUB_TOKEN
|
||||
# on fork PRs where APP_ID is unavailable.
|
||||
github-token: ${{ steps.app-token.outputs.token }}
|
||||
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
|
||||
@@ -71,6 +74,7 @@ jobs:
|
||||
uses: ./.github/workflows/tests.yml
|
||||
with:
|
||||
slice_count: 8
|
||||
secrets: inherit
|
||||
|
||||
lint:
|
||||
name: Python lints
|
||||
@@ -79,12 +83,14 @@ 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
|
||||
@@ -97,41 +103,48 @@ 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'
|
||||
uses: ./.github/workflows/docker.yml
|
||||
secrets: inherit
|
||||
|
||||
supply-chain:
|
||||
name: Supply-chain scan
|
||||
@@ -150,13 +163,14 @@ 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.
|
||||
@@ -172,16 +186,13 @@ jobs:
|
||||
# ─────────────────────────────────────────────────────────────────────
|
||||
comment-live:
|
||||
name: CI review comment (live)
|
||||
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check, e2e-desktop]
|
||||
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check]
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
persist-credentials: false
|
||||
|
||||
- name: Run live comment poller
|
||||
env:
|
||||
@@ -315,6 +326,13 @@ jobs:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Restore baseline cache (PR only)
|
||||
if: github.event_name == 'pull_request'
|
||||
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
|
||||
@@ -328,7 +346,12 @@ jobs:
|
||||
|
||||
- name: Collect timings and generate report
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
# 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 }}
|
||||
run: |
|
||||
python3 scripts/ci/timings_report.py \
|
||||
--baseline ci-timings-baseline.json \
|
||||
|
||||
@@ -60,7 +60,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
|
||||
@@ -20,9 +20,7 @@ env:
|
||||
IMAGE_NAME: nousresearch/hermes-agent
|
||||
|
||||
jobs:
|
||||
# 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, test, and optionally push the image for each architecture.
|
||||
build:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
strategy:
|
||||
@@ -64,6 +62,49 @@ 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`).
|
||||
@@ -106,74 +147,6 @@ 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 —
|
||||
@@ -185,9 +158,8 @@ 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: [publish]
|
||||
needs: [build]
|
||||
timeout-minutes: 10
|
||||
environment: container-publish
|
||||
steps:
|
||||
- name: Download digests
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
|
||||
@@ -2,10 +2,6 @@ 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
|
||||
@@ -19,8 +15,6 @@ 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
|
||||
|
||||
@@ -64,8 +58,7 @@ jobs:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
# ── Build desktop app ─────────────────────────────────────────────
|
||||
# The Playwright step below runs `npm run build` before testing so
|
||||
# dist/ is always fresh — no separate build step needed here.
|
||||
- run: npm run --prefix apps/desktop build
|
||||
|
||||
# ── Restore visual baseline screenshots from main ──────────────────
|
||||
# Baselines are generated on main (via --update-snapshots) and cached.
|
||||
@@ -86,18 +79,16 @@ 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"
|
||||
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list --update-snapshots
|
||||
else
|
||||
echo "On PR — comparing against cached baselines"
|
||||
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list
|
||||
fi
|
||||
env:
|
||||
@@ -152,38 +143,6 @@ 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
|
||||
@@ -197,50 +156,49 @@ jobs:
|
||||
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
|
||||
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
|
||||
run: |
|
||||
{
|
||||
echo "## Desktop E2E — Visual Diff Report"
|
||||
echo ""
|
||||
echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Count diff images (playwright writes *-diff.png on mismatch)
|
||||
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
|
||||
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$DIFF_COUNT" -eq 0 ]; then
|
||||
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)."
|
||||
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY"
|
||||
else
|
||||
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**"
|
||||
echo ""
|
||||
echo "| Test | Diff | Actual | Expected |"
|
||||
echo "|------|------|--------|----------|"
|
||||
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# List each diff image with a link to the artifact
|
||||
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
|
||||
base=${diff%-diff.png}
|
||||
base=$(echo "$diff" | sed 's/-diff\.png$//')
|
||||
test_name=$(basename "$base")
|
||||
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |"
|
||||
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
|
||||
done
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "📥 **Artifacts:**"
|
||||
echo ""
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$RESULTS_URL" ]; then
|
||||
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces"
|
||||
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [ -n "$REPORT_URL" ]; then
|
||||
echo "- [playwright-report]($REPORT_URL) — interactive HTML report"
|
||||
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [ -n "$DIFFS_URL" ]; then
|
||||
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)"
|
||||
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
echo ""
|
||||
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally."
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
# Also parse the JSON report for pass/fail counts
|
||||
if [ -f playwright-report/results.json ]; then
|
||||
echo ""
|
||||
echo "### Test Results"
|
||||
echo ""
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "### Test Results" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
node -e "
|
||||
const r = require('./playwright-report/results.json');
|
||||
const stats = r.stats || {};
|
||||
@@ -250,6 +208,5 @@ jobs:
|
||||
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
|
||||
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
|
||||
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
|
||||
" 2>/dev/null || true
|
||||
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -122,7 +122,6 @@ 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
|
||||
@@ -133,7 +132,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Download patch
|
||||
|
||||
@@ -48,9 +48,6 @@ 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:
|
||||
@@ -67,7 +64,7 @@ jobs:
|
||||
- name: Download SARIF result
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: OSV Scanner SARIF file
|
||||
name: osv-results
|
||||
path: /tmp/osv-results
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -1,69 +0,0 @@
|
||||
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
|
||||
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"
|
||||
@@ -23,10 +23,6 @@ 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
|
||||
@@ -82,25 +78,18 @@ 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[@]}" \
|
||||
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
|
||||
--output "$GITHUB_OUTPUT"
|
||||
python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fail on missing label
|
||||
if: steps.label-check.outputs.ci_reviewed != 'true'
|
||||
|
||||
@@ -21,7 +21,6 @@ jobs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
environment: trusted-automation
|
||||
steps:
|
||||
- name: Probe live index
|
||||
id: probe
|
||||
@@ -114,7 +113,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Open issue on degraded / failed probe
|
||||
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
|
||||
@@ -29,7 +28,7 @@ jobs:
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ vars.APP_CLIENT_ID }}
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
@@ -61,13 +60,12 @@ 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: ${{ vars.APP_CLIENT_ID }}
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
|
||||
@@ -65,11 +65,17 @@ 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: ${{ github.token }}
|
||||
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
|
||||
GH_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
|
||||
@@ -87,7 +93,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 --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
|
||||
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
|
||||
if [ -n "$PTH_FILES" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: .pth file added or modified
|
||||
@@ -135,11 +141,8 @@ 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 --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
|
||||
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
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: Install-hook file added or modified
|
||||
These files can execute code during package installation or interpreter startup.
|
||||
|
||||
@@ -215,6 +215,11 @@ 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
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
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
|
||||
@@ -150,11 +150,6 @@ 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/
|
||||
@@ -174,4 +169,3 @@ apps/desktop/demo/
|
||||
# PR body is the archive. See the hermes-agent-dev skill's
|
||||
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
|
||||
infographic/
|
||||
native/fts5_cjk/*.so
|
||||
|
||||
+14
@@ -0,0 +1,14 @@
|
||||
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
|
||||
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
|
||||
recursive-include gateway/assets *
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
@@ -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 the runtime lazy installer.
|
||||
with ``hermes postinstall`` and the runtime lazy installer.
|
||||
|
||||
Returns 0 on success, 1 on failure.
|
||||
"""
|
||||
|
||||
+25
-140
@@ -74,10 +74,6 @@ 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,
|
||||
@@ -973,49 +969,11 @@ 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)
|
||||
@@ -1023,13 +981,11 @@ 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
|
||||
|
||||
@@ -1100,11 +1056,7 @@ class HermesACPAgent(acp.Agent):
|
||||
if role == "user":
|
||||
text = self._history_message_text(message)
|
||||
if text:
|
||||
update = self._history_message_update(
|
||||
role=role,
|
||||
text=text,
|
||||
field_meta=self._history_summary_meta(message, text),
|
||||
)
|
||||
update = self._history_message_update(role=role, text=text)
|
||||
if update is not None and not await _send(update):
|
||||
return
|
||||
continue
|
||||
@@ -1116,11 +1068,7 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
text = self._history_message_text(message)
|
||||
if text:
|
||||
update = self._history_message_update(
|
||||
role=role,
|
||||
text=text,
|
||||
field_meta=self._history_summary_meta(message, text),
|
||||
)
|
||||
update = self._history_message_update(role=role, text=text)
|
||||
if update is not None and not await _send(update):
|
||||
return
|
||||
|
||||
@@ -1270,19 +1218,12 @@ 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
|
||||
# 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,
|
||||
)
|
||||
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(
|
||||
@@ -1411,26 +1352,6 @@ 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,
|
||||
@@ -1445,54 +1366,23 @@ class HermesACPAgent(acp.Agent):
|
||||
await self._send_usage_update(state)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
# 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
|
||||
# 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.
|
||||
with state.runtime_lock:
|
||||
if state.is_running:
|
||||
if (
|
||||
text_only_prompt
|
||||
and isinstance(user_content, str)
|
||||
and getattr(
|
||||
state.agent,
|
||||
"_supports_active_turn_redirect",
|
||||
False,
|
||||
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)"
|
||||
)
|
||||
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")
|
||||
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]"
|
||||
|
||||
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
|
||||
|
||||
@@ -2021,10 +1911,7 @@ class HermesACPAgent(acp.Agent):
|
||||
lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens")
|
||||
|
||||
if getattr(agent, "compression_enabled", True) is False:
|
||||
lines.append(
|
||||
"Auto-compaction is disabled (compression.enabled: false); "
|
||||
"/compress still compresses manually."
|
||||
)
|
||||
lines.append("Compression is disabled for this agent.")
|
||||
else:
|
||||
lines.append("Tip: run /compress to compress manually before the threshold.")
|
||||
|
||||
@@ -2051,9 +1938,8 @@ class HermesACPAgent(acp.Agent):
|
||||
return "Nothing to compress — conversation is empty."
|
||||
try:
|
||||
agent = state.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 getattr(agent, "compression_enabled", True):
|
||||
return "Context compression is disabled for this agent."
|
||||
if not hasattr(agent, "_compress_context"):
|
||||
return "Context compression not available for this agent."
|
||||
|
||||
@@ -2078,7 +1964,6 @@ 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
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
|
||||
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 882 B |
@@ -701,18 +701,6 @@ 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=(
|
||||
|
||||
+1
-42
@@ -720,8 +720,6 @@ 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
|
||||
@@ -733,13 +731,6 @@ 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
|
||||
@@ -906,12 +897,6 @@ 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).
|
||||
@@ -1854,18 +1839,6 @@ def init_agent(
|
||||
}
|
||||
else:
|
||||
compression_model_thresholds = {}
|
||||
# Absolute token cap: when set, compression triggers at the lower of
|
||||
# the ratio-based threshold and this absolute count. Clamped to the
|
||||
# model's context length at apply-time so a cap above the window is
|
||||
# a no-op (ratio-based threshold wins).
|
||||
compression_threshold_tokens = _compression_cfg.get("threshold_tokens")
|
||||
if compression_threshold_tokens is not None:
|
||||
try:
|
||||
compression_threshold_tokens = int(compression_threshold_tokens)
|
||||
if compression_threshold_tokens <= 0:
|
||||
compression_threshold_tokens = None
|
||||
except (TypeError, ValueError):
|
||||
compression_threshold_tokens = None
|
||||
# In-place compaction: when True, compress_context() rewrites the message
|
||||
# list + rebuilds the system prompt WITHOUT rotating the session id (no
|
||||
# parent_session_id chain, no `name #N` renumber). See #38763 and
|
||||
@@ -1884,12 +1857,6 @@ 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
|
||||
@@ -2304,7 +2271,6 @@ def init_agent(
|
||||
abort_on_summary_failure=compression_abort_on_summary_failure,
|
||||
max_tokens=agent.max_tokens,
|
||||
model_thresholds=compression_model_thresholds,
|
||||
threshold_tokens_cap=compression_threshold_tokens,
|
||||
)
|
||||
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
|
||||
if callable(_bind_session_state):
|
||||
@@ -2316,9 +2282,6 @@ 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).
|
||||
@@ -2519,11 +2482,7 @@ def init_agent(
|
||||
_active_threshold_pct = getattr(
|
||||
agent.context_compressor, "threshold_percent", compression_threshold
|
||||
)
|
||||
_cap_note = ""
|
||||
_cap = getattr(agent.context_compressor, "threshold_tokens_cap", None)
|
||||
if _cap and _cap > 0:
|
||||
_cap_note = f" (capped at {_cap:,} tokens)"
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})")
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
|
||||
else:
|
||||
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
|
||||
# Notice with the exact opt-back-out command. Printed inline at startup
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
"""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 "")
|
||||
@@ -702,16 +702,6 @@ 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}. "
|
||||
@@ -721,27 +711,9 @@ 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
|
||||
@@ -847,12 +819,6 @@ 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
|
||||
|
||||
+93
-743
File diff suppressed because it is too large
Load Diff
@@ -30,12 +30,10 @@ from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import inspect
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
import threading
|
||||
from datetime import datetime
|
||||
@@ -57,71 +55,6 @@ 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.
|
||||
@@ -240,43 +173,6 @@ def _session_was_rotated_by_compression(session_db: Any, session_id: str) -> boo
|
||||
)
|
||||
|
||||
|
||||
def _emit_compression_attempt_telemetry(
|
||||
agent: Any,
|
||||
*,
|
||||
started_at: float,
|
||||
commit_status: str,
|
||||
split_status: str,
|
||||
failure_class: str | None = None,
|
||||
) -> None:
|
||||
"""Emit one content-free JSON log line for a compression attempt."""
|
||||
try:
|
||||
telemetry = getattr(agent.context_compressor, "_last_compression_telemetry", None)
|
||||
if not isinstance(telemetry, dict):
|
||||
telemetry = {}
|
||||
payload = dict(telemetry)
|
||||
payload.setdefault("event", "compression_attempt")
|
||||
payload.setdefault("attempt_id", getattr(agent, "_compression_attempt_id", "") or uuid.uuid4().hex)
|
||||
payload.setdefault("session_id", getattr(agent, "session_id", "") or "")
|
||||
payload["total_duration_ms"] = int((time.monotonic() - started_at) * 1000)
|
||||
payload["commit_status"] = commit_status
|
||||
payload["split_status"] = split_status
|
||||
if failure_class:
|
||||
payload["failure_class"] = failure_class
|
||||
payload.setdefault("chunking", False)
|
||||
payload.setdefault("chunk_count", 0)
|
||||
payload["fallback_used"] = bool(
|
||||
payload.get("fallback_used")
|
||||
or getattr(agent.context_compressor, "_last_summary_fallback_used", False)
|
||||
or getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
|
||||
)
|
||||
logger.info(
|
||||
"context compression attempt telemetry: %s",
|
||||
json.dumps(payload, sort_keys=True, separators=(",", ":")),
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("failed to emit compression attempt telemetry: %s", exc)
|
||||
|
||||
|
||||
def _compression_lock_holder(agent: Any) -> str:
|
||||
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
|
||||
|
||||
@@ -1008,19 +904,6 @@ def compress_context(
|
||||
):
|
||||
raise RuntimeError("a compression notification is already pending")
|
||||
|
||||
_attempt_started_at = time.monotonic()
|
||||
_attempt_id = uuid.uuid4().hex
|
||||
_trigger_source = "manual" if force else "auto"
|
||||
try:
|
||||
agent._compression_attempt_id = _attempt_id
|
||||
setattr(agent.context_compressor, "_compression_telemetry_seed", {
|
||||
"attempt_id": _attempt_id,
|
||||
"session_id": agent.session_id or "",
|
||||
"trigger_source": _trigger_source,
|
||||
})
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Codex app-server sessions: the codex agent owns the real thread context;
|
||||
# Hermes' summarizer would only rewrite a local mirror without shrinking
|
||||
# the actual thread (#36801). Route compaction to the app server's own
|
||||
@@ -1090,14 +973,6 @@ 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
|
||||
@@ -1235,26 +1110,12 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
try:
|
||||
if hasattr(agent.context_compressor, "_begin_compression_telemetry"):
|
||||
agent.context_compressor._begin_compression_telemetry(current_tokens=approx_tokens)
|
||||
except Exception:
|
||||
pass
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
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
|
||||
@@ -1374,7 +1235,7 @@ def compress_context(
|
||||
messages_before_compression = copy.deepcopy(messages)
|
||||
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
|
||||
compressed = compress_fn(messages, **compress_kwargs)
|
||||
except BaseException as _compress_exc:
|
||||
except BaseException:
|
||||
# ANY exception after lock acquisition — memory hook, capability
|
||||
# inspection, engine lookup, or compress() — must release the lock so
|
||||
# the session isn't permanently blocked from future compression.
|
||||
@@ -1382,13 +1243,6 @@ def compress_context(
|
||||
_activity_heartbeat.stop("context compression failed")
|
||||
_activity_heartbeat = None
|
||||
_release_lock()
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
split_status="aborted",
|
||||
failure_class=f"exception:{type(_compress_exc).__name__}",
|
||||
)
|
||||
raise
|
||||
finally:
|
||||
if _activity_heartbeat is not None:
|
||||
@@ -1424,16 +1278,6 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
split_status="aborted",
|
||||
failure_class=(
|
||||
getattr(agent.context_compressor, "_last_summary_error", None)
|
||||
and "summary_generation_aborted"
|
||||
),
|
||||
)
|
||||
return messages, _existing_sp
|
||||
finally:
|
||||
_release_lock()
|
||||
@@ -1452,13 +1296,6 @@ def compress_context(
|
||||
_existing_sp = getattr(agent, "_cached_system_prompt", None)
|
||||
if not _existing_sp:
|
||||
_existing_sp = agent._build_system_prompt(system_message)
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status="aborted",
|
||||
split_status="aborted",
|
||||
failure_class="no_progress",
|
||||
)
|
||||
_release_lock()
|
||||
return messages, _existing_sp
|
||||
|
||||
@@ -1540,9 +1377,7 @@ def compress_context(
|
||||
agent._cached_system_prompt = new_system_prompt
|
||||
|
||||
_session_commit_succeeded = False
|
||||
split_status = "not_applicable"
|
||||
if agent._session_db:
|
||||
split_status = "pending"
|
||||
try:
|
||||
# Trigger memory extraction on the current session before the
|
||||
# transcript is rewritten (runs in BOTH modes — the logical
|
||||
@@ -1570,7 +1405,6 @@ def compress_context(
|
||||
# WITHOUT destroying history, unlike a hard replace_messages).
|
||||
# See #38763.
|
||||
agent._session_db.archive_and_compact(agent.session_id, compressed)
|
||||
split_status = "in_place_committed"
|
||||
# Reset the flush identity set so the next turn's appends are
|
||||
# diffed against the COMPACTED transcript: the compacted dicts
|
||||
# are passed as conversation_history next turn and skipped by
|
||||
@@ -1687,7 +1521,6 @@ def compress_context(
|
||||
agent._session_db_created = True
|
||||
raise
|
||||
agent._session_db_created = True
|
||||
split_status = "rotated_committed"
|
||||
# Carry a persistent /goal onto the continuation session.
|
||||
# Compression mints a fresh child id; load_goal does a flat
|
||||
# per-session lookup with no parent walk, so without this an
|
||||
@@ -1726,7 +1559,6 @@ def compress_context(
|
||||
}
|
||||
_session_commit_succeeded = True
|
||||
except Exception as e:
|
||||
split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed"
|
||||
# If the rotation rolled back to the parent (orphan-avoidance
|
||||
# above), agent.session_id is the still-indexed parent and
|
||||
# old_session_id was cleared — so this is recovery, not an
|
||||
@@ -1869,18 +1701,6 @@ def compress_context(
|
||||
agent.session_id or "none", _pre_msg_count, len(compressed),
|
||||
f"{_compressed_est:,}",
|
||||
)
|
||||
_commit_status = "committed" if split_status in {"not_applicable", "in_place_committed", "rotated_committed"} else "aborted"
|
||||
_emit_compression_attempt_telemetry(
|
||||
agent,
|
||||
started_at=_attempt_started_at,
|
||||
commit_status=_commit_status,
|
||||
split_status=split_status,
|
||||
failure_class=(
|
||||
"session_split_failed"
|
||||
if split_status in {"failed_not_indexed", "aborted"}
|
||||
else None
|
||||
),
|
||||
)
|
||||
return compressed, new_system_prompt
|
||||
finally:
|
||||
# Release the lock on the OLD session_id only AFTER rotation completed
|
||||
@@ -1951,15 +1771,6 @@ 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()
|
||||
@@ -1967,7 +1778,6 @@ 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):
|
||||
@@ -1992,7 +1802,6 @@ 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:
|
||||
@@ -2032,7 +1841,6 @@ 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
|
||||
|
||||
|
||||
@@ -2293,7 +2101,6 @@ def try_shrink_image_parts_in_messages(
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATUS",
|
||||
"COMPACTION_DONE_STATUS",
|
||||
"COMPACTION_STATUS_MARKER",
|
||||
"check_compression_model_feasibility",
|
||||
"replay_compression_warning",
|
||||
|
||||
+28
-206
@@ -28,14 +28,7 @@ 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 (
|
||||
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.conversation_compression import conversation_history_after_compression
|
||||
from agent.display import KawaiiSpinner
|
||||
from agent.error_classifier import FailoverReason, classify_api_error
|
||||
from agent.iteration_budget import IterationBudget
|
||||
@@ -110,53 +103,6 @@ _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 = []
|
||||
@@ -307,19 +253,6 @@ 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 "
|
||||
@@ -327,24 +260,12 @@ def _billing_or_entitlement_message(
|
||||
),
|
||||
"Add credits or update billing with that provider, then retry.",
|
||||
]
|
||||
if billing_url:
|
||||
lines.append(f"{provider_label} billing: {billing_url}")
|
||||
if base_url_host_matches(str(base_url or ""), "openrouter.ai"):
|
||||
lines.append("OpenRouter credits: https://openrouter.ai/settings/credits")
|
||||
lines.append("You can switch providers temporarily with /model <model> --provider <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,
|
||||
*,
|
||||
@@ -812,16 +733,6 @@ 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()
|
||||
|
||||
@@ -1311,9 +1222,8 @@ def run_conversation(
|
||||
max_compression_attempts,
|
||||
)
|
||||
agent._emit_status(
|
||||
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(
|
||||
tokens=request_pressure_tokens
|
||||
)
|
||||
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
|
||||
f"near the context/output limit. Compacting before the next model call."
|
||||
)
|
||||
_last_preflight_pressure = request_pressure_tokens
|
||||
messages, active_system_prompt = agent._compress_context(
|
||||
@@ -1622,59 +1532,22 @@ def run_conversation(
|
||||
|
||||
from hermes_cli.middleware import run_llm_execution_middleware
|
||||
|
||||
_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
|
||||
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),
|
||||
)
|
||||
|
||||
api_duration = time.time() - api_start_time
|
||||
|
||||
@@ -2636,15 +2509,6 @@ 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
|
||||
@@ -3527,9 +3391,8 @@ def run_conversation(
|
||||
)
|
||||
if len(messages) < original_len or old_ctx > _reduced_ctx:
|
||||
agent._buffer_status(
|
||||
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format(
|
||||
new_ctx=_reduced_ctx, old_ctx=old_ctx
|
||||
)
|
||||
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
|
||||
f"(was {old_ctx:,}), retrying..."
|
||||
)
|
||||
time.sleep(2)
|
||||
_retry.restart_with_compressed_messages = True
|
||||
@@ -3790,9 +3653,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(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
|
||||
agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
|
||||
else:
|
||||
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
|
||||
agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
|
||||
time.sleep(2) # Brief pause between compression retries
|
||||
_retry.restart_with_compressed_messages = True
|
||||
break
|
||||
@@ -4010,7 +3873,7 @@ def run_conversation(
|
||||
"failed": True,
|
||||
"compression_exhausted": True,
|
||||
}
|
||||
agent._buffer_status(COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=approx_tokens, attempt=compression_attempts, cap=max_compression_attempts))
|
||||
agent._buffer_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...")
|
||||
|
||||
original_len = len(messages)
|
||||
original_tokens = estimate_messages_tokens_rough(messages)
|
||||
@@ -4031,9 +3894,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(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
|
||||
agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
|
||||
elif new_tokens > 0 and new_tokens < original_tokens * 0.95:
|
||||
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
|
||||
agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
|
||||
time.sleep(2) # Brief pause between compression retries
|
||||
_retry.restart_with_compressed_messages = True
|
||||
break
|
||||
@@ -4309,31 +4172,6 @@ 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,
|
||||
@@ -4494,14 +4332,10 @@ 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:
|
||||
@@ -4541,9 +4375,6 @@ 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
|
||||
@@ -4626,15 +4457,6 @@ 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"
|
||||
|
||||
@@ -1484,43 +1484,6 @@ 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
|
||||
@@ -1642,18 +1605,7 @@ class CredentialPool:
|
||||
if entry.last_status == STATUS_EXHAUSTED:
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
if exhausted_until is not None and now < exhausted_until:
|
||||
# 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
|
||||
continue
|
||||
if clear_expired:
|
||||
cleared = replace(
|
||||
entry,
|
||||
|
||||
+25
-1
@@ -32,6 +32,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sysconfig
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -91,8 +92,12 @@ 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. ``<repo-root>/locales`` -- source checkouts and editable installs,
|
||||
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
|
||||
where the working tree sits next to ``agent/``.
|
||||
3. ``<sysconfig data|purelib|platlib>/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
|
||||
@@ -111,6 +116,25 @@ 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
|
||||
|
||||
|
||||
|
||||
@@ -52,13 +52,6 @@ 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, "
|
||||
@@ -81,12 +74,6 @@ 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, "
|
||||
|
||||
+11
-90
@@ -19,18 +19,13 @@ from typing import Optional
|
||||
from agent.runtime_cwd import resolve_agent_cwd
|
||||
from agent.skill_utils import (
|
||||
EXCLUDED_SKILL_DIRS,
|
||||
ORG_ACTIVE_MARKER,
|
||||
ORG_MIRROR_DIR_NAME,
|
||||
ORG_PROVENANCE_FILE,
|
||||
SKILL_SUPPORT_DIRS,
|
||||
extract_skill_conditions,
|
||||
extract_skill_description,
|
||||
get_all_skills_dirs,
|
||||
get_disabled_skill_names,
|
||||
iter_skill_index_files,
|
||||
org_id_of_path,
|
||||
parse_frontmatter,
|
||||
read_active_org_id,
|
||||
skill_matches_environment,
|
||||
skill_matches_platform,
|
||||
skill_matches_platform_list,
|
||||
@@ -1315,9 +1310,7 @@ def drain_truncation_warnings() -> list:
|
||||
_SKILLS_PROMPT_CACHE_MAX = 8
|
||||
_SKILLS_PROMPT_CACHE: OrderedDict[tuple, str] = OrderedDict()
|
||||
_SKILLS_PROMPT_CACHE_LOCK = threading.Lock()
|
||||
# v2: entries gained org provenance fields (org_id/org_author/rel_dir) for M2
|
||||
# org-shared skills; older snapshots are discarded and rebuilt.
|
||||
_SKILLS_SNAPSHOT_VERSION = 2
|
||||
_SKILLS_SNAPSHOT_VERSION = 1
|
||||
|
||||
|
||||
def _skills_prompt_snapshot_path() -> Path:
|
||||
@@ -1336,32 +1329,13 @@ def clear_skills_system_prompt_cache(*, clear_snapshot: bool = False) -> None:
|
||||
|
||||
|
||||
def _build_skills_manifest(skills_dir: Path) -> dict[str, list[int]]:
|
||||
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files.
|
||||
|
||||
Org mirrors (M2): only the ACTIVE org's mirror participates, and the
|
||||
``.active_org`` marker itself is included — so switching/leaving an org
|
||||
invalidates the snapshot even when no SKILL.md changed.
|
||||
"""
|
||||
"""Build an mtime/size manifest of all SKILL.md and DESCRIPTION.md files."""
|
||||
manifest: dict[str, list[int]] = {}
|
||||
skills_dir_str = str(skills_dir)
|
||||
base = os.path.join(skills_dir_str, "")
|
||||
prefix_len = len(base)
|
||||
active_org = read_active_org_id(skills_dir)
|
||||
org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME)
|
||||
marker_path = os.path.join(org_root, ORG_ACTIVE_MARKER)
|
||||
try:
|
||||
st = os.stat(marker_path)
|
||||
manifest[ORG_MIRROR_DIR_NAME + "/" + ORG_ACTIVE_MARKER] = [
|
||||
int(st.st_mtime), int(st.st_size),
|
||||
]
|
||||
except OSError:
|
||||
pass
|
||||
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
|
||||
has_skill_md = "SKILL.md" in files
|
||||
if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None:
|
||||
dirs.remove(ORG_MIRROR_DIR_NAME)
|
||||
elif root == org_root:
|
||||
dirs[:] = [d for d in dirs if d == active_org]
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
@@ -1426,15 +1400,6 @@ def _build_snapshot_entry(
|
||||
"""Build a serialisable metadata dict for one skill."""
|
||||
rel_path = skill_file.relative_to(skills_dir)
|
||||
parts = rel_path.parts
|
||||
|
||||
# M2 org mirror: strip the `_org/<org_id>/` prefix so category/name derive
|
||||
# from the path WITHIN the mirror (same shape the org tree was built
|
||||
# from), and record provenance for labeling + fail-loud collisions.
|
||||
org_id: str | None = None
|
||||
if len(parts) >= 3 and parts[0] == ORG_MIRROR_DIR_NAME:
|
||||
org_id = parts[1]
|
||||
parts = parts[2:]
|
||||
|
||||
if len(parts) >= 2:
|
||||
skill_name = parts[-2]
|
||||
category = "/".join(parts[:-2]) if len(parts) > 2 else parts[0]
|
||||
@@ -1446,7 +1411,7 @@ def _build_snapshot_entry(
|
||||
if isinstance(platforms, str):
|
||||
platforms = [platforms]
|
||||
|
||||
entry = {
|
||||
return {
|
||||
"skill_name": skill_name,
|
||||
"category": category,
|
||||
"frontmatter_name": str(frontmatter.get("name", skill_name)),
|
||||
@@ -1454,22 +1419,6 @@ def _build_snapshot_entry(
|
||||
"platforms": [str(p).strip() for p in platforms if str(p).strip()],
|
||||
"conditions": extract_skill_conditions(frontmatter),
|
||||
}
|
||||
if org_id:
|
||||
entry["org_id"] = org_id
|
||||
# Author from the pull-time provenance sidecar (token-verified at
|
||||
# push by the plane's author_mismatch guard). Best-effort.
|
||||
try:
|
||||
import json as _json
|
||||
|
||||
prov_path = (
|
||||
skills_dir / ORG_MIRROR_DIR_NAME / org_id / ORG_PROVENANCE_FILE
|
||||
)
|
||||
prov = _json.loads(prov_path.read_text(encoding="utf-8"))
|
||||
device = str(prov.get("author_device") or "")
|
||||
entry["org_author"] = device or str(prov.get("author_user_id") or "")
|
||||
except Exception:
|
||||
entry["org_author"] = ""
|
||||
return entry
|
||||
|
||||
|
||||
# =========================================================================
|
||||
@@ -1605,10 +1554,6 @@ def build_skills_system_prompt(
|
||||
|
||||
skills_by_category: dict[str, list[tuple[str, str]]] = {}
|
||||
category_descriptions: dict[str, str] = {}
|
||||
# Unified visible-entry list (both paths) so the org labeling +
|
||||
# fail-loud collision pass below runs identically for snapshot and scan.
|
||||
visible_entries: list[dict] = []
|
||||
skill_entries: list[dict] = []
|
||||
|
||||
if snapshot is not None:
|
||||
# Fast path: use pre-parsed metadata from disk
|
||||
@@ -1616,6 +1561,7 @@ def build_skills_system_prompt(
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
skill_name = entry.get("skill_name") or ""
|
||||
category = entry.get("category") or "general"
|
||||
frontmatter_name = entry.get("frontmatter_name") or skill_name
|
||||
platforms = entry.get("platforms") or []
|
||||
if not skill_matches_platform_list(platforms):
|
||||
@@ -1628,13 +1574,16 @@ def build_skills_system_prompt(
|
||||
available_toolsets,
|
||||
):
|
||||
continue
|
||||
visible_entries.append(entry)
|
||||
skills_by_category.setdefault(category, []).append(
|
||||
(frontmatter_name, entry.get("description", ""))
|
||||
)
|
||||
category_descriptions = {
|
||||
str(k): str(v)
|
||||
for k, v in (snapshot.get("category_descriptions") or {}).items()
|
||||
}
|
||||
else:
|
||||
# Cold path: full filesystem scan + write snapshot for next time
|
||||
skill_entries: list[dict] = []
|
||||
for skill_file in iter_skill_index_files(skills_dir, "SKILL.md"):
|
||||
is_compatible, frontmatter, desc = _parse_skill_file(skill_file)
|
||||
entry = _build_snapshot_entry(skill_file, skills_dir, frontmatter, desc)
|
||||
@@ -1650,38 +1599,10 @@ def build_skills_system_prompt(
|
||||
available_toolsets,
|
||||
):
|
||||
continue
|
||||
visible_entries.append(entry)
|
||||
skills_by_category.setdefault(entry["category"], []).append(
|
||||
(entry["frontmatter_name"], entry["description"])
|
||||
)
|
||||
|
||||
# ── M2 org labeling + FAIL-LOUD collisions ─────────────────────────
|
||||
# An org skill lists with an explicit provenance tag. When a personal and
|
||||
# an org skill share a name, NEITHER silently wins: both list qualified
|
||||
# (personal keeps the bare name is the wrong default — silent divergence
|
||||
# from the org set; org winning silently shadows the user's own work) —
|
||||
# so both entries carry a [name collision] flag and skill_view refuses
|
||||
# the ambiguous bare name (its existing multi-candidate guard).
|
||||
name_owners: dict[str, set[str]] = {}
|
||||
for entry in visible_entries:
|
||||
fm = entry.get("frontmatter_name") or entry.get("skill_name") or ""
|
||||
kind = "org" if entry.get("org_id") else "personal"
|
||||
name_owners.setdefault(fm, set()).add(kind)
|
||||
for entry in visible_entries:
|
||||
fm = entry.get("frontmatter_name") or entry.get("skill_name") or ""
|
||||
desc = entry.get("description", "")
|
||||
org_id = entry.get("org_id")
|
||||
collided = len(name_owners.get(fm, set())) > 1
|
||||
if org_id:
|
||||
author = entry.get("org_author") or ""
|
||||
tag = f"[org-shared{': by ' + author if author else ''}]"
|
||||
desc = f"{tag} {desc}".strip()
|
||||
category = f"org:{org_id}"
|
||||
else:
|
||||
category = entry.get("category") or "general"
|
||||
if collided:
|
||||
desc = f"[name collision — also exists {'personally' if org_id else 'in your org'}; load via category path] {desc}".strip()
|
||||
skills_by_category.setdefault(category, []).append((fm, desc))
|
||||
|
||||
if snapshot is None:
|
||||
# (continuation of the cold path below: category descriptions + write)
|
||||
# Read category-level DESCRIPTION.md files
|
||||
for desc_file in iter_skill_index_files(skills_dir, "DESCRIPTION.md"):
|
||||
try:
|
||||
|
||||
@@ -49,52 +49,6 @@ EXCLUDED_SKILL_DIRS = frozenset(
|
||||
# archive workflow preserves a complete old skill package under references/.
|
||||
SKILL_SUPPORT_DIRS = frozenset(("references", "templates", "assets", "scripts"))
|
||||
|
||||
# ── M2 org-shared skills (hsp-1-contract.md §11) ───────────────────────────
|
||||
# Org mirrors live under ~/.hermes/skills/_org/<org_id>/. Resolution is
|
||||
# TOKEN-GATED via a marker file the sync client writes after verifying the
|
||||
# token (skills_sync_client.pull_org_skills): only the marked org's mirror is
|
||||
# scanned. No marker ⇒ no org skills load. The marker is plain data (org_id
|
||||
# string) so this module stays import-light; the VERIFICATION lives in the
|
||||
# sync client, which is the only writer. Offline grace: the marker persists,
|
||||
# so already-pulled org skills keep working without connectivity; a VERIFIED
|
||||
# org change (or personal-org token) rewrites/removes it.
|
||||
|
||||
ORG_MIRROR_DIR_NAME = "_org"
|
||||
ORG_ACTIVE_MARKER = ".active_org"
|
||||
ORG_PROVENANCE_FILE = ".org-provenance.json"
|
||||
|
||||
|
||||
def read_active_org_id(skills_dir: Path) -> Optional[str]:
|
||||
"""The org id whose mirror may resolve, or None (no org skills load)."""
|
||||
try:
|
||||
marker = skills_dir / ORG_MIRROR_DIR_NAME / ORG_ACTIVE_MARKER
|
||||
if not marker.exists():
|
||||
return None
|
||||
val = marker.read_text(encoding="utf-8").strip()
|
||||
return val or None
|
||||
except OSError:
|
||||
return None
|
||||
|
||||
|
||||
def is_org_mirror_path(path, skills_dir: Path) -> bool:
|
||||
"""True when *path* is inside the org mirror (``_org/``)."""
|
||||
try:
|
||||
rel = Path(path).resolve().relative_to(Path(skills_dir).resolve())
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return bool(rel.parts) and rel.parts[0] == ORG_MIRROR_DIR_NAME
|
||||
|
||||
|
||||
def org_id_of_path(path, skills_dir: Path) -> Optional[str]:
|
||||
"""The ``<org_id>`` segment for a path under ``_org/<org_id>/...``."""
|
||||
try:
|
||||
rel = Path(path).resolve().relative_to(Path(skills_dir).resolve())
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
if len(rel.parts) >= 2 and rel.parts[0] == ORG_MIRROR_DIR_NAME:
|
||||
return rel.parts[1]
|
||||
return None
|
||||
|
||||
|
||||
def is_excluded_skill_path(path) -> bool:
|
||||
"""True if *path* should be skipped by active skill scanners.
|
||||
@@ -848,24 +802,11 @@ def iter_skill_index_files(skills_dir: Path, filename: str):
|
||||
scripts) can contain arbitrary markdown and even archived package
|
||||
``SKILL.md`` files, but they are progressive-disclosure data loaded through
|
||||
``skill_view(..., file_path=...)`` rather than active skill roots.
|
||||
|
||||
M2 org mirrors (``_org/``): TOKEN-GATED resolution. Only the active org's
|
||||
subdir (per the sync-client-written ``.active_org`` marker) is walked;
|
||||
every other ``_org/<id>/`` (stale mirror from a previous org, or no
|
||||
marker at all) is pruned — leave an org and its skills stop resolving,
|
||||
without any manual cleanup.
|
||||
"""
|
||||
skills_dir_str = str(skills_dir)
|
||||
active_org = read_active_org_id(skills_dir)
|
||||
org_root = os.path.join(skills_dir_str, ORG_MIRROR_DIR_NAME)
|
||||
matches: list[str] = []
|
||||
for root, dirs, files in os.walk(skills_dir_str, followlinks=True):
|
||||
has_skill_md = "SKILL.md" in files
|
||||
if root == skills_dir_str and ORG_MIRROR_DIR_NAME in dirs and active_org is None:
|
||||
dirs.remove(ORG_MIRROR_DIR_NAME)
|
||||
elif root == org_root:
|
||||
# Inside _org/: descend ONLY into the active org's mirror.
|
||||
dirs[:] = [d for d in dirs if d == active_org]
|
||||
dirs[:] = [
|
||||
d
|
||||
for d in dirs
|
||||
|
||||
@@ -300,8 +300,6 @@ 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
|
||||
@@ -376,8 +374,6 @@ 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()
|
||||
@@ -399,33 +395,6 @@ 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(
|
||||
@@ -500,18 +469,11 @@ 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
|
||||
|
||||
# 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
|
||||
self._interrupt_event.clear()
|
||||
projector = CodexEventProjector()
|
||||
|
||||
user_input_text = _coerce_turn_input_text(user_input)
|
||||
@@ -543,7 +505,6 @@ 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.
|
||||
@@ -553,12 +514,9 @@ 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
|
||||
@@ -783,9 +741,6 @@ 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(
|
||||
|
||||
+4
-116
@@ -26,16 +26,11 @@ 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 (
|
||||
IDLE_COMPACTION_STATUS_TEMPLATE,
|
||||
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
|
||||
conversation_history_after_compression,
|
||||
)
|
||||
from agent.conversation_compression import conversation_history_after_compression
|
||||
from agent.iteration_budget import IterationBudget
|
||||
from agent.memory_manager import build_memory_context_block
|
||||
from agent.model_metadata import (
|
||||
@@ -260,40 +255,6 @@ 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."""
|
||||
@@ -618,78 +579,6 @@ 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
|
||||
@@ -772,10 +661,9 @@ def build_turn_context(
|
||||
f"{_compressor.context_length:,}",
|
||||
)
|
||||
agent._emit_status(
|
||||
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
|
||||
tokens=_preflight_tokens,
|
||||
threshold=_compressor.threshold_tokens,
|
||||
)
|
||||
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
|
||||
f">= {_compressor.threshold_tokens:,} threshold. "
|
||||
"This may take a moment."
|
||||
)
|
||||
# Preflight passes honor the same configured per-turn cap
|
||||
# (compression.max_attempts) as the loop's compression sites;
|
||||
|
||||
@@ -73,10 +73,6 @@ 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.
|
||||
|
||||
@@ -8,10 +8,13 @@
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from './test'
|
||||
import { test } from './test'
|
||||
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
@@ -58,7 +61,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:
|
||||
@@ -78,61 +81,11 @@ 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') })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<number> {
|
||||
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<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="message"], [data-message-id]'))
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
|
||||
.map(message => message.textContent?.trim() ?? '')
|
||||
.filter(Boolean)
|
||||
})
|
||||
}
|
||||
|
||||
async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
// 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<void> {
|
||||
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)
|
||||
})
|
||||
})
|
||||
@@ -27,7 +27,7 @@ import * as path from 'node:path'
|
||||
|
||||
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
|
||||
import { startMockServer, type MockServerOptions } from './mock-server'
|
||||
import { startMockServer } from './mock-server'
|
||||
import { installErrorBannerGuard } from './test'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
@@ -140,18 +140,10 @@ 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 extraConfig optional YAML lines appended to the `display:` section,
|
||||
* used by the interim-message e2e test to toggle
|
||||
* `display.interim_assistant_messages`.
|
||||
*/
|
||||
export function writeMockProviderConfig(hermesHome: string, mockUrl: string, extraConfig?: string): void {
|
||||
export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
|
||||
const displaySection = extraConfig
|
||||
? `\ndisplay:\n${extraConfig}\n`
|
||||
: ''
|
||||
|
||||
const config = `# Auto-generated by E2E test fixtures
|
||||
model:
|
||||
default: mock-model
|
||||
@@ -165,7 +157,7 @@ providers:
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
${displaySection}`
|
||||
`
|
||||
|
||||
fs.writeFileSync(configPath, config, 'utf8')
|
||||
}
|
||||
@@ -326,21 +318,11 @@ export async function launchDesktop(
|
||||
export interface MockBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
mock: Awaited<ReturnType<typeof startMockServer>>
|
||||
mockUrl: string
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a full mock-backend E2E environment:
|
||||
* 1. Start the mock inference server
|
||||
@@ -348,17 +330,13 @@ export interface MockBackendOptions {
|
||||
* 3. Launch the desktop app
|
||||
* 4. Return handles for test interaction
|
||||
*/
|
||||
export interface MockBackendOptions {
|
||||
mockServer?: MockServerOptions
|
||||
}
|
||||
|
||||
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
|
||||
export async function setupMockBackend(): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer(options.mockServer)
|
||||
const mock = await startMockServer()
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('mock')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url, options.extraDisplayConfig)
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Build env + launch
|
||||
@@ -368,7 +346,6 @@ export async function setupMockBackend(options: MockBackendOptions = {}): Promis
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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<number> {
|
||||
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)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -1,235 +0,0 @@
|
||||
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<void>
|
||||
}
|
||||
|
||||
interface PaintState {
|
||||
bursts: number
|
||||
timeline: Array<{ mutations: number; time: number }>
|
||||
}
|
||||
|
||||
async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<SeededFixture> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<typeof setTimeout> | 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<PaintState> {
|
||||
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<number> {
|
||||
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<void> {
|
||||
await fixture.page.reload()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
await openNewSession(fixture.page)
|
||||
}
|
||||
|
||||
async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise<void> {
|
||||
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)
|
||||
})
|
||||
}
|
||||
})
|
||||
+80
-572
@@ -15,245 +15,17 @@
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
|
||||
/** A canned assistant reply used for every chat completion request. */
|
||||
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
|
||||
}
|
||||
|
||||
export interface MockServer {
|
||||
port: number
|
||||
url: string
|
||||
receivedPrompts: string[]
|
||||
waitForHeldStream: () => Promise<void>
|
||||
releaseHeldStream: () => void
|
||||
close: () => Promise<void>
|
||||
}
|
||||
|
||||
// ─── 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<string, unknown>
|
||||
}>
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
/**
|
||||
* Start the mock server on an ephemeral port.
|
||||
*
|
||||
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
|
||||
* @returns a handle with `port`, `url`, and `close()`.
|
||||
*/
|
||||
export function startMockServer(options: MockServerOptions = {}): Promise<MockServer> {
|
||||
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const receivedPrompts: string[] = []
|
||||
let resolveHeldStreamStarted: (() => void) | null = null
|
||||
let releaseHeldStream: (() => void) | null = null
|
||||
const heldStreamStarted = new Promise<void>(resolveHeld => {
|
||||
resolveHeldStreamStarted = resolveHeld
|
||||
})
|
||||
const heldStreamReleased = new Promise<void>(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.
|
||||
@@ -303,114 +75,88 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
// 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'
|
||||
|
||||
// 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) {
|
||||
const holdThisStream = Boolean(
|
||||
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
|
||||
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
|
||||
)
|
||||
streamTextResponse(res, model, MOCK_REPLY, holdThisStream ? () => {
|
||||
resolveHeldStreamStarted?.()
|
||||
return heldStreamReleased
|
||||
} : undefined)
|
||||
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 {
|
||||
nonStreamingTextResponse(res, model, MOCK_REPLY)
|
||||
// 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,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -441,9 +187,6 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
resolve({
|
||||
port,
|
||||
url,
|
||||
receivedPrompts,
|
||||
waitForHeldStream: () => heldStreamStarted,
|
||||
releaseHeldStream: () => releaseHeldStream?.(),
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
@@ -458,238 +201,3 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Response helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** SSE chunk shape for a streaming chat completion. */
|
||||
function sseChunk(model: string, delta: Record<string, unknown>, 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>,
|
||||
): 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<string, unknown> = { 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
|
||||
|
||||
@@ -1,118 +0,0 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 5 })
|
||||
await expect(primary).toHaveAttribute('aria-label', /Steer/)
|
||||
await primary.click()
|
||||
}
|
||||
|
||||
async function transcriptMessageOrder(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return []
|
||||
|
||||
return Array.from(viewport.querySelectorAll<HTMLElement>('[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!
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
|
||||
|
||||
await send(page, ACTIVE_PROMPT)
|
||||
await mock.waitForHeldStream()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(QUEUED_PROMPT, { delay: 5 })
|
||||
await queue.click()
|
||||
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])
|
||||
})
|
||||
})
|
||||
@@ -1,52 +0,0 @@
|
||||
#!/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]} <state.db>")
|
||||
|
||||
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()
|
||||
@@ -1,83 +0,0 @@
|
||||
/**
|
||||
* 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 { receivedUserTexts, restartMockServer } from './mock-server'
|
||||
|
||||
async function send(page: Page, text: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(text, { delay: 15 })
|
||||
await page.keyboard.press('Enter')
|
||||
}
|
||||
|
||||
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -1,252 +0,0 @@
|
||||
/**
|
||||
* 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<void> {
|
||||
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' })
|
||||
})
|
||||
})
|
||||
@@ -1,75 +0,0 @@
|
||||
/**
|
||||
* 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') })
|
||||
})
|
||||
@@ -1,210 +0,0 @@
|
||||
/**
|
||||
* 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' })
|
||||
})
|
||||
})
|
||||
@@ -1,484 +0,0 @@
|
||||
/**
|
||||
* 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<MockBackendFixture> {
|
||||
// 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<void> {
|
||||
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<typeof setTimeout> | 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)
|
||||
})
|
||||
@@ -1,94 +0,0 @@
|
||||
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 expect(codingRow).toContainText('main')
|
||||
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 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') })
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, 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,15 +51,6 @@ 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)
|
||||
|
||||
@@ -104,16 +104,6 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
|
||||
* in resolveHermesBackend.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
/**
|
||||
* An explicit desktop backend command is a deployment contract, not a PATH
|
||||
* discovery candidate. In particular, the Nix desktop wrapper points this at
|
||||
* its immutable, matching Hermes package; it must never fall through to the
|
||||
* mutable install-script bootstrap path if a best-effort probe is slow.
|
||||
*/
|
||||
function shouldTrustHermesOverride(hermesOverride?: string) {
|
||||
return typeof hermesOverride === 'string' && hermesOverride.trim().length > 0
|
||||
}
|
||||
|
||||
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
if (!hermesCommand) {
|
||||
return false
|
||||
@@ -133,4 +123,4 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verifyHermesCli }
|
||||
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }
|
||||
|
||||
@@ -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, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
import { canImportHermesCli, verifyHermesCli } from './backend-probes'
|
||||
import { waitForDashboardPortAnnouncement } from './backend-ready'
|
||||
import { shouldLatchBackendStartFailure } from './backend-start-failure'
|
||||
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
|
||||
@@ -80,6 +80,19 @@ 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,
|
||||
@@ -116,15 +129,6 @@ 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'
|
||||
@@ -3564,11 +3568,7 @@ function resolveHermesBackend(backendArgs) {
|
||||
// and lets the resolver fall through to step 6 / bootstrap.
|
||||
const shellForProbe = isCommandScript(hermesCommand)
|
||||
|
||||
// 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 })) {
|
||||
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||||
return (
|
||||
unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
@@ -5851,7 +5851,6 @@ async function ensureNativeAccessToken(baseUrl: string): Promise<string | null>
|
||||
{ refresh_token: tokens.refreshToken, provider: tokens.provider },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
|
||||
const rotated = parseTokenResponse(body)
|
||||
_storeNativeTokens(baseUrl, rotated)
|
||||
|
||||
@@ -5884,7 +5883,6 @@ async function mintGatewayWsTicket(baseUrl) {
|
||||
timeoutMs: 8_000,
|
||||
bearer: nativeAt
|
||||
})) as any
|
||||
|
||||
const ticket = body?.ticket
|
||||
|
||||
if (!ticket || typeof ticket !== 'string') {
|
||||
@@ -6461,7 +6459,10 @@ 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
|
||||
}
|
||||
@@ -8954,7 +8955,6 @@ 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,7 +8977,6 @@ 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) {
|
||||
@@ -8987,7 +8986,9 @@ 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 }
|
||||
})
|
||||
|
||||
@@ -10,7 +10,11 @@ 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) ---
|
||||
|
||||
|
||||
@@ -44,7 +44,9 @@ 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
|
||||
|
||||
@@ -22,18 +22,14 @@ 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
|
||||
|
||||
@@ -32,10 +32,10 @@ import {
|
||||
buildNativeAuthorizeUrl,
|
||||
generatePkcePair,
|
||||
generateState,
|
||||
type NativeTokenSet,
|
||||
nativeTokenUrl,
|
||||
parseLoopbackCallback,
|
||||
parseTokenResponse
|
||||
parseTokenResponse,
|
||||
type NativeTokenSet
|
||||
} from './native-oauth'
|
||||
|
||||
// Loopback login must complete inside this window (user opens browser,
|
||||
@@ -90,7 +90,6 @@ export async function runNativeLogin(
|
||||
return new Promise<NativeTokenSet>((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.
|
||||
@@ -180,7 +179,6 @@ export async function runNativeLogin(
|
||||
}
|
||||
|
||||
const redirectUri = `http://127.0.0.1:${addr.port}/callback`
|
||||
|
||||
const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, {
|
||||
challenge,
|
||||
redirectUri,
|
||||
|
||||
@@ -34,7 +34,6 @@ 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')
|
||||
@@ -42,7 +41,6 @@ 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, /[+/=]/)
|
||||
@@ -93,7 +91,6 @@ 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')
|
||||
@@ -111,7 +108,6 @@ 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')
|
||||
@@ -132,7 +128,10 @@ 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', () => {
|
||||
|
||||
@@ -86,7 +86,10 @@ 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'
|
||||
}
|
||||
@@ -106,7 +109,6 @@ 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',
|
||||
@@ -143,7 +145,10 @@ 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')
|
||||
@@ -198,11 +203,7 @@ 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<NativeTokenSet, 'expiresAt'>,
|
||||
nowSeconds: number,
|
||||
skewSeconds = 60
|
||||
): boolean {
|
||||
export function tokenNeedsRefresh(tokens: Pick<NativeTokenSet, 'expiresAt'>, 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
|
||||
|
||||
@@ -11,19 +11,15 @@
|
||||
"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 && 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 .",
|
||||
"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 .",
|
||||
"start": "npm run build && electron .",
|
||||
"prebuild": "npm run clean",
|
||||
"prebuild": "tsc -b . --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",
|
||||
@@ -55,9 +51,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": "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"
|
||||
"test:e2e": "playwright test e2e/",
|
||||
"test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
|
||||
"test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.14.23",
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
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<React.ComponentProps<typeof ComposerControls>> = {}) {
|
||||
return render(
|
||||
<I18nProvider configClient={null} initialLocale="en">
|
||||
<ComposerControls
|
||||
autoSpeak={false}
|
||||
busy={false}
|
||||
busyAction="stop"
|
||||
canSubmit={true}
|
||||
conversation={{
|
||||
active: false,
|
||||
level: 0,
|
||||
muted: false,
|
||||
onEnd: vi.fn(),
|
||||
onStart: vi.fn(),
|
||||
onStopTurn: vi.fn(),
|
||||
onToggleMute: vi.fn(),
|
||||
status: 'idle'
|
||||
}}
|
||||
disabled={false}
|
||||
hasComposerPayload={true}
|
||||
onDictate={vi.fn()}
|
||||
onQueue={vi.fn()}
|
||||
onToggleAutoSpeak={vi.fn()}
|
||||
state={state}
|
||||
voiceStatus="idle"
|
||||
{...overrides}
|
||||
/>
|
||||
</I18nProvider>
|
||||
)
|
||||
}
|
||||
|
||||
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 Ctrl+Enter for Steer', async () => {
|
||||
renderControls({ busy: true, busyAction: 'steer' })
|
||||
|
||||
await expectShortcutTooltip('Steer the current run', 'Ctrl+↵')
|
||||
})
|
||||
|
||||
it('shows Enter for Queue', async () => {
|
||||
renderControls({ busy: true, busyAction: 'queue' })
|
||||
|
||||
await expectShortcutTooltip('Queue message', '↵')
|
||||
})
|
||||
})
|
||||
@@ -1,9 +1,11 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Tip } 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'
|
||||
@@ -40,6 +42,7 @@ export function ComposerControls({
|
||||
autoSpeak,
|
||||
busy,
|
||||
busyAction,
|
||||
canSteer,
|
||||
canSubmit,
|
||||
compactModelPill = false,
|
||||
conversation,
|
||||
@@ -48,12 +51,13 @@ export function ComposerControls({
|
||||
state,
|
||||
voiceStatus,
|
||||
onDictate,
|
||||
onQueue,
|
||||
onSteer,
|
||||
onToggleAutoSpeak
|
||||
}: {
|
||||
autoSpeak: boolean
|
||||
busy: boolean
|
||||
busyAction: 'steer' | 'queue' | 'stop'
|
||||
busyAction: 'queue' | 'stop'
|
||||
canSteer: boolean
|
||||
canSubmit: boolean
|
||||
compactModelPill?: boolean
|
||||
conversation: ConversationProps
|
||||
@@ -62,39 +66,50 @@ export function ComposerControls({
|
||||
state: ChatBarState
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
onQueue: () => void
|
||||
onSteer: () => void
|
||||
onToggleAutoSpeak: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const steerCombo = formatCombo('mod+enter')
|
||||
const steerLabel = `${c.steer} (${steerCombo})`
|
||||
|
||||
const steerTip = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
|
||||
</span>
|
||||
)
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
}
|
||||
|
||||
const showVoicePrimary = !busy && !hasComposerPayload
|
||||
const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
{busyAction === 'steer' ? (
|
||||
<Tip label={<TipKeybindLabel actionId="composer.send" text={c.queueMessage} />}>
|
||||
{/* 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 ? (
|
||||
<Tip label={steerTip}>
|
||||
<Button
|
||||
aria-label={c.queueMessage}
|
||||
aria-label={steerLabel}
|
||||
className={GHOST_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={onQueue}
|
||||
onClick={onSteer}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<Layers3 className={iconSize.sm} />
|
||||
<SteeringWheel className={iconSize.sm} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : null}
|
||||
) : (
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
)}
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
<Button
|
||||
@@ -112,17 +127,9 @@ export function ComposerControls({
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Tip
|
||||
label={
|
||||
busy ? (
|
||||
<TipKeybindLabel actionId={busyAction === 'steer' ? 'composer.steer' : 'composer.send'} text={busyLabel} />
|
||||
) : (
|
||||
<TipKeybindLabel actionId="composer.send" text={c.send} />
|
||||
)
|
||||
}
|
||||
>
|
||||
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
|
||||
<Button
|
||||
aria-label={busy ? busyLabel : c.send}
|
||||
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
@@ -130,8 +137,6 @@ export function ComposerControls({
|
||||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 className={iconSize.sm} />
|
||||
) : busyAction === 'steer' ? (
|
||||
<SteeringWheel className={iconSize.sm} />
|
||||
) : (
|
||||
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
|
||||
@@ -1,143 +0,0 @@
|
||||
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ComposerAttachment } from '@/store/composer'
|
||||
|
||||
import { useComposerSubmit } from './use-composer-submit'
|
||||
|
||||
interface SubmitHarnessOptions {
|
||||
attachments?: ComposerAttachment[]
|
||||
busy?: boolean
|
||||
text?: string
|
||||
}
|
||||
|
||||
function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitHarnessOptions = {}) {
|
||||
const draftRef = { current: text }
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = 'composer-rich-input'
|
||||
editor.textContent = text
|
||||
const editorRef = { current: editor }
|
||||
const onCancel = vi.fn()
|
||||
const onSteer = vi.fn(async () => true)
|
||||
const onSubmit = vi.fn(async () => true)
|
||||
const queueCurrentDraft = vi.fn(() => true)
|
||||
|
||||
const clearDraft = vi.fn(() => {
|
||||
draftRef.current = ''
|
||||
editorRef.current!.textContent = ''
|
||||
})
|
||||
|
||||
const hook = renderHook(() =>
|
||||
useComposerSubmit({
|
||||
activeQueueSessionKey: 'stored-session',
|
||||
activeQueueSessionKeyRef: { current: 'stored-session' },
|
||||
attachments,
|
||||
busy,
|
||||
clearDraft,
|
||||
disabled: false,
|
||||
draftRef,
|
||||
drainNextQueued: vi.fn(async () => false),
|
||||
editorRef,
|
||||
exitQueuedEdit: vi.fn(() => false),
|
||||
focusInput: vi.fn(),
|
||||
inputDisabled: false,
|
||||
loadIntoComposer: vi.fn(),
|
||||
onCancel,
|
||||
onSteer,
|
||||
onSubmit,
|
||||
queueCurrentDraft,
|
||||
queueEdit: null,
|
||||
queuedPrompts: [],
|
||||
sessionId: 'runtime-session',
|
||||
setComposerText: vi.fn(),
|
||||
stashAt: vi.fn()
|
||||
})
|
||||
)
|
||||
|
||||
return { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft }
|
||||
}
|
||||
|
||||
describe('useComposerSubmit busy-turn routing', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('steers a plain-text follow-up instead of queueing or stopping', async () => {
|
||||
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
text: 'change course'
|
||||
})
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
|
||||
expect(queueCurrentDraft).not.toHaveBeenCalled()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('runs slash commands immediately while busy', async () => {
|
||||
const { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
|
||||
busy: true,
|
||||
text: '/compress preserve context'
|
||||
})
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/compress preserve context'))
|
||||
expect(clearDraft).toHaveBeenCalledTimes(1)
|
||||
expect(onSteer).not.toHaveBeenCalled()
|
||||
expect(queueCurrentDraft).not.toHaveBeenCalled()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('queues an attachment-bearing follow-up while busy', () => {
|
||||
const attachment: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.txt' }
|
||||
|
||||
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
|
||||
attachments: [attachment],
|
||||
busy: true,
|
||||
text: 'read this'
|
||||
})
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
|
||||
expect(onSteer).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('stops an active turn only with an empty composer', () => {
|
||||
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true })
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
expect(onCancel).toHaveBeenCalledTimes(1)
|
||||
expect(onSteer).not.toHaveBeenCalled()
|
||||
expect(onSubmit).not.toHaveBeenCalled()
|
||||
expect(queueCurrentDraft).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits a normal turn while idle', async () => {
|
||||
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ text: 'ordinary question' })
|
||||
|
||||
act(() => {
|
||||
hook.result.current.submitDraft()
|
||||
})
|
||||
|
||||
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ordinary question', { attachments: [] }))
|
||||
expect(onSteer).not.toHaveBeenCalled()
|
||||
expect(queueCurrentDraft).not.toHaveBeenCalled()
|
||||
expect(onCancel).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -17,6 +17,7 @@ interface UseComposerSubmitArgs {
|
||||
activeQueueSessionKeyRef: RefObject<string | null>
|
||||
attachments: ComposerAttachment[]
|
||||
busy: boolean
|
||||
canSteer: boolean
|
||||
clearDraft: () => void
|
||||
disabled: boolean
|
||||
draftRef: RefObject<string>
|
||||
@@ -42,7 +43,7 @@ interface UseComposerSubmitArgs {
|
||||
* queue meet. `submitDraft` is the one decision tree (queue-edit save · slash-
|
||||
* now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared
|
||||
* send-with-restore primitive (re-loads + re-stashes the draft if the gateway
|
||||
* rejects, so nothing is ever lost); `steerDraft` redirects the live turn. Reads
|
||||
* rejects, so nothing is ever lost); `steerDraft` nudges the live turn. Reads
|
||||
* the draft + queue APIs; owns no state of its own beyond the stable
|
||||
* external-submit listener ref.
|
||||
*/
|
||||
@@ -51,6 +52,7 @@ export function useComposerSubmit({
|
||||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
canSteer,
|
||||
clearDraft,
|
||||
disabled,
|
||||
draftRef,
|
||||
@@ -149,14 +151,7 @@ export function useComposerSubmit({
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
dispatchSubmit(text)
|
||||
} else if (!attachments.length && text.trim()) {
|
||||
// Cursor-style stop-and-correct: interrupt the live turn and redirect
|
||||
// it with this text. redirect() preserves the shown reasoning/work; if
|
||||
// the turn already ended, steerDraft re-queues so nothing is lost.
|
||||
steerDraft()
|
||||
} else if (payloadPresent) {
|
||||
// Attachments can't ride a redirect (no tool-result image carriage) —
|
||||
// queue the whole payload for the next turn.
|
||||
queueCurrentDraft()
|
||||
} else {
|
||||
// Stop button (the only way to reach here while busy with an empty
|
||||
@@ -178,18 +173,16 @@ export function useComposerSubmit({
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// Redirect the live turn with a correction. The gateway either restarts the
|
||||
// active model request with its displayed context or waits for the current
|
||||
// tool boundary. If the turn already ended, queue the words instead.
|
||||
// Steer the live turn (nudge without interrupting). Clears the draft up front
|
||||
// for snappy feedback; if the gateway rejects (no live tool window) the words
|
||||
// are re-queued so nothing is lost — same safety net as a plain queue.
|
||||
const steerDraft = () => {
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
// Guard on live editor state, not the render-lagged `canSteer`: a redirect
|
||||
// fired on a fast Enter must not be dropped because state hasn't synced.
|
||||
if (!onSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
|
||||
if (!onSteer || !canSteer) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
|
||||
@@ -200,14 +193,5 @@ export function useComposerSubmit({
|
||||
})
|
||||
}
|
||||
|
||||
const queueDraft = () => {
|
||||
if (disabled || !busy) {
|
||||
return
|
||||
}
|
||||
|
||||
queueCurrentDraft()
|
||||
focusInput()
|
||||
}
|
||||
|
||||
return { dispatchSubmit, queueDraft, steerDraft, submitDraft }
|
||||
return { dispatchSubmit, steerDraft, submitDraft }
|
||||
}
|
||||
|
||||
@@ -1,14 +1,7 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
|
||||
import {
|
||||
markVoicePlaybackInterrupted,
|
||||
playSpeechText,
|
||||
type SpeechStreamSession,
|
||||
startSpeechStream,
|
||||
stopVoicePlayback
|
||||
} from '@/lib/voice-playback'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useMicRecorder } from './use-mic-recorder'
|
||||
@@ -51,9 +44,7 @@ export function useVoiceConversation({
|
||||
const awaitingSpokenResponseRef = useRef(false)
|
||||
const responseIdRef = useRef<string | null>(null)
|
||||
const spokenSourceLengthRef = useRef(0)
|
||||
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
|
||||
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
|
||||
const bargeCapturePendingRef = useRef(false)
|
||||
const speechBufferRef = useRef('')
|
||||
const enabledRef = useRef(enabled)
|
||||
const mutedRef = useRef(muted)
|
||||
const busyRef = useRef(busy)
|
||||
@@ -83,13 +74,60 @@ export function useVoiceConversation({
|
||||
}
|
||||
}
|
||||
|
||||
const dropSpeechSession = () => {
|
||||
stopBargeMonitorRef.current?.()
|
||||
stopBargeMonitorRef.current = null
|
||||
bargeCapturePendingRef.current = false
|
||||
speechSessionRef.current = null
|
||||
const resetSpeechBuffer = () => {
|
||||
responseIdRef.current = null
|
||||
spokenSourceLengthRef.current = 0
|
||||
speechBufferRef.current = ''
|
||||
}
|
||||
|
||||
const appendSpeechText = (text: string) => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
speechBufferRef.current = `${speechBufferRef.current}${text}`
|
||||
}
|
||||
|
||||
const takeSpeechChunk = (force = false): string | null => {
|
||||
const buffer = speechBufferRef.current.replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (!buffer) {
|
||||
speechBufferRef.current = ''
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const sentence = buffer.match(/^(.+?[.!?。!?])(?:\s+|$)/)
|
||||
|
||||
if (sentence?.[1] && (sentence[1].length >= 8 || force)) {
|
||||
const chunk = sentence[1].trim()
|
||||
speechBufferRef.current = buffer.slice(sentence[1].length).trim()
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
if (!force && buffer.length > 220) {
|
||||
const softBoundary = Math.max(
|
||||
buffer.lastIndexOf(', ', 180),
|
||||
buffer.lastIndexOf('; ', 180),
|
||||
buffer.lastIndexOf(': ', 180)
|
||||
)
|
||||
|
||||
if (softBoundary > 80) {
|
||||
const chunk = buffer.slice(0, softBoundary + 1).trim()
|
||||
speechBufferRef.current = buffer.slice(softBoundary + 1).trim()
|
||||
|
||||
return chunk
|
||||
}
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
return null
|
||||
}
|
||||
|
||||
speechBufferRef.current = ''
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
const handleTurn = useCallback(
|
||||
@@ -129,7 +167,7 @@ export function useVoiceConversation({
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = true
|
||||
dropSpeechSession()
|
||||
resetSpeechBuffer()
|
||||
await onSubmit(transcript)
|
||||
setStatus('thinking')
|
||||
} catch (error) {
|
||||
@@ -155,10 +193,6 @@ export function useVoiceConversation({
|
||||
return
|
||||
}
|
||||
|
||||
if (bargeCapturePendingRef.current) {
|
||||
return // the barge monitor is mid-capture and owns the mic
|
||||
}
|
||||
|
||||
if (statusRef.current !== 'idle') {
|
||||
return
|
||||
}
|
||||
@@ -186,238 +220,24 @@ export function useVoiceConversation({
|
||||
}
|
||||
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
|
||||
|
||||
const settleAfterSpeech = useCallback(
|
||||
(barged: boolean) => {
|
||||
if (barged || !awaitingSpokenResponseRef.current) {
|
||||
awaitingSpokenResponseRef.current = false
|
||||
consumePendingResponse()
|
||||
}
|
||||
|
||||
if (bargeCapturePendingRef.current) {
|
||||
// The barge monitor is still capturing the user's interruption — it
|
||||
// owns the next turn. Keep it alive and don't re-open the mic; the
|
||||
// utterance callback transcribes and submits when they go quiet.
|
||||
speechSessionRef.current = null
|
||||
responseIdRef.current = null
|
||||
spokenSourceLengthRef.current = 0
|
||||
setStatus('listening')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
dropSpeechSession()
|
||||
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
},
|
||||
[consumePendingResponse]
|
||||
)
|
||||
|
||||
/**
|
||||
* Submit the utterance the barge monitor captured — the user's interruption
|
||||
* from its first syllable, no re-listen round trip. Empty/failed captures
|
||||
* fall back to normal listening.
|
||||
*/
|
||||
const submitCapturedUtterance = useCallback(
|
||||
async (audio: Blob | null) => {
|
||||
const resumeListening = () => {
|
||||
if (enabledRef.current && !mutedRef.current) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
}
|
||||
|
||||
if (!audio || !onTranscribeAudio) {
|
||||
resumeListening()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('transcribing')
|
||||
|
||||
try {
|
||||
const transcript = (await onTranscribeAudio(audio)).trim()
|
||||
|
||||
if (!transcript) {
|
||||
resumeListening()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = true
|
||||
dropSpeechSession()
|
||||
consumePendingResponse()
|
||||
await onSubmit(transcript)
|
||||
setStatus('thinking')
|
||||
} catch (error) {
|
||||
notifyError(error, voiceCopy.transcriptionFailed)
|
||||
resumeListening()
|
||||
}
|
||||
},
|
||||
[consumePendingResponse, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
|
||||
)
|
||||
|
||||
/** Barge-in monitor wiring shared by the live and fallback speech paths. */
|
||||
const openBargeMonitor = useCallback(
|
||||
(onBarge: () => void) =>
|
||||
monitorSpeechDuringPlayback({
|
||||
onSpeech: () => {
|
||||
bargeCapturePendingRef.current = true
|
||||
onBarge()
|
||||
markVoicePlaybackInterrupted()
|
||||
stopVoicePlayback()
|
||||
},
|
||||
onUtterance: audio => {
|
||||
bargeCapturePendingRef.current = false
|
||||
stopBargeMonitorRef.current = null
|
||||
void submitCapturedUtterance(audio)
|
||||
}
|
||||
}),
|
||||
[submitCapturedUtterance]
|
||||
)
|
||||
|
||||
/** Push any new reply text into the live session; finish when complete. */
|
||||
const feedSpeechSession = useCallback(
|
||||
(responseId: string) => {
|
||||
const session = speechSessionRef.current
|
||||
|
||||
if (!session || responseIdRef.current !== responseId) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = pendingResponse()
|
||||
|
||||
if (response && response.id === responseId) {
|
||||
if (response.text.length > spokenSourceLengthRef.current) {
|
||||
session.append(response.text.slice(spokenSourceLengthRef.current))
|
||||
spokenSourceLengthRef.current = response.text.length
|
||||
}
|
||||
|
||||
if (!response.pending && !busyRef.current) {
|
||||
session.finish()
|
||||
}
|
||||
} else if (!busyRef.current) {
|
||||
// Reply consumed/vanished while we were speaking — close out the turn.
|
||||
session.finish()
|
||||
}
|
||||
},
|
||||
[pendingResponse]
|
||||
)
|
||||
|
||||
/** Whole-text fallback: wait for the reply to complete, then speak it. */
|
||||
const awaitFallbackSpeech = useCallback(
|
||||
(responseId: string) => {
|
||||
const poll = () => {
|
||||
if (responseIdRef.current !== responseId) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = pendingResponse()
|
||||
|
||||
if (!response || response.id !== responseId) {
|
||||
settleAfterSpeech(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (response.pending || busyRef.current) {
|
||||
window.setTimeout(poll, 250)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let barged = false
|
||||
|
||||
stopBargeMonitorRef.current?.()
|
||||
stopBargeMonitorRef.current = openBargeMonitor(() => {
|
||||
barged = true
|
||||
})
|
||||
|
||||
void playSpeechText(response.text, { source: 'voice-conversation' })
|
||||
.catch(error => notifyError(error, voiceCopy.playbackFailed))
|
||||
.finally(() => {
|
||||
if (responseIdRef.current === responseId) {
|
||||
awaitingSpokenResponseRef.current = false
|
||||
settleAfterSpeech(barged)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
poll()
|
||||
},
|
||||
[openBargeMonitor, pendingResponse, settleAfterSpeech, voiceCopy.playbackFailed]
|
||||
)
|
||||
|
||||
/**
|
||||
* Live-speak the streaming reply: one speech session per response, fed
|
||||
* incremental text as the assistant generates it. Audio overlaps generation
|
||||
* — no wait for the full reply, no per-sentence gaps.
|
||||
*/
|
||||
const openLiveSpeech = useCallback(
|
||||
(responseId: string) => {
|
||||
responseIdRef.current = responseId
|
||||
spokenSourceLengthRef.current = 0
|
||||
const speak = useCallback(
|
||||
async (text: string) => {
|
||||
setStatus('speaking')
|
||||
|
||||
let barged = false
|
||||
|
||||
// VAD barge-in: the user talking over the reply cuts playback, drops
|
||||
// the not-yet-spoken remainder, AND keeps capturing — the interruption
|
||||
// is transcribed from its first syllable instead of losing the opening
|
||||
// words to a mic re-open.
|
||||
stopBargeMonitorRef.current = openBargeMonitor(() => {
|
||||
barged = true
|
||||
})
|
||||
|
||||
void (async () => {
|
||||
const session = await startSpeechStream({ source: 'voice-conversation' })
|
||||
|
||||
// The session may resolve after the loop moved on (barge, disable).
|
||||
if (responseIdRef.current !== responseId) {
|
||||
if (session) {
|
||||
stopVoicePlayback()
|
||||
}
|
||||
|
||||
return
|
||||
try {
|
||||
await playSpeechText(text, { source: 'voice-conversation' })
|
||||
} catch (error) {
|
||||
notifyError(error, voiceCopy.playbackFailed)
|
||||
} finally {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
} else {
|
||||
setStatus('idle')
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// No streaming backend/provider: speak the whole reply once it lands.
|
||||
speechSessionRef.current = null
|
||||
awaitFallbackSpeech(responseId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
speechSessionRef.current = session
|
||||
|
||||
// Timer-driven feed: reply text flows into the session at delta rate
|
||||
// regardless of React render cadence.
|
||||
const feedTimer = window.setInterval(() => feedSpeechSession(responseId), 150)
|
||||
feedSpeechSession(responseId)
|
||||
|
||||
const outcome = await session.done
|
||||
window.clearInterval(feedTimer)
|
||||
|
||||
if (responseIdRef.current !== responseId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (outcome === 'fallback') {
|
||||
awaitFallbackSpeech(responseId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = false
|
||||
settleAfterSpeech(barged)
|
||||
})()
|
||||
}
|
||||
},
|
||||
[awaitFallbackSpeech, feedSpeechSession, openBargeMonitor, settleAfterSpeech]
|
||||
[voiceCopy.playbackFailed]
|
||||
)
|
||||
|
||||
const start = useCallback(async () => {
|
||||
@@ -434,7 +254,7 @@ export function useVoiceConversation({
|
||||
|
||||
setMuted(false)
|
||||
awaitingSpokenResponseRef.current = false
|
||||
dropSpeechSession()
|
||||
resetSpeechBuffer()
|
||||
consumePendingResponse()
|
||||
pendingStartRef.current = true
|
||||
await startListening()
|
||||
@@ -454,7 +274,7 @@ export function useVoiceConversation({
|
||||
handle.cancel()
|
||||
turnClosingRef.current = false
|
||||
awaitingSpokenResponseRef.current = false
|
||||
dropSpeechSession()
|
||||
resetSpeechBuffer()
|
||||
consumePendingResponse()
|
||||
setMuted(false)
|
||||
setStatus('idle')
|
||||
@@ -505,9 +325,8 @@ export function useVoiceConversation({
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [enabled, stopTurn])
|
||||
|
||||
// Drive the loop: when a voice-submitted reply appears, open a live speech
|
||||
// session (which feeds itself from then on). Otherwise start listening when
|
||||
// idle between turns.
|
||||
// Drive the loop: after a voice-submitted turn, speak stable chunks as the
|
||||
// assistant stream grows. Otherwise start listening when idle between turns.
|
||||
useEffect(() => {
|
||||
if (!enabled || muted) {
|
||||
return
|
||||
@@ -517,15 +336,38 @@ export function useVoiceConversation({
|
||||
const response = pendingResponse()
|
||||
|
||||
if (response) {
|
||||
openLiveSpeech(response.id)
|
||||
if (response.id !== responseIdRef.current) {
|
||||
resetSpeechBuffer()
|
||||
responseIdRef.current = response.id
|
||||
}
|
||||
|
||||
return
|
||||
if (response.text.length > spokenSourceLengthRef.current) {
|
||||
appendSpeechText(response.text.slice(spokenSourceLengthRef.current))
|
||||
spokenSourceLengthRef.current = response.text.length
|
||||
}
|
||||
|
||||
const chunk = takeSpeechChunk(!response.pending && !busy)
|
||||
|
||||
if (chunk) {
|
||||
void speak(chunk)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.pending && !busy) {
|
||||
awaitingSpokenResponseRef.current = false
|
||||
consumePendingResponse()
|
||||
resetSpeechBuffer()
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (!busy && status === 'thinking') {
|
||||
// Turn finished without any speakable reply (tool-only, error).
|
||||
awaitingSpokenResponseRef.current = false
|
||||
dropSpeechSession()
|
||||
resetSpeechBuffer()
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
|
||||
@@ -540,7 +382,7 @@ export function useVoiceConversation({
|
||||
if (pendingStartRef.current) {
|
||||
void startListening()
|
||||
}
|
||||
}, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status])
|
||||
}, [busy, consumePendingResponse, enabled, muted, pendingResponse, speak, startListening, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !wasEnabledRef.current) {
|
||||
|
||||
@@ -227,22 +227,20 @@ export function ChatBar({
|
||||
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
|
||||
const hasComposerPayload = hasText || attachments.length > 0
|
||||
const canSubmit = busy || hasComposerPayload
|
||||
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
|
||||
// into a tool result) and never for a slash command (those execute inline).
|
||||
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
|
||||
|
||||
// While busy: text redirects the live turn (Cursor-style stop-and-correct),
|
||||
// attachments queue for the next turn, an empty composer stops.
|
||||
const busyAction: 'steer' | 'queue' | 'stop' = canSteer ? 'steer' : hasComposerPayload ? 'queue' : 'stop'
|
||||
|
||||
// The submit engine — the orchestration seam where draft + queue meet. Owns
|
||||
// the submit decision tree, the send-with-restore primitive, and steer.
|
||||
const { queueDraft, steerDraft, submitDraft } = useComposerSubmit({
|
||||
const { steerDraft, submitDraft } = useComposerSubmit({
|
||||
activeQueueSessionKey,
|
||||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
canSteer,
|
||||
clearDraft,
|
||||
disabled,
|
||||
draftRef,
|
||||
@@ -723,6 +721,7 @@ export function ChatBar({
|
||||
autoSpeak={autoSpeak}
|
||||
busy={busy}
|
||||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
compactModelPill={poppedOut || compactPill}
|
||||
conversation={{
|
||||
@@ -738,7 +737,7 @@ export function ChatBar({
|
||||
disabled={disabled}
|
||||
hasComposerPayload={hasComposerPayload}
|
||||
onDictate={dictate}
|
||||
onQueue={queueDraft}
|
||||
onSteer={steerDraft}
|
||||
onToggleAutoSpeak={handleToggleAutoSpeak}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
|
||||
@@ -4,7 +4,6 @@ import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { blurComposerInput } from '@/app/chat/composer/focus'
|
||||
import { AGENTS_ROUTE } from '@/app/routes'
|
||||
import { BillingBanner } from '@/components/billing-banner'
|
||||
import { composerDockCard } from '@/components/chat/composer-dock'
|
||||
import { StatusSection } from '@/components/chat/status-section'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -12,7 +11,6 @@ import { Codicon } from '@/components/ui/codicon'
|
||||
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
|
||||
import { type Translations, useI18n } from '@/i18n'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $billingBlock } from '@/store/billing-block'
|
||||
import {
|
||||
$statusItemsBySession,
|
||||
type ComposerStatusItem,
|
||||
@@ -72,7 +70,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
const itemsBySession = useStore($statusItemsBySession)
|
||||
const previewsBySession = useStore($previewStatusBySession)
|
||||
const scrolledUp = useStore($threadScrolledUp)
|
||||
const billing = useStore($billingBlock)
|
||||
|
||||
const groups = useMemo(
|
||||
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
|
||||
@@ -126,13 +123,6 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
|
||||
|
||||
const sections: { key: string; node: ReactNode }[] = []
|
||||
|
||||
// Billing wall sits at the very top of the stack — it's the most important
|
||||
// thing above the composer when the account is out of credits. Rendered here
|
||||
// (not as a composer-disable) so slash commands stay usable.
|
||||
if (billing && sessionId && billing.sessionId === sessionId) {
|
||||
sections.push({ key: 'billing', node: <BillingBanner sessionId={sessionId} /> })
|
||||
}
|
||||
|
||||
for (const group of groups) {
|
||||
sections.push({
|
||||
key: group.type,
|
||||
|
||||
@@ -27,12 +27,11 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
|
||||
import { sessionMessagesSignature } from '@/lib/session-signatures'
|
||||
import { isMessagingSource } from '@/lib/session-source'
|
||||
import { latestSessionTodos } from '@/lib/todos'
|
||||
import { $billingSettingsRequest } from '@/store/billing-block'
|
||||
import { setCronFocusJobId } from '@/store/cron'
|
||||
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
|
||||
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
|
||||
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
|
||||
import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects'
|
||||
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$connection,
|
||||
@@ -49,6 +48,8 @@ import {
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentModelSource,
|
||||
setCurrentProvider,
|
||||
@@ -88,7 +89,6 @@ import { useRouteResume } from '../session/hooks/use-route-resume'
|
||||
import { useSessionActions } from '../session/hooks/use-session-actions'
|
||||
import { useSessionListActions } from '../session/hooks/use-session-list-actions'
|
||||
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
|
||||
import { startWorkspaceSession } from '../session/workspace-session-target'
|
||||
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
|
||||
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
|
||||
import { titlebarControlsPosition } from '../shell/titlebar'
|
||||
@@ -126,10 +126,6 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
|
||||
const busyRef = useRef(false)
|
||||
const creatingSessionRef = useRef(false)
|
||||
// Billing recovery routes to Settings → Billing from surfaces without router
|
||||
// context (the sticky toast). The shell owns `navigate`, so it consumes the
|
||||
// intent counter here; the ref skips the initial mount value.
|
||||
const billingSettingsSeenRef = useRef(0)
|
||||
const messagingTranscriptSignatureRef = useRef(new Map<string, string>())
|
||||
// Stable identity for the whole callback surface (see WiringActions). Mutated
|
||||
// in place each render so memoized surfaces never re-render on churn.
|
||||
@@ -137,20 +133,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const billingSettingsRequest = useStore($billingSettingsRequest)
|
||||
const currentCwd = useStore($currentCwd)
|
||||
|
||||
useEffect(() => {
|
||||
if (billingSettingsRequest === billingSettingsSeenRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
billingSettingsSeenRef.current = billingSettingsRequest
|
||||
|
||||
if (billingSettingsRequest > 0) {
|
||||
navigate(`${SETTINGS_ROUTE}?tab=billing`)
|
||||
}
|
||||
}, [billingSettingsRequest, navigate])
|
||||
const freshDraftReady = useStore($freshDraftReady)
|
||||
const resumeFailedSessionId = useStore($resumeFailedSessionId)
|
||||
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
|
||||
@@ -467,16 +450,33 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
// also drills the sidebar into that project so the new lane is visible.
|
||||
const startSessionInWorkspace = useCallback(
|
||||
(path: null | string) => {
|
||||
startWorkspaceSession({
|
||||
activeSessionIdRef,
|
||||
followActiveSessionCwd,
|
||||
onExplicitWorkspace: restoreWorktree,
|
||||
path,
|
||||
requestGateway,
|
||||
startFreshSessionDraft
|
||||
})
|
||||
startFreshSessionDraft()
|
||||
|
||||
// A worktree lane carries its own path; the trunk "+" can be path-less
|
||||
// (the main checkout is implicit), so fall back to the active project's
|
||||
// root instead of no-op'ing on null.
|
||||
const target = path?.trim() || resolveNewSessionCwd()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
const resolved = info.cwd || target
|
||||
|
||||
setCurrentCwd(resolved)
|
||||
setCurrentBranch(info.branch || '')
|
||||
|
||||
if (path?.trim()) {
|
||||
restoreWorktree(resolved)
|
||||
void followActiveSessionCwd(resolved)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
},
|
||||
[activeSessionIdRef, requestGateway, startFreshSessionDraft]
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
// Composer "branch off into a new worktree": open a fresh session anchored
|
||||
|
||||
@@ -1,125 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
|
||||
import { routeTargetFromToken, sessionContextDrift } from './session-context-drift'
|
||||
|
||||
const SESS_A = 'sess-a'
|
||||
const SESS_B = 'sess-b'
|
||||
|
||||
// Build a route token the way desktop-controller does: pathname:search:hash.
|
||||
const routeToken = (pathname: string, search = '', hash = '') => `${pathname}:${search}:${hash}`
|
||||
|
||||
describe('routeTargetFromToken', () => {
|
||||
it('maps a session route to its session id, a non-chat route to null, and the new-chat route to __new__', () => {
|
||||
expect(routeTargetFromToken(routeToken(sessionRoute(SESS_A)))).toBe(SESS_A)
|
||||
expect(routeTargetFromToken(routeToken(SETTINGS_ROUTE))).toBeNull()
|
||||
expect(routeTargetFromToken(routeToken(NEW_CHAT_ROUTE))).toBe('__new__')
|
||||
})
|
||||
|
||||
it('ignores search and hash — only the pathname selects the chat', () => {
|
||||
expect(routeTargetFromToken(routeToken(sessionRoute(SESS_A), '?panel=preview', '#reply'))).toBe(SESS_A)
|
||||
})
|
||||
|
||||
it('treats a colon-free token as a bare pathname', () => {
|
||||
expect(routeTargetFromToken(sessionRoute(SESS_A))).toBe(SESS_A)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionContextDrift', () => {
|
||||
it('does not drift on search/hash-only route churn', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_A), '?panel=preview', '#reply'),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBeNull()
|
||||
})
|
||||
|
||||
it('does not drift on a selection null-reset (gateway/profile switch, reconnect)', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: null,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBeNull()
|
||||
})
|
||||
|
||||
it('drifts when selection moves to a different non-null stored session', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_B,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBe('selection:sess-a->sess-b')
|
||||
})
|
||||
|
||||
it('drifts when the routed session id changes to another session', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_B)),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBe('route:sess-a->sess-b')
|
||||
})
|
||||
|
||||
it('drifts when the route moves to the new-chat route mid-submit', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(NEW_CHAT_ROUTE),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBe('route:sess-a->__new__')
|
||||
})
|
||||
|
||||
it('does not drift when the route moves to a non-chat route (null target)', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(SETTINGS_ROUTE),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBeNull()
|
||||
})
|
||||
|
||||
it('does not drift when route and selection re-home onto the submit target (the create pipeline re-home)', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(NEW_CHAT_ROUTE),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
startSelectedStoredId: null,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBeNull()
|
||||
})
|
||||
|
||||
it('drifts when a new-chat draft with no target yet is switched to an existing chat', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(NEW_CHAT_ROUTE),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_B)),
|
||||
startSelectedStoredId: null,
|
||||
nowSelectedStoredId: SESS_B,
|
||||
submitTargetStoredId: null
|
||||
})
|
||||
|
||||
expect(reason).toBe('route:__new__->sess-b')
|
||||
})
|
||||
})
|
||||
@@ -1,84 +0,0 @@
|
||||
import { isNewChatRoute, routeSessionId } from '../../routes'
|
||||
|
||||
/**
|
||||
* The chat a route token points at: the stored/routed session id, `'__new__'`
|
||||
* for the new-chat route, or null for a route that isn't a chat (settings and
|
||||
* the other overlay routes). Used to compare two route tokens by their *chat*
|
||||
* rather than their raw string.
|
||||
*/
|
||||
export type RouteTarget = string | null
|
||||
|
||||
/**
|
||||
* Reduce a route token to the chat it targets. The token is
|
||||
* `${pathname}:${search}:${hash}` (desktop-controller's routeToken), and only
|
||||
* the pathname selects the chat — search/hash carry overlay/panel state, so a
|
||||
* change there must not read as a session switch. We take the substring before
|
||||
* the first ':' as the pathname; that is safe because `location.pathname` never
|
||||
* contains a raw ':' (sessionRoute encodeURIComponent's the id, so a ':' in an
|
||||
* id arrives as %3A, and the app's other routes are literal colon-free paths).
|
||||
*/
|
||||
export function routeTargetFromToken(token: string): RouteTarget {
|
||||
const separator = token.indexOf(':')
|
||||
const pathname = separator === -1 ? token : token.slice(0, separator)
|
||||
|
||||
return routeSessionId(pathname) ?? (isNewChatRoute(pathname) ? '__new__' : null)
|
||||
}
|
||||
|
||||
interface SessionContextDriftArgs {
|
||||
startRouteToken: string
|
||||
nowRouteToken: string
|
||||
startSelectedStoredId: string | null
|
||||
nowSelectedStoredId: string | null
|
||||
/**
|
||||
* The stored session this submit is bound to, when known. Drift ignores a
|
||||
* move *to* this id: the submit pipeline itself re-homes selection and route
|
||||
* onto its target (a fresh create, a resume), and that self-inflicted move is
|
||||
* not a user switch. Omit it (pre-create new-chat draft) to treat any move to
|
||||
* a real chat as drift.
|
||||
*/
|
||||
submitTargetStoredId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the session context genuinely changed under an in-flight
|
||||
* submit — the user (or a real navigation) moved to a DIFFERENT chat — as
|
||||
* opposed to the programmatic churn a busy gateway produces constantly:
|
||||
* - selection null-resets on a gateway/profile switch or reconnect
|
||||
* (gateway-switch's `setSelectedStoredSessionId(null)`),
|
||||
* - search/hash-only route changes from overlays and side panels,
|
||||
* - background gateway events retargeting the active runtime id (#47709 class,
|
||||
* which is why the active ref is not a prong here at all).
|
||||
* Returns null when nothing genuinely drifted, or a short reason string
|
||||
* (`route:<from>-><to>` / `selection:<from>-><to>`) for the abort log.
|
||||
*/
|
||||
export function sessionContextDrift({
|
||||
startRouteToken,
|
||||
nowRouteToken,
|
||||
startSelectedStoredId,
|
||||
nowSelectedStoredId,
|
||||
submitTargetStoredId
|
||||
}: SessionContextDriftArgs): string | null {
|
||||
const targetStart = routeTargetFromToken(startRouteToken)
|
||||
const targetNow = routeTargetFromToken(nowRouteToken)
|
||||
|
||||
// Route prong: the routed chat moved to a different, real chat. A null target
|
||||
// (navigated to settings / a non-chat overlay route) or a search/hash-only
|
||||
// change (same target) is not drift, and neither is landing on the submit's
|
||||
// own target.
|
||||
if (targetNow !== targetStart && targetNow !== null && targetNow !== submitTargetStoredId) {
|
||||
return `route:${targetStart}->${targetNow}`
|
||||
}
|
||||
|
||||
// Selection prong: selection moved to a different, real stored session. A
|
||||
// null-reset (nowSelectedStoredId === null) or a move onto the submit's own
|
||||
// target is not drift.
|
||||
if (
|
||||
nowSelectedStoredId !== null &&
|
||||
nowSelectedStoredId !== startSelectedStoredId &&
|
||||
nowSelectedStoredId !== submitTargetStoredId
|
||||
) {
|
||||
return `selection:${startSelectedStoredId}->${nowSelectedStoredId}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -79,14 +79,4 @@ describe('useMessageStream compaction lifecycle', () => {
|
||||
|
||||
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
|
||||
})
|
||||
|
||||
it('clears the compaction phase on the structured completion edge', async () => {
|
||||
await mountStream()
|
||||
setSessionCompacting(OTHER_SID, true)
|
||||
|
||||
emit('status.update', { kind: 'compacting' })
|
||||
emit('status.update', { kind: 'compacted' })
|
||||
|
||||
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { BillingBlock } from '@hermes/shared'
|
||||
import type { HermesSkin } from '@hermes/shared/skin'
|
||||
import type { QueryClient } from '@tanstack/react-query'
|
||||
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
|
||||
@@ -16,7 +15,6 @@ import { triggerHaptic } from '@/lib/haptics'
|
||||
import { modelOptionsQueryKey } from '@/lib/model-options'
|
||||
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
|
||||
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
|
||||
import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
|
||||
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
|
||||
import { setSessionCompacting } from '@/store/compaction'
|
||||
import { refreshBackgroundProcesses } from '@/store/composer-status'
|
||||
@@ -24,7 +22,6 @@ import { $gateway } from '@/store/gateway'
|
||||
import { dispatchNativeNotification } from '@/store/native-notifications'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { revealDesktopPane } from '@/store/pane-focus'
|
||||
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
import { followActiveSessionCwd } from '@/store/projects'
|
||||
@@ -59,50 +56,6 @@ import type { ClientSessionState } from '../../../types'
|
||||
|
||||
import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
|
||||
|
||||
function firstBillingLine(text: string): string {
|
||||
return (text || '').split('\n')[0]?.trim() ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* A turn failed on a billing wall (out of credits / payment required). The
|
||||
* gateway forwards the structured descriptor built by `agent/billing_links.py`;
|
||||
* we cache it per-session (drives the in-chat banner) AND raise one sticky,
|
||||
* billing-specific toast — never the generic "Hermes error" — with a smart CTA
|
||||
* (Nous → in-app Settings → Billing, other providers → their billing page).
|
||||
*/
|
||||
function surfaceBillingBlock(sessionId: string, raw: unknown): void {
|
||||
if (!raw || typeof raw !== 'object') {
|
||||
return
|
||||
}
|
||||
|
||||
const block = raw as BillingBlock
|
||||
|
||||
if (typeof block.provider !== 'string') {
|
||||
return
|
||||
}
|
||||
|
||||
setBillingBlock(sessionId, block)
|
||||
|
||||
const ctaCopy = {
|
||||
addCredits: translateNow('billingBlock.addCredits'),
|
||||
openBilling: translateNow('billingBlock.openBilling')
|
||||
}
|
||||
|
||||
notify({
|
||||
// Collapse repeat walls from the same provider into one toast.
|
||||
id: `billing-block:${block.provider}`,
|
||||
kind: 'warning',
|
||||
icon: 'credit-card',
|
||||
title: block.is_nous
|
||||
? translateNow('billingBlock.titleNous')
|
||||
: translateNow('billingBlock.titleProvider', block.provider_label),
|
||||
message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'),
|
||||
// Sticky: a credit wall blocks every turn until resolved.
|
||||
durationMs: 0,
|
||||
action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) }
|
||||
})
|
||||
}
|
||||
|
||||
const COMPACTION_RESUME_EVENT_TYPES = new Set([
|
||||
'message.delta',
|
||||
'message.interim',
|
||||
@@ -436,9 +389,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
nativeSubagentSessionsRef.current.delete(sessionId)
|
||||
// A fresh turn on this session optimistically clears its billing wall;
|
||||
// if credits are still exhausted the next failure re-raises it.
|
||||
clearBillingBlock(sessionId)
|
||||
|
||||
if (isActiveEvent) {
|
||||
triggerHaptic('streamStart')
|
||||
@@ -562,12 +512,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
|
||||
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
|
||||
|
||||
// Structured billing wall forwarded by the gateway (out of credits /
|
||||
// payment required) — cache it + raise a billing-specific toast.
|
||||
if (payload?.billing) {
|
||||
surfaceBillingBlock(sessionId, payload.billing)
|
||||
}
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(null)
|
||||
|
||||
@@ -806,21 +750,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
// Agent closed its own read-only tab via the desktop-gated close_terminal tool.
|
||||
// The process is untouched — this only drops the view.
|
||||
closeAgentTerminalByProc(payload?.process_id ?? '')
|
||||
} else if (event.type === 'pane.reveal') {
|
||||
// Agent revealed a pane via the desktop-gated focus_pane tool, in
|
||||
// response to an explicit user request. Active session only — a
|
||||
// background turn must never move the user's focus (desktop AGENTS.md:
|
||||
// offer, don't hijack).
|
||||
if (isActiveEvent) {
|
||||
revealDesktopPane(payload?.pane ?? '')
|
||||
}
|
||||
} else if (event.type === 'status.update') {
|
||||
if (sessionId && payload?.kind === 'compacting') {
|
||||
setSessionCompacting(sessionId, true)
|
||||
compactedTurnRef.current.add(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'compacted') {
|
||||
setSessionCompacting(sessionId, false)
|
||||
compactedTurnRef.current.delete(sessionId)
|
||||
} else if (sessionId && payload?.kind === 'process') {
|
||||
// The gateway's notification poller announces background process
|
||||
// completions / watch matches here — re-sync the status stack.
|
||||
|
||||
@@ -55,13 +55,6 @@ interface QueuedStreamDeltas {
|
||||
reasoning: string
|
||||
}
|
||||
|
||||
// Date.now() alone can collide when an interim seal and the next segment's
|
||||
// first delta land in the same millisecond — the new segment would then find
|
||||
// the sealed bubble by id and append into it instead of starting fresh.
|
||||
let streamMessageSeq = 0
|
||||
|
||||
const nextStreamMessageId = (prefix: string) => `${prefix}-${Date.now()}-${++streamMessageSeq}`
|
||||
|
||||
export function useMessageStream({
|
||||
activeGatewayProfile = 'default',
|
||||
activeSessionIdRef,
|
||||
@@ -98,7 +91,7 @@ export function useMessageStream({
|
||||
return state
|
||||
}
|
||||
|
||||
const streamId = state.streamId ?? nextStreamMessageId('assistant-stream')
|
||||
const streamId = state.streamId ?? `assistant-stream-${Date.now()}`
|
||||
const groupId = state.pendingBranchGroup ?? undefined
|
||||
const prev = state.messages
|
||||
let nextMessages: ChatMessage[]
|
||||
@@ -404,21 +397,19 @@ export function useMessageStream({
|
||||
let nextMessages = state.messages
|
||||
|
||||
if (streamId && nextMessages.some(m => m.id === streamId)) {
|
||||
// Seal the streaming bubble in place, marked interim so it renders
|
||||
// without an action footer (see ChatMessage.interim).
|
||||
// Finalize the existing streaming bubble in place
|
||||
nextMessages = nextMessages.map(m =>
|
||||
m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false, interim: true } : m
|
||||
m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false } : m
|
||||
)
|
||||
} else {
|
||||
// No streaming bubble — create a standalone interim message
|
||||
nextMessages = [
|
||||
...nextMessages,
|
||||
{
|
||||
id: nextStreamMessageId('assistant-interim'),
|
||||
id: `assistant-interim-${Date.now()}`,
|
||||
role: 'assistant' as const,
|
||||
parts: [assistantTextPart(authoritativeText)],
|
||||
pending: false,
|
||||
interim: true,
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
@@ -468,15 +459,19 @@ export function useMessageStream({
|
||||
return mergeFinalAssistantText(parts, visibleFinalText)
|
||||
}
|
||||
|
||||
// Settling the final response onto a bubble makes it the turn's real
|
||||
// reply — clear `interim` so it regains the action footer.
|
||||
const completeMessage = (message: ChatMessage): ChatMessage => {
|
||||
const settled = { ...message, pending: false, interim: false }
|
||||
|
||||
return completionError
|
||||
? { ...settled, error: completionError, parts: message.parts.filter(part => part.type !== 'text') }
|
||||
: { ...settled, parts: replaceTextPart(message.parts) }
|
||||
}
|
||||
const completeMessage = (message: ChatMessage): ChatMessage =>
|
||||
completionError
|
||||
? {
|
||||
...message,
|
||||
error: completionError,
|
||||
parts: message.parts.filter(part => part.type !== 'text'),
|
||||
pending: false
|
||||
}
|
||||
: {
|
||||
...message,
|
||||
parts: replaceTextPart(message.parts),
|
||||
pending: false
|
||||
}
|
||||
|
||||
const newAssistantFromCompletion = (): ChatMessage => ({
|
||||
id: `assistant-${Date.now()}`,
|
||||
|
||||
@@ -111,36 +111,6 @@ describe('useMessageStream interim text sealing', () => {
|
||||
expect(texts).toContain('All checks passed.')
|
||||
})
|
||||
|
||||
it('marks sealed interim bubbles interim and leaves the final reply unmarked', async () => {
|
||||
await mountStream()
|
||||
await start()
|
||||
|
||||
await delta('Let me check the files.')
|
||||
await interim('Let me check the files.')
|
||||
await delta('Now the second pass.')
|
||||
await interim('Now the second pass.')
|
||||
await complete('All done.')
|
||||
|
||||
const assistants = getState().messages.filter(m => m.role === 'assistant' && !m.hidden)
|
||||
const byText = (text: string) => assistants.find(m => chatMessageText(m) === text)
|
||||
|
||||
expect(byText('Let me check the files.')?.interim).toBe(true)
|
||||
expect(byText('Now the second pass.')?.interim).toBe(true)
|
||||
expect(byText('All done.')?.interim).toBeFalsy()
|
||||
})
|
||||
|
||||
it('clears the interim mark when a previewed final settles onto the interim bubble', async () => {
|
||||
await mountStream()
|
||||
await start()
|
||||
|
||||
await interim('same reply')
|
||||
await completePreviewed('same reply')
|
||||
|
||||
const assistants = getState().messages.filter(m => m.role === 'assistant' && !m.hidden)
|
||||
expect(assistants).toHaveLength(1)
|
||||
expect(assistants[0].interim).toBeFalsy()
|
||||
})
|
||||
|
||||
it('dedupes interim text when the final response includes it', async () => {
|
||||
await mountStream()
|
||||
await start()
|
||||
|
||||
@@ -120,46 +120,6 @@ describe('usePreviewRouting', () => {
|
||||
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('opens the preview pane on a preview.open event for the active session', async () => {
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
handleEvent = handler
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() =>
|
||||
handleEvent({
|
||||
payload: { url: 'https://www.cnn.com', label: 'CNN' },
|
||||
session_id: 'session-1',
|
||||
type: 'preview.open'
|
||||
})
|
||||
)
|
||||
|
||||
await waitFor(() => {
|
||||
expect($previewTarget.get()).toMatchObject({ kind: 'url', label: 'CNN', url: 'https://www.cnn.com' })
|
||||
})
|
||||
})
|
||||
|
||||
it('ignores a preview.open event for a background session', async () => {
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
onEvent={handler => {
|
||||
handleEvent = handler
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
act(() =>
|
||||
handleEvent({ payload: { url: 'https://www.cnn.com' }, session_id: 'other-session', type: 'preview.open' })
|
||||
)
|
||||
|
||||
// Give any (wrongly) scheduled async open a tick to resolve before asserting.
|
||||
await Promise.resolve()
|
||||
expect($previewTarget.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('does not auto-open a preview from tool results', async () => {
|
||||
render(
|
||||
<PreviewRoutingHarness
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
|
||||
import { type MutableRefObject, useCallback, useEffect } from 'react'
|
||||
|
||||
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
|
||||
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
|
||||
import {
|
||||
$previewTarget,
|
||||
$sessionPreviewRegistry,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
getSessionPreviewRecord,
|
||||
progressPreviewServerRestart,
|
||||
requestPreviewReload,
|
||||
setCurrentSessionPreviewTarget,
|
||||
setPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
@@ -103,30 +101,6 @@ export function usePreviewRouting({
|
||||
event => {
|
||||
baseHandleGatewayEvent(event)
|
||||
|
||||
if (event.type === 'preview.open') {
|
||||
// Agent-driven open in response to an explicit user request ("show
|
||||
// cnn.com in the preview pane"). Honor it only for the active session —
|
||||
// a background turn must not yank the pane open (see desktop AGENTS.md:
|
||||
// offer, don't hijack). Routes through the same normalizer as the file
|
||||
// browser so URLs, localhost, and file paths all resolve correctly.
|
||||
const { url, label } = asRecord(event.payload)
|
||||
const target = typeof url === 'string' ? url.trim() : ''
|
||||
|
||||
if (target && (!event.session_id || event.session_id === activeSessionIdRef.current)) {
|
||||
void normalizeOrLocalPreviewTarget(target, $currentCwd.get() || currentCwd || undefined).then(resolved => {
|
||||
if (resolved) {
|
||||
const trimmedLabel = typeof label === 'string' ? label.trim() : ''
|
||||
setCurrentSessionPreviewTarget(
|
||||
trimmedLabel ? { ...resolved, label: trimmedLabel } : resolved,
|
||||
'tool-result'
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (event.type === 'preview.restart.complete') {
|
||||
const { task_id, text } = asRecord(event.payload)
|
||||
|
||||
@@ -152,7 +126,7 @@ export function usePreviewRouting({
|
||||
requestPreviewReload()
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, baseHandleGatewayEvent, currentCwd]
|
||||
[activeSessionIdRef, baseHandleGatewayEvent]
|
||||
)
|
||||
|
||||
return { handleDesktopGatewayEvent, restartPreviewServer }
|
||||
|
||||
@@ -5,18 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { textPart } from '@/lib/chat-messages'
|
||||
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
import {
|
||||
$busy,
|
||||
$connection,
|
||||
$currentUsage,
|
||||
$messages,
|
||||
$sessions,
|
||||
$turnStartedAt,
|
||||
setCurrentUsage,
|
||||
setMessages,
|
||||
setSessions
|
||||
} from '@/store/session'
|
||||
import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import type { SubmitTextOptions } from './utils'
|
||||
@@ -72,10 +61,7 @@ interface HarnessHandle {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
cancelRun: () => Promise<void>
|
||||
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
|
||||
redirectPrompt: (text: string) => Promise<boolean>
|
||||
/** @deprecated Use `redirectPrompt`. */
|
||||
steerPrompt: (text: string) => Promise<boolean>
|
||||
submitTextRaw: (text: string, options?: SubmitTextOptions) => Promise<boolean>
|
||||
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>
|
||||
}
|
||||
|
||||
@@ -112,7 +98,7 @@ function Harness({
|
||||
onSeedState?: (state: Record<string, unknown>) => void
|
||||
openMemoryGraph?: () => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
seedMessages?: unknown[]
|
||||
selectedStoredSessionIdRef?: MutableRefObject<string | null>
|
||||
@@ -174,18 +160,14 @@ function Harness({
|
||||
act(async () => actions.cancelRun(...args)) as Promise<void>,
|
||||
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
|
||||
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
|
||||
redirectPrompt: (...args: Parameters<typeof actions.redirectPrompt>) =>
|
||||
act(async () => actions.redirectPrompt(...args)) as Promise<boolean>,
|
||||
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
|
||||
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
|
||||
submitTextRaw: actions.submitText,
|
||||
submitText: (...args: Parameters<typeof actions.submitText>) =>
|
||||
act(async () => actions.submitText(...args)) as Promise<boolean>
|
||||
})
|
||||
}, [
|
||||
actions.cancelRun,
|
||||
actions.restoreToMessage,
|
||||
actions.redirectPrompt,
|
||||
actions.steerPrompt,
|
||||
actions.submitText,
|
||||
activeSessionIdRef,
|
||||
@@ -296,513 +278,6 @@ describe('usePromptActions /title', () => {
|
||||
})
|
||||
})
|
||||
|
||||
// Helper: extract rendered text parts from captured updateSessionState seeds.
|
||||
function renderedSeedTexts(seeds: Record<string, unknown>[]): string[] {
|
||||
return seeds.flatMap(state => {
|
||||
const messages = Array.isArray(state.messages)
|
||||
? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)
|
||||
: []
|
||||
|
||||
return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
|
||||
})
|
||||
}
|
||||
|
||||
describe('usePromptActions /compress', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo()])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
clearNotifications()
|
||||
setCurrentUsage({ calls: 0, input: 0, output: 0, total: 0 })
|
||||
setMessages([])
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('routes through session.compress (not slash.exec) with a 120s timeout and renders the summary', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => {
|
||||
if (method === 'session.compress') {
|
||||
return {
|
||||
removed: 8,
|
||||
summary: {
|
||||
headline: 'Compressed: 234 → 226 messages',
|
||||
noop: false,
|
||||
token_line: 'Approx request size: ~285,727 → ~198,104 tokens'
|
||||
}
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/compress')
|
||||
|
||||
// The dedicated RPC is the TUI's path and has no slash-worker pipe
|
||||
// timeout — and the call carries an LLM-sized client timeout instead of
|
||||
// the 30s WS default, so a large session's compression can finish.
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'session.compress',
|
||||
expect.objectContaining({ session_id: RUNTIME_SESSION_ID }),
|
||||
120_000
|
||||
)
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
|
||||
})
|
||||
|
||||
it('replaces the transcript from the response messages', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => {
|
||||
if (method === 'session.compress') {
|
||||
return {
|
||||
removed: 2,
|
||||
summary: { headline: 'Compressed: 4 → 2 messages' },
|
||||
messages: [
|
||||
{ role: 'user', content: 'summarized context' },
|
||||
{ role: 'assistant', content: 'sure, here is the summary' }
|
||||
]
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/compress')
|
||||
|
||||
// The transcript was replaced with the post-compress history.
|
||||
const finalMessages = seeds[seeds.length - 1]?.messages as Array<{ parts?: Array<{ text?: string }> }>
|
||||
|
||||
const renderedText = (finalMessages ?? [])
|
||||
.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
|
||||
.join('\n')
|
||||
|
||||
expect(renderedText).toContain('summarized context')
|
||||
expect(renderedText).toContain('sure, here is the summary')
|
||||
})
|
||||
|
||||
it('uses the compute-host response transcript and success output', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.compress') {
|
||||
return {
|
||||
host_ack: { output: 'Compressed 4 → 2 messages' },
|
||||
messages: [
|
||||
{ role: 'user', content: 'compute-host summary' },
|
||||
{ role: 'assistant', content: 'compute-host answer' }
|
||||
]
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/compress')
|
||||
|
||||
const computeHostTexts = renderedSeedTexts(seeds)
|
||||
expect(computeHostTexts).toEqual(expect.arrayContaining(['compute-host summary', 'compute-host answer']))
|
||||
expect(computeHostTexts.some(text => text.includes('Compressed 4 → 2 messages'))).toBe(true)
|
||||
expect($notifications.get()).toEqual(
|
||||
expect.arrayContaining([expect.objectContaining({ message: 'Compressed 4 → 2 messages' })])
|
||||
)
|
||||
})
|
||||
|
||||
it('renders an aborted compression as an error, not a success', async () => {
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.compress') {
|
||||
return {
|
||||
status: 'aborted',
|
||||
summary: {
|
||||
aborted: true,
|
||||
headline: 'Compression aborted: 6 messages preserved',
|
||||
note: 'No compression provider is configured.'
|
||||
}
|
||||
} as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
await handle!.submitText('/compress')
|
||||
|
||||
expect($notifications.get()).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'error', message: expect.stringContaining('Compression aborted') })
|
||||
])
|
||||
)
|
||||
expect($notifications.get()).not.toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({ kind: 'success', message: expect.stringContaining('Compression aborted') })
|
||||
])
|
||||
)
|
||||
})
|
||||
|
||||
it('passes a focus topic through as focus_topic', async () => {
|
||||
const requestGateway = vi.fn(
|
||||
async (_method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => ({ removed: 0 }) as never
|
||||
)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
await handle!.submitText('/compress the auth refactor')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'session.compress',
|
||||
expect.objectContaining({ focus_topic: 'the auth refactor' }),
|
||||
120_000
|
||||
)
|
||||
})
|
||||
|
||||
it('surfaces the RPC error verbatim (e.g. the busy guard) instead of a routing error', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.compress') {
|
||||
throw new Error('session busy — /interrupt the current turn before /compress')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/compress')
|
||||
|
||||
const texts = renderedSeedTexts(seeds)
|
||||
expect(texts.some(text => text.includes('session busy'))).toBe(true)
|
||||
expect(texts.some(text => text.includes('not a quick/plugin/skill command'))).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to the slash worker when an older gateway lacks session.compress', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.compress') {
|
||||
throw new Error('method not found: session.compress')
|
||||
}
|
||||
|
||||
if (method === 'slash.exec') {
|
||||
return { output: 'compressed by legacy gateway' } as never
|
||||
}
|
||||
|
||||
throw new Error(`unexpected method: ${method}`)
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/compress')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'compress' }))
|
||||
expect(renderedSeedTexts(seeds).some(text => text.includes('compressed by legacy gateway'))).toBe(true)
|
||||
})
|
||||
|
||||
it('does not clobber the foreground transcript when compression resolves after a session switch', async () => {
|
||||
const RUNTIME_SESSION_B = 'rt-session-b'
|
||||
const storedSessionIdRef: MutableRefObject<string | null> = { current: 'stored-a' }
|
||||
const updates: Array<{ sessionId: string; storedSessionId: null | string | undefined }> = []
|
||||
|
||||
let resolveCompress: (value: unknown) => void = () => undefined
|
||||
|
||||
const compressResult = new Promise(resolve => {
|
||||
resolveCompress = resolve
|
||||
})
|
||||
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.compress') {
|
||||
return (await compressResult) as never
|
||||
}
|
||||
|
||||
throw new Error(`unexpected method: ${method}`)
|
||||
})
|
||||
|
||||
setMessages([{ id: 'foreground-b', parts: [textPart('session B transcript')], role: 'user', timestamp: 0 }])
|
||||
setCurrentUsage({ calls: 7, input: 70, output: 30, total: 100 })
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
onReady={h => (handle = h)}
|
||||
onUpdateState={(sessionId, storedSessionId) => updates.push({ sessionId, storedSessionId })}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
selectedStoredSessionIdRef={storedSessionIdRef}
|
||||
/>
|
||||
)
|
||||
|
||||
// Start the request in a synchronous act. The async `submitText` harness
|
||||
// helper cannot be used here: this test intentionally keeps its promise
|
||||
// pending while the user switches sessions, which would leave React's
|
||||
// async act scope open and overlap the wait below.
|
||||
let submitted: Promise<boolean>
|
||||
act(() => {
|
||||
submitted = handle!.submitTextRaw('/compress')
|
||||
})
|
||||
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.anything(), 120_000))
|
||||
|
||||
// Switch to session B before compression resolves.
|
||||
activeSessionIdRef.current = RUNTIME_SESSION_B
|
||||
resolveCompress({
|
||||
info: { usage: { context_used: 4_000, total: 12_000 } },
|
||||
messages: [{ content: 'compressed session A transcript', role: 'system' }],
|
||||
removed: 5
|
||||
})
|
||||
await act(async () => {
|
||||
await submitted
|
||||
})
|
||||
|
||||
// The foreground transcript + usage are unchanged — the late result only
|
||||
// updated session A's cache, not the active session B's.
|
||||
expect($messages.get()).toEqual([
|
||||
{ id: 'foreground-b', parts: [textPart('session B transcript')], role: 'user', timestamp: 0 }
|
||||
])
|
||||
expect($currentUsage.get()).toEqual(expect.objectContaining({ calls: 7, input: 70, output: 30, total: 100 }))
|
||||
expect(updates).toContainEqual({ sessionId: RUNTIME_SESSION_ID, storedSessionId: 'stored-a' })
|
||||
})
|
||||
|
||||
it('keeps a late compression error bound to its invocation-time stored session', async () => {
|
||||
const RUNTIME_SESSION_B = 'rt-session-b'
|
||||
const storedSessionIdRef: MutableRefObject<string | null> = { current: 'stored-a' }
|
||||
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
|
||||
const updates: Array<{ sessionId: string; storedSessionId: null | string | undefined }> = []
|
||||
let rejectCompress: (reason?: unknown) => void = () => undefined
|
||||
|
||||
const compressResult = new Promise((_, reject) => {
|
||||
rejectCompress = reject
|
||||
})
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.compress') {
|
||||
return (await compressResult) as never
|
||||
}
|
||||
|
||||
throw new Error(`unexpected method: ${method}`)
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
activeSessionIdRef={activeSessionIdRef}
|
||||
onReady={h => (handle = h)}
|
||||
onUpdateState={(sessionId, storedSessionId) => updates.push({ sessionId, storedSessionId })}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
selectedStoredSessionIdRef={storedSessionIdRef}
|
||||
/>
|
||||
)
|
||||
|
||||
// Keep the RPC pending while the selected stored session changes without
|
||||
// leaving an async React act scope open (see the foreground-race test).
|
||||
let submitted: Promise<boolean>
|
||||
act(() => {
|
||||
submitted = handle!.submitTextRaw('/compress')
|
||||
})
|
||||
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.anything(), 120_000))
|
||||
activeSessionIdRef.current = RUNTIME_SESSION_B
|
||||
storedSessionIdRef.current = 'stored-b'
|
||||
rejectCompress(new Error('compression failed'))
|
||||
await act(async () => {
|
||||
await submitted
|
||||
})
|
||||
|
||||
expect(updates).toContainEqual({ sessionId: RUNTIME_SESSION_ID, storedSessionId: 'stored-a' })
|
||||
})
|
||||
it('shows a compression progress toast outside the transcript', async () => {
|
||||
let resolveCompress: (value: unknown) => void = () => undefined
|
||||
|
||||
const compressResult = new Promise(resolve => {
|
||||
resolveCompress = resolve
|
||||
})
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.compress') {
|
||||
return (await compressResult) as never
|
||||
}
|
||||
|
||||
throw new Error(`unexpected method: ${method}`)
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
const submitted = handle!.submitTextRaw('/compress')
|
||||
await waitFor(() => expect($notifications.get().some(item => item.message === 'compressing context...')).toBe(true))
|
||||
resolveCompress({ messages: [{ content: 'compressed transcript', role: 'system' }] })
|
||||
await submitted
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions exec fallback error reporting', () => {
|
||||
beforeEach(() => {
|
||||
setSessions(() => [sessionInfo()])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('surfaces the slash.exec failure when command.dispatch only adds "not a quick/plugin/skill command"', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'slash.exec') {
|
||||
throw new Error('slash worker timed out')
|
||||
}
|
||||
|
||||
if (method === 'command.dispatch') {
|
||||
throw new Error('not a quick/plugin/skill command: debug')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
// /debug still goes through exec (no dedicated RPC), so it exercises the
|
||||
// slash.exec → command.dispatch fallback + error unmasking path.
|
||||
await handle!.submitText('/debug')
|
||||
|
||||
// The dispatch fallback knowing nothing about /debug is routing noise;
|
||||
// the worker timeout is what actually went wrong (#44456).
|
||||
const texts = renderedSeedTexts(seeds)
|
||||
expect(texts.some(text => text.includes('slash worker timed out'))).toBe(true)
|
||||
expect(texts.some(text => text.includes('not a quick/plugin/skill command'))).toBe(false)
|
||||
})
|
||||
|
||||
it('falls back to slash.exec when an older gateway lacks a dedicated RPC', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'session.status') {
|
||||
throw new Error('method not found: session.status')
|
||||
}
|
||||
|
||||
if (method === 'slash.exec') {
|
||||
return { output: 'session status from slash worker' } as never
|
||||
}
|
||||
|
||||
throw new Error(`unexpected method: ${method}`)
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/status')
|
||||
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.status', expect.anything(), undefined)
|
||||
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'status' }))
|
||||
expect(renderedSeedTexts(seeds).some(text => text.includes('session status from slash worker'))).toBe(true)
|
||||
})
|
||||
|
||||
it('still reports a real command.dispatch failure for skill/quick commands', async () => {
|
||||
const seeds: Record<string, unknown>[] = []
|
||||
|
||||
const requestGateway = vi.fn(async (method: string) => {
|
||||
if (method === 'slash.exec') {
|
||||
throw new Error('skill command: use command.dispatch for /my-skill')
|
||||
}
|
||||
|
||||
if (method === 'command.dispatch') {
|
||||
throw new Error('quick command failed with exit code 1')
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={s => seeds.push(s)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
await handle!.submitText('/my-skill')
|
||||
|
||||
const texts = renderedSeedTexts(seeds)
|
||||
expect(texts.some(text => text.includes('quick command failed with exit code 1'))).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions slash.exec dispatch payloads', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
@@ -1056,44 +531,6 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('flags prompt.submit with interrupted:true after a voice-playback barge', async () => {
|
||||
const { markVoicePlaybackInterrupted } = await import('@/lib/voice-playback')
|
||||
const requestGateway = vi.fn(async () => ({}) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
markVoicePlaybackInterrupted()
|
||||
await handle!.submitText('stop! rude interruption')
|
||||
|
||||
// The latch is one-shot: the flag rides this submit, the next is clean.
|
||||
expect(requestGateway).toHaveBeenCalledWith(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'stop! rude interruption',
|
||||
interrupted: true
|
||||
},
|
||||
1_800_000
|
||||
)
|
||||
|
||||
await handle!.submitText('follow-up without a barge')
|
||||
expect(requestGateway).toHaveBeenLastCalledWith(
|
||||
'prompt.submit',
|
||||
{
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'follow-up without a barge'
|
||||
},
|
||||
1_800_000
|
||||
)
|
||||
})
|
||||
|
||||
it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => {
|
||||
// busyRef lags $busy by one effect tick on the busy→false settle edge, so a
|
||||
// drained queue send would otherwise hit the busy guard and silently no-op.
|
||||
@@ -1266,41 +703,32 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions redirectPrompt', () => {
|
||||
describe('usePromptActions steerPrompt', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('redirects the live turn with trimmed correction text', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never)
|
||||
it('injects the trimmed text via session.steer and reports acceptance on a queued status', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
const capturedStates: Record<string, unknown>[] = []
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => capturedStates.push(state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
const accepted = await handle!.redirectPrompt(' nudge the run ')
|
||||
const accepted = await handle!.steerPrompt(' nudge the run ')
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.redirect', {
|
||||
// Steer never starts a turn — it rides the live run via session.steer only.
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.steer', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'nudge the run'
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
||||
expect((capturedStates.at(-1)?.messages as unknown[]).at(-1)).toMatchObject({
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'nudge the run' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('reports rejection so the caller queues when the turn already ended', async () => {
|
||||
it('reports rejection (so the caller queues) when the gateway has no live tool window', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
@@ -1308,12 +736,12 @@ describe('usePromptActions redirectPrompt', () => {
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.redirectPrompt('too late')).toBe(false)
|
||||
expect(await handle!.steerPrompt('too late')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports rejection without throwing when the redirect RPC errors', async () => {
|
||||
it('reports rejection (never throws) when the steer RPC errors', async () => {
|
||||
const requestGateway = vi.fn(async () => {
|
||||
throw new Error('agent does not support redirect')
|
||||
throw new Error('agent does not support steer')
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
@@ -1321,93 +749,20 @@ describe('usePromptActions redirectPrompt', () => {
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.redirectPrompt('boom')).toBe(false)
|
||||
expect(await handle!.steerPrompt('boom')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips the RPC entirely for empty text', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never)
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.redirectPrompt(' ')).toBe(false)
|
||||
expect(await handle!.steerPrompt(' ')).toBe(false)
|
||||
expect(requestGateway).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('accepts a queued redirect during the agent-build window and records the correction', async () => {
|
||||
// running=True but the agent is still building: the gateway queues the
|
||||
// correction instead of rejecting, so the composer must NOT re-queue it.
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
const capturedStates: Record<string, unknown>[] = []
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => capturedStates.push(state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
expect(await handle!.redirectPrompt('build-window nudge')).toBe(true)
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.redirect', {
|
||||
session_id: RUNTIME_SESSION_ID,
|
||||
text: 'build-window nudge'
|
||||
})
|
||||
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
|
||||
expect((capturedStates.at(-1)?.messages as unknown[]).at(-1)).toMatchObject({
|
||||
role: 'user',
|
||||
parts: [{ type: 'text', text: 'build-window nudge' }]
|
||||
})
|
||||
})
|
||||
|
||||
it('resumes the stored session and retries once when session.redirect reports "session not found"', async () => {
|
||||
const STORED_SESSION_ID = 'stored-db-xyz789'
|
||||
const RECOVERED_SESSION_ID = 'rt-recovered-456'
|
||||
const calls: { method: string; params?: Record<string, unknown> }[] = []
|
||||
let redirectAttempts = 0
|
||||
|
||||
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
|
||||
calls.push({ method, params })
|
||||
|
||||
if (method === 'session.redirect') {
|
||||
redirectAttempts += 1
|
||||
|
||||
if (redirectAttempts === 1) {
|
||||
throw new Error('session not found')
|
||||
}
|
||||
|
||||
return { status: 'redirected' } as never
|
||||
}
|
||||
|
||||
if (method === 'session.resume') {
|
||||
return { session_id: RECOVERED_SESSION_ID } as never
|
||||
}
|
||||
|
||||
return {} as never
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
storedSessionId={STORED_SESSION_ID}
|
||||
/>
|
||||
)
|
||||
await waitFor(() => expect(handle).not.toBeNull())
|
||||
|
||||
expect(await handle!.redirectPrompt('reconnect nudge')).toBe(true)
|
||||
expect(calls.map(c => c.method)).toEqual(['session.redirect', 'session.resume', 'session.redirect'])
|
||||
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID, text: 'reconnect nudge' })
|
||||
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
|
||||
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'reconnect nudge' })
|
||||
expect(handle!.activeSessionIdRef.current).toBe(RECOVERED_SESSION_ID)
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions restoreToMessage', () => {
|
||||
|
||||
@@ -41,7 +41,7 @@ import type {
|
||||
HandoffRequestResponse,
|
||||
HandoffStateResponse,
|
||||
ImageAttachResponse,
|
||||
SessionRedirectResponse
|
||||
SessionSteerResponse
|
||||
} from '../../../types'
|
||||
|
||||
import {
|
||||
@@ -220,13 +220,7 @@ export function usePromptActions({
|
||||
const copy = t.desktop
|
||||
|
||||
const appendSessionTextMessage = useCallback(
|
||||
(
|
||||
sessionId: string,
|
||||
role: ChatMessage['role'],
|
||||
text: string,
|
||||
storedSessionId?: string | null,
|
||||
options: { insertBeforeActiveReply?: boolean } = {}
|
||||
) => {
|
||||
(sessionId: string, role: ChatMessage['role'], text: string) => {
|
||||
// Strip ANSI: slash-command output from the backend worker carries SGR
|
||||
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
|
||||
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
|
||||
@@ -237,33 +231,21 @@ export function usePromptActions({
|
||||
return
|
||||
}
|
||||
|
||||
const messageId = `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
|
||||
|
||||
updateSessionState(
|
||||
sessionId,
|
||||
state => {
|
||||
const message: ChatMessage = {
|
||||
id: messageId,
|
||||
role,
|
||||
parts: [textPart(body)]
|
||||
}
|
||||
const streamIndex = options.insertBeforeActiveReply && state.streamId
|
||||
? state.messages.findIndex(candidate => candidate.id === state.streamId)
|
||||
: -1
|
||||
const lastAssistantIndex = options.insertBeforeActiveReply
|
||||
? state.messages.map(candidate => candidate.role).lastIndexOf('assistant')
|
||||
: -1
|
||||
const insertionIndex = streamIndex >= 0 ? streamIndex : lastAssistantIndex
|
||||
const messages = insertionIndex >= 0
|
||||
? [...state.messages.slice(0, insertionIndex), message, ...state.messages.slice(insertionIndex)]
|
||||
: [...state.messages, message]
|
||||
|
||||
return { ...state, messages }
|
||||
},
|
||||
storedSessionId ?? selectedStoredSessionIdRef.current
|
||||
state => ({
|
||||
...state,
|
||||
messages: [
|
||||
...state.messages,
|
||||
{
|
||||
id: `${role}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`,
|
||||
role,
|
||||
parts: [textPart(body)]
|
||||
}
|
||||
]
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
)
|
||||
|
||||
return messageId
|
||||
},
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
)
|
||||
@@ -496,10 +478,8 @@ export function usePromptActions({
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText,
|
||||
updateSessionState
|
||||
submitPromptText
|
||||
})
|
||||
|
||||
const submitText = useCallback(
|
||||
@@ -619,11 +599,11 @@ export function usePromptActions({
|
||||
}
|
||||
}, [activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState])
|
||||
|
||||
// The desktop steering action is an immediate correction: the core cancels
|
||||
// model generation and rebuilds the live turn with displayed reasoning and
|
||||
// completed work intact. During a tool it waits for the safe result boundary.
|
||||
// Returns false when the turn raced to completion so the composer can queue.
|
||||
const redirectPrompt = useCallback(
|
||||
// Steer = nudge the live turn without interrupting: the gateway appends the
|
||||
// text to the next tool result so the model reads it on its next iteration
|
||||
// (desktop parity with `/steer`). Returns false on reject (no live tool
|
||||
// window) so the caller can fall back to queueing the words for the next turn.
|
||||
const steerPrompt = useCallback(
|
||||
async (rawText: string): Promise<boolean> => {
|
||||
const text = sanitizeComposerInput(rawText).trim()
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
@@ -632,88 +612,25 @@ export function usePromptActions({
|
||||
return false
|
||||
}
|
||||
|
||||
// Accepted whether the live turn was redirected in place or queued for
|
||||
// the next turn (the build window, before the agent is wired) — either
|
||||
// way the correction reaches the model, so record it once as a real user
|
||||
// message after the interrupted checkpoint, matching the durable core
|
||||
// transcript rather than a system note that changes role after reload.
|
||||
const send = async (id: string): Promise<boolean> => {
|
||||
// Redirect aborts the model request, so the completion event can race
|
||||
// its RPC response. Insert before the live reply *before* awaiting the
|
||||
// gateway; appending after the response leaves the correction below a
|
||||
// reply that the redirect has already replaced.
|
||||
const messageId = appendSessionTextMessage(id, 'user', text, undefined, { insertBeforeActiveReply: true })
|
||||
const discardOptimisticMessage = () =>
|
||||
updateSessionState(id, state => ({
|
||||
...state,
|
||||
messages: state.messages.filter(message => message.id !== messageId)
|
||||
}))
|
||||
const moveOptimisticMessageToEnd = () =>
|
||||
updateSessionState(id, state => {
|
||||
const message = state.messages.find(candidate => candidate.id === messageId)
|
||||
|
||||
return message
|
||||
? { ...state, messages: [...state.messages.filter(candidate => candidate.id !== messageId), message] }
|
||||
: state
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SessionRedirectResponse>('session.redirect', { session_id: id, text })
|
||||
|
||||
if (result?.status === 'redirected') {
|
||||
triggerHaptic('submit')
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
if (result?.status === 'queued') {
|
||||
// Build-window redirects become the next turn, not part of the
|
||||
// active reply, so retain the optimistic row at the tail.
|
||||
moveOptimisticMessageToEnd()
|
||||
triggerHaptic('submit')
|
||||
|
||||
return true
|
||||
}
|
||||
} catch (err) {
|
||||
discardOptimisticMessage()
|
||||
throw err
|
||||
}
|
||||
|
||||
discardOptimisticMessage()
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
return await send(sessionId)
|
||||
} catch (err) {
|
||||
// A stale runtime id after reconnect 404s ("session not found"): resume
|
||||
// the stored session and retry once, mirroring stopPrompt so a
|
||||
// correction right after a reconnect isn't lost to the race.
|
||||
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
|
||||
try {
|
||||
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
|
||||
session_id: selectedStoredSessionIdRef.current,
|
||||
source: 'desktop'
|
||||
})
|
||||
const result = await requestGateway<SessionSteerResponse>('session.steer', { session_id: sessionId, text })
|
||||
|
||||
const recoveredId = resumed?.session_id
|
||||
if (result?.status === 'queued') {
|
||||
triggerHaptic('submit')
|
||||
// Inline note (not a toast) so the nudge lives in the transcript next
|
||||
// to the turn it steered. The `steer:` prefix is rendered as a codicon
|
||||
// row by SystemMessage (see STEER_NOTE_RE), same style as slash output.
|
||||
appendSessionTextMessage(sessionId, 'system', `steer:${text}`)
|
||||
|
||||
if (recoveredId) {
|
||||
activeSessionIdRef.current = recoveredId
|
||||
|
||||
return await send(recoveredId)
|
||||
}
|
||||
} catch {
|
||||
// fall through — caller queues so nothing is lost
|
||||
}
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
// Swallow — caller queues the text so nothing is lost.
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway, selectedStoredSessionIdRef, updateSessionState]
|
||||
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway]
|
||||
)
|
||||
|
||||
const reloadFromMessage = useCallback(
|
||||
@@ -889,9 +806,7 @@ export function usePromptActions({
|
||||
handoffSession,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
redirectPrompt,
|
||||
/** @deprecated Use `redirectPrompt` — this is an active-turn redirect, not tool steer. */
|
||||
steerPrompt: redirectPrompt,
|
||||
steerPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
}
|
||||
|
||||
@@ -1,23 +1,21 @@
|
||||
import { type MutableRefObject, useCallback, useRef } from 'react'
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
|
||||
import { getProfiles } from '@/hermes'
|
||||
import type { Translations } from '@/i18n'
|
||||
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
|
||||
import { type ChatMessage } from '@/lib/chat-messages'
|
||||
import { parseCommandDispatch, parseSlashCommand, sessionTitle } from '@/lib/chat-runtime'
|
||||
import {
|
||||
type CommandsCatalogLike,
|
||||
type DesktopActionId,
|
||||
type DesktopCommandSurface,
|
||||
type DesktopPickerId,
|
||||
desktopSlashUnavailableMessage,
|
||||
isDesktopSlashCommand,
|
||||
resolveDesktopCommand
|
||||
} from '@/lib/desktop-slash-commands'
|
||||
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
|
||||
import { setSessionYolo } from '@/lib/yolo-session'
|
||||
import { openCommandPalettePage } from '@/store/command-palette'
|
||||
import { setComposerDraft } from '@/store/composer'
|
||||
import { dismissNotification, notify, notifyError } from '@/store/notifications'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
|
||||
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
|
||||
@@ -25,35 +23,22 @@ import {
|
||||
$connection,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
setCurrentUsage,
|
||||
setModelPickerOpen,
|
||||
setSessionPickerOpen,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
import type {
|
||||
BrowserManageResponse,
|
||||
ClientSessionState,
|
||||
SessionCompressResponse,
|
||||
SessionTitleResponse,
|
||||
SlashExecResponse
|
||||
} from '../../../types'
|
||||
import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types'
|
||||
|
||||
import {
|
||||
type GatewayRequest,
|
||||
isSessionIdCandidate,
|
||||
renderCommandsCatalog,
|
||||
renderRpcResult,
|
||||
slashStatusText,
|
||||
type SubmitTextOptions
|
||||
} from './utils'
|
||||
|
||||
// Manual compression is LLM-bound and routinely outlives the desktop's 30s
|
||||
// default WS request timeout on large sessions — give it the TUI client's
|
||||
// 120s RPC budget (HERMES_TUI_RPC_TIMEOUT_MS default) instead.
|
||||
const SESSION_COMPRESS_TIMEOUT_MS = 120_000
|
||||
|
||||
/** Everything a slash handler needs about the invocation it's serving. */
|
||||
interface SlashActionCtx {
|
||||
arg: string
|
||||
@@ -65,12 +50,7 @@ interface SlashActionCtx {
|
||||
|
||||
interface SlashCommandDeps {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
appendSessionTextMessage: (
|
||||
sessionId: string,
|
||||
role: ChatMessage['role'],
|
||||
text: string,
|
||||
storedSessionId?: string | null
|
||||
) => void
|
||||
appendSessionTextMessage: (sessionId: string, role: ChatMessage['role'], text: string) => void
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
copy: Translations['desktop']
|
||||
@@ -84,14 +64,8 @@ interface SlashCommandDeps {
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: GatewayRequest
|
||||
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
|
||||
selectedStoredSessionIdRef: MutableRefObject<string | null>
|
||||
startFreshSessionDraft: () => void
|
||||
submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise<boolean>
|
||||
updateSessionState: (
|
||||
sessionId: string,
|
||||
updater: (state: ClientSessionState) => ClientSessionState,
|
||||
storedSessionId?: string | null
|
||||
) => ClientSessionState
|
||||
}
|
||||
|
||||
/** The /slash command dispatcher, extracted from usePromptActions. */
|
||||
@@ -109,14 +83,10 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText,
|
||||
updateSessionState
|
||||
submitPromptText
|
||||
} = deps
|
||||
|
||||
const compressInFlightRef = useRef(new Set<string>())
|
||||
|
||||
return useCallback(
|
||||
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
|
||||
const ensureSessionId = async (sessionHint?: string) =>
|
||||
@@ -127,7 +97,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
// build-renderSlashOutput boilerplate every exec-style handler repeats.
|
||||
const withSlashOutput = async (
|
||||
ctx: SlashActionCtx
|
||||
): Promise<{ render: (text: string) => void; sessionId: string; storedSessionId: string | null } | null> => {
|
||||
): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
|
||||
const sessionId = await ensureSessionId(ctx.sessionHint)
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -136,19 +106,10 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
return null
|
||||
}
|
||||
|
||||
// A long-running command can finish after a session switch. Keep its
|
||||
// output bound to the stored session selected at invocation time.
|
||||
const storedSessionId = selectedStoredSessionIdRef.current
|
||||
|
||||
const render = (text: string) =>
|
||||
appendSessionTextMessage(
|
||||
sessionId,
|
||||
'system',
|
||||
ctx.recordInput ? slashStatusText(ctx.command, text) : text,
|
||||
storedSessionId
|
||||
)
|
||||
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
|
||||
|
||||
return { render, sessionId, storedSessionId }
|
||||
return { render, sessionId }
|
||||
}
|
||||
|
||||
// `exec` commands (and unknown skill / quick commands the backend owns)
|
||||
@@ -170,8 +131,6 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
let slashExecError: unknown = null
|
||||
|
||||
const handleDispatch = async (
|
||||
dispatch: NonNullable<ReturnType<typeof parseCommandDispatch>>
|
||||
): Promise<void> => {
|
||||
@@ -247,11 +206,8 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
renderSlashOutput(output?.warning ? `warning: ${output.warning}\n${body}` : body)
|
||||
|
||||
return
|
||||
} catch (error) {
|
||||
// Fall back to command.dispatch for skill/send/alias directives, but
|
||||
// keep the worker error: a slash.exec worker timeout/crash is the real
|
||||
// failure, not the "not a quick/plugin/skill command" routing noise.
|
||||
slashExecError = error
|
||||
} catch {
|
||||
// Fall back to command.dispatch for skill/send/alias directives.
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -267,66 +223,6 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
|
||||
await handleDispatch(dispatch)
|
||||
} catch (err) {
|
||||
// "not a quick/plugin/skill command" just means the fallback had
|
||||
// nothing to add — the slash.exec failure (worker timeout, crash) is
|
||||
// the real error, so don't bury it under the routing noise.
|
||||
const dispatchMessage = err instanceof Error ? err.message : String(err)
|
||||
|
||||
if (slashExecError && /not a quick\/plugin\/skill command/i.test(dispatchMessage)) {
|
||||
const original = slashExecError instanceof Error ? slashExecError.message : String(slashExecError)
|
||||
renderSlashOutput(`error: /${name} failed: ${original}`)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
renderSlashOutput(`error: ${dispatchMessage}`)
|
||||
}
|
||||
}
|
||||
|
||||
// `rpc` commands have a dedicated gateway handler — skip slash.exec /
|
||||
// command.dispatch entirely. The response is structured (a JSON RPC
|
||||
// reply, not an inline text stream) so we render it via the generic
|
||||
// `renderRpcResult` shaper that knows the field conventions shared by
|
||||
// session.save / session.status / session.usage / session.steer /
|
||||
// process.stop / agents.list.
|
||||
async function runRpc(
|
||||
surface: Extract<DesktopCommandSurface, { kind: 'rpc' }>,
|
||||
ctx: SlashActionCtx
|
||||
): Promise<void> {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId } = resolved
|
||||
|
||||
try {
|
||||
const params = surface.buildParams({
|
||||
arg: ctx.arg,
|
||||
command: ctx.command,
|
||||
name: ctx.name,
|
||||
sessionId
|
||||
})
|
||||
|
||||
// Forward the surface's declared timeout when present; the default
|
||||
// requestGateway layer keeps (30s) is too tight for RPCs that do
|
||||
// real work.
|
||||
const result = await requestGateway<unknown>(surface.rpc, params, surface.timeoutMs)
|
||||
const body = renderRpcResult(result, ctx.name)
|
||||
|
||||
renderSlashOutput(body || `/${ctx.name}: no output`)
|
||||
} catch (err) {
|
||||
// TODO: remove this compatibility fallback once every supported
|
||||
// managed runtime exposes the dedicated RPC surface. Desktop and its
|
||||
// gateway update independently; older gateways still support the
|
||||
// slash-worker route.
|
||||
if (isMissingRpcMethod(err)) {
|
||||
await runExec(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
}
|
||||
}
|
||||
@@ -341,145 +237,6 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
branch: async () => {
|
||||
await branchCurrentSession()
|
||||
},
|
||||
// /compress (alias /compact) runs the gateway's dedicated
|
||||
// session.compress RPC — the TUI's path
|
||||
// (ui-tui/src/app/slash/commands/session.ts). It must NOT go through
|
||||
// runExec: compressing a large session outlives the slash worker's pipe
|
||||
// timeout (45s) and the desktop's 30s WS default, and the resulting
|
||||
// slash.exec error cascaded into command.dispatch's misleading "not a
|
||||
// quick/plugin/skill command: compress" (#44456).
|
||||
//
|
||||
// The RPC returns the post-compress `messages` array (same shape
|
||||
// session.resume returns), so we replace the transcript from it —
|
||||
// otherwise the summarized bubbles stay on screen forever (#44462
|
||||
// review). `updateSessionState` only publishes for the active runtime,
|
||||
// so a late result after a session switch refreshes its own cache
|
||||
// without clobbering the foreground transcript (#53755 review).
|
||||
compress: async ctx => {
|
||||
const resolved = await withSlashOutput(ctx)
|
||||
|
||||
if (!resolved) {
|
||||
return
|
||||
}
|
||||
|
||||
const { render: renderSlashOutput, sessionId, storedSessionId } = resolved
|
||||
const focusTopic = ctx.arg.trim()
|
||||
const noticeId = `session-compress:${sessionId}`
|
||||
|
||||
// Coalesce concurrent compress requests for the same session so a
|
||||
// double-enter doesn't fire two LLM summarise calls.
|
||||
if (compressInFlightRef.current.has(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
compressInFlightRef.current.add(sessionId)
|
||||
notify({
|
||||
durationMs: 0,
|
||||
id: noticeId,
|
||||
kind: 'info',
|
||||
message: focusTopic ? `compressing context for: ${focusTopic}` : 'compressing context...'
|
||||
})
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SessionCompressResponse>(
|
||||
'session.compress',
|
||||
{
|
||||
session_id: sessionId,
|
||||
...(focusTopic ? { focus_topic: focusTopic } : {})
|
||||
},
|
||||
SESSION_COMPRESS_TIMEOUT_MS
|
||||
)
|
||||
|
||||
// Replace the transcript with the post-compress history so the
|
||||
// summarized bubbles actually disappear. `messages` is the same
|
||||
// shape session.resume returns (_history_to_messages), so
|
||||
// toChatMessages handles it directly. updateSessionState only
|
||||
// publishes for the active runtime, guarding against a late result
|
||||
// clobbering the foreground after a session switch.
|
||||
if (Array.isArray(result?.messages)) {
|
||||
updateSessionState(
|
||||
sessionId,
|
||||
state => ({ ...state, messages: toChatMessages(result.messages!) }),
|
||||
storedSessionId
|
||||
)
|
||||
}
|
||||
|
||||
const usage = { ...result?.usage, ...result?.info?.usage }
|
||||
|
||||
if (Object.keys(usage).length && activeSessionIdRef.current === sessionId) {
|
||||
setCurrentUsage(current => ({ ...current, ...usage }))
|
||||
}
|
||||
|
||||
if (result?.info?.title !== undefined) {
|
||||
setSessions(prev =>
|
||||
prev.map(session =>
|
||||
session.id === sessionId ? { ...session, title: result.info!.title || null } : session
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
if (result?.summary?.headline) {
|
||||
const lines = [result.summary.headline, result.summary.token_line, result.summary.note].filter(
|
||||
(line): line is string => Boolean(line)
|
||||
)
|
||||
|
||||
const aborted = result.status === 'aborted' || result.summary.aborted === true
|
||||
|
||||
if (!aborted) {
|
||||
// Keep a durable record of a successful manual compression in
|
||||
// the chat after replacing it with the authoritative backend
|
||||
// transcript. Errors remain transient: appending an error as a
|
||||
// system message would look like a successful state change.
|
||||
renderSlashOutput(lines.join('\n'))
|
||||
}
|
||||
|
||||
notify({
|
||||
durationMs: 5_000,
|
||||
id: noticeId,
|
||||
kind: aborted ? 'error' : 'success',
|
||||
message: lines.join('\n')
|
||||
})
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const hostOutput = result?.host_ack?.output?.trim()
|
||||
|
||||
if (hostOutput) {
|
||||
renderSlashOutput(hostOutput)
|
||||
notify({ durationMs: 5_000, id: noticeId, kind: 'success', message: hostOutput })
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const removed = result?.removed ?? 0
|
||||
const message = removed > 0 ? `compressed ${removed} messages` : 'nothing to compress'
|
||||
renderSlashOutput(message)
|
||||
notify({
|
||||
durationMs: 5_000,
|
||||
id: noticeId,
|
||||
kind: 'success',
|
||||
message
|
||||
})
|
||||
} catch (err) {
|
||||
dismissNotification(noticeId)
|
||||
|
||||
// Desktop and gateway runtimes update independently. Preserve the
|
||||
// historical slash-worker path for an older gateway that has not
|
||||
// shipped session.compress yet; it cannot offer the longer RPC
|
||||
// timeout, but it must not turn a once-working command into an
|
||||
// immediate "method not found" error.
|
||||
if (isMissingRpcMethod(err)) {
|
||||
await runExec(ctx)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
|
||||
} finally {
|
||||
compressInFlightRef.current.delete(sessionId)
|
||||
}
|
||||
},
|
||||
// /yolo maps to the status-bar YOLO control — a per-session approval
|
||||
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
|
||||
// it locally; the session-create path applies it on the first message.
|
||||
@@ -850,9 +607,6 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
case 'action':
|
||||
return actionHandlers[surface.action](ctx)
|
||||
|
||||
case 'rpc':
|
||||
return runRpc(surface, ctx)
|
||||
|
||||
default:
|
||||
// exec spec, or an unknown skill / quick command the backend owns.
|
||||
return runExec(ctx)
|
||||
@@ -874,10 +628,8 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText,
|
||||
updateSessionState
|
||||
submitPromptText
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,12 +6,6 @@ import { type ChatMessage, textPart } from '@/lib/chat-messages'
|
||||
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
|
||||
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import {
|
||||
isVoicePlaybackActive,
|
||||
markVoicePlaybackInterrupted,
|
||||
stopVoicePlayback,
|
||||
takeVoicePlaybackInterrupted
|
||||
} from '@/lib/voice-playback'
|
||||
import {
|
||||
$composerAttachments,
|
||||
clearComposerAttachments,
|
||||
@@ -23,7 +17,6 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
|
||||
import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'
|
||||
|
||||
import type { ClientSessionState } from '../../../types'
|
||||
import { sessionContextDrift } from '../session-context-drift'
|
||||
|
||||
import {
|
||||
_submitInFlight,
|
||||
@@ -148,16 +141,6 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Typing barge-in: a new send silences any in-flight spoken reply.
|
||||
if (isVoicePlaybackActive()) {
|
||||
markVoicePlaybackInterrupted()
|
||||
stopVoicePlayback()
|
||||
}
|
||||
|
||||
// Barged mid-speech (here or via the voice loop's VAD)? Flag the submit
|
||||
// so the backend notes the interruption to the model.
|
||||
const interrupted = takeVoicePlaybackInterrupted()
|
||||
|
||||
// Queue drains carry their source session explicitly. A background drain
|
||||
// must never inherit the currently selected session after the user moves
|
||||
// to another chat.
|
||||
@@ -192,28 +175,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
|
||||
let startingRouteToken = getRouteToken()
|
||||
|
||||
// Reason string (or null) for why the session context genuinely drifted
|
||||
// under this in-flight submit. sessionContextDrift ignores the churn a
|
||||
// busy gateway produces (selection null-resets on a gateway/profile
|
||||
// switch, search/hash-only route changes, background active-ref
|
||||
// retargets) so a second-session send doesn't silently abort — it fires
|
||||
// only on a real move to a DIFFERENT chat. Reads the live refs/route each
|
||||
// call and measures against the (mutable) baseline, which is re-pinned to
|
||||
// the created chat after createBackendSessionForSend. submitTargetStoredId
|
||||
// is the stored session this submit targets, so a move ONTO it (the
|
||||
// pipeline's own re-home) is never counted as drift.
|
||||
const sessionDriftReason = (): string | null =>
|
||||
targetStartedInCurrentView
|
||||
? sessionContextDrift({
|
||||
startRouteToken: startingRouteToken,
|
||||
nowRouteToken: getRouteToken(),
|
||||
startSelectedStoredId: startingStoredSessionId,
|
||||
nowSelectedStoredId: selectedStoredSessionIdRef.current,
|
||||
submitTargetStoredId: startingStoredSessionId
|
||||
})
|
||||
: null
|
||||
const sessionContextDrifted = (): boolean =>
|
||||
targetStartedInCurrentView &&
|
||||
(selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken)
|
||||
|
||||
const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionDriftReason()
|
||||
const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionContextDrifted()
|
||||
|
||||
// One submit in flight per session — drop any concurrent re-fire so a
|
||||
// stalled turn can't stack the same prompt into multiple real turns. The
|
||||
@@ -351,11 +317,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
return abortForSessionSwitch(null)
|
||||
}
|
||||
|
||||
const routedResumeDrift = sessionDriftReason()
|
||||
|
||||
if (routedResumeDrift) {
|
||||
console.warn('[submit-drift-abort]', routedResumeDrift, { phase: 'post-routed-resume' })
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
return abortForSessionSwitch(null)
|
||||
}
|
||||
|
||||
@@ -389,11 +351,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
source: 'desktop'
|
||||
})
|
||||
|
||||
const resumeDrift = sessionDriftReason()
|
||||
|
||||
if (resumeDrift) {
|
||||
console.warn('[submit-drift-abort]', resumeDrift, { phase: 'post-resume' })
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
return abortForSessionSwitch(sessionId)
|
||||
}
|
||||
|
||||
@@ -413,11 +371,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
return abortForSessionSwitch(null)
|
||||
}
|
||||
|
||||
const resumeSettleDrift = sessionDriftReason()
|
||||
|
||||
if (resumeSettleDrift) {
|
||||
console.warn('[submit-drift-abort]', resumeSettleDrift, { phase: 'post-resume-settle' })
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
return abortForSessionSwitch(sessionId)
|
||||
}
|
||||
|
||||
@@ -446,11 +400,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
// createBackendSessionForSend returns null when the user switched
|
||||
// sessions mid-create (it closes the orphaned session itself) —
|
||||
// abort silently. Anything else is a real failure worth a toast.
|
||||
const createNullDrift = sessionDriftReason()
|
||||
|
||||
if (createNullDrift) {
|
||||
console.warn('[submit-drift-abort]', createNullDrift, { phase: 'post-create-null' })
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
return abortForSessionSwitch(null)
|
||||
}
|
||||
|
||||
@@ -489,11 +439,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
updateComposerAttachments: usingComposerAttachments
|
||||
})
|
||||
|
||||
const attachmentsDrift = sessionDriftReason()
|
||||
|
||||
if (attachmentsDrift) {
|
||||
console.warn('[submit-drift-abort]', attachmentsDrift, { phase: 'post-attachments' })
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
return abortForSessionSwitch(sessionId)
|
||||
}
|
||||
|
||||
@@ -504,12 +450,6 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
rewriteOptimistic(sessionId)
|
||||
const text = buildContextText(syncedAttachments)
|
||||
|
||||
const submitParams = (targetId: string) => ({
|
||||
session_id: targetId,
|
||||
text,
|
||||
...(interrupted && { interrupted })
|
||||
})
|
||||
|
||||
// On sleep/wake the gateway's in-memory session may have been cleared
|
||||
// while the desktop app still holds the old session ID. Detect this,
|
||||
// resume the stored session to re-register it, and retry once.
|
||||
@@ -517,7 +457,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
|
||||
try {
|
||||
await withSessionBusyRetry(() =>
|
||||
requestGateway('prompt.submit', submitParams(sessionId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
)
|
||||
} catch (firstErr) {
|
||||
const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current
|
||||
@@ -533,11 +473,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
source: 'desktop'
|
||||
})
|
||||
|
||||
const resumeRetryDrift = sessionDriftReason()
|
||||
|
||||
if (resumeRetryDrift) {
|
||||
console.warn('[submit-drift-abort]', resumeRetryDrift, { phase: 'post-resume-retry' })
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
return abortForSessionSwitch(sessionId)
|
||||
}
|
||||
|
||||
@@ -549,7 +485,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
}
|
||||
|
||||
await withSessionBusyRetry(() =>
|
||||
requestGateway('prompt.submit', submitParams(recoveredId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
)
|
||||
} else {
|
||||
submitErr = firstErr
|
||||
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
isSessionBusyError,
|
||||
isSessionIdCandidate,
|
||||
isSessionNotFoundError,
|
||||
renderRpcResult,
|
||||
slashStatusText,
|
||||
visibleUserIndexAtOrdinal,
|
||||
visibleUserOrdinal
|
||||
@@ -126,139 +125,3 @@ describe('visible user ordinals', () => {
|
||||
expect(visibleUserIndexAtOrdinal(messages, 5)).toBe(-1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('renderRpcResult', () => {
|
||||
describe('session.compress (summary shape)', () => {
|
||||
it('renders the summary headline with token line and note', () => {
|
||||
expect(
|
||||
renderRpcResult(
|
||||
{
|
||||
summary: {
|
||||
headline: 'Compressed: 280 → 120 messages',
|
||||
token_line: 'Approx request size: ~126,575 → ~30,000 tokens',
|
||||
note: 'Removed 8 older turns',
|
||||
noop: false
|
||||
}
|
||||
},
|
||||
'compress'
|
||||
)
|
||||
).toBe(
|
||||
[
|
||||
'✓ Compressed: 280 → 120 messages',
|
||||
' Approx request size: ~126,575 → ~30,000 tokens',
|
||||
' Removed 8 older turns'
|
||||
].join('\n')
|
||||
)
|
||||
})
|
||||
|
||||
it('drops the checkmark when the summary is a noop', () => {
|
||||
expect(
|
||||
renderRpcResult(
|
||||
{ summary: { headline: 'Already compressed', note: 'No new turns since last compress', noop: true } },
|
||||
'compress'
|
||||
)
|
||||
).toBe('Already compressed\n No new turns since last compress')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.steer', () => {
|
||||
it('reports a queued steer with the original text', () => {
|
||||
expect(renderRpcResult({ status: 'queued', text: 'skip the docs' }, 'steer')).toBe(
|
||||
'Steered · "skip the docs" queued for next tool call'
|
||||
)
|
||||
})
|
||||
|
||||
it('reports a rejected steer without echoing user text', () => {
|
||||
expect(renderRpcResult({ status: 'rejected', text: 'whatever' }, 'steer')).toBe(
|
||||
'Steer rejected — agent declined input'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('process.stop', () => {
|
||||
it('reports the numeric number of stopped processes', () => {
|
||||
expect(renderRpcResult({ killed: 2 }, 'stop')).toBe('Stopped 2 background processes.')
|
||||
})
|
||||
|
||||
it('reports nothing-to-stop when the numeric count is zero', () => {
|
||||
expect(renderRpcResult({ killed: 0 }, 'stop')).toBe('No background processes to stop.')
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.save', () => {
|
||||
it('echoes the saved file path', () => {
|
||||
expect(renderRpcResult({ file: '/home/user/.hermes/sessions/saved/x.json' }, 'save')).toBe(
|
||||
'Saved transcript to /home/user/.hermes/sessions/saved/x.json'
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.status', () => {
|
||||
it('passes through the multi-line plain-text output verbatim', () => {
|
||||
const output = 'Hermes TUI Status\n\nSession ID: s-1\nModel: nous-hermes-3 (unknown)'
|
||||
expect(renderRpcResult({ output }, 'status')).toBe(output)
|
||||
})
|
||||
})
|
||||
|
||||
describe('session.usage', () => {
|
||||
it('formats calls / input / output / total with thousands separators', () => {
|
||||
expect(renderRpcResult({ calls: 12, input: 1_234_567, output: 89_012, total: 1_323_579 }, 'usage')).toBe(
|
||||
'Usage: 12 calls · 1,234,567 in / 89,012 out · 1,323,579 total'
|
||||
)
|
||||
})
|
||||
|
||||
it('appends credits_lines when present', () => {
|
||||
const body = renderRpcResult(
|
||||
{
|
||||
calls: 1,
|
||||
input: 10,
|
||||
output: 20,
|
||||
total: 30,
|
||||
credits_lines: ['Nous credits: 8,420 remaining', 'Resets: 2026-08-01']
|
||||
},
|
||||
'usage'
|
||||
)
|
||||
|
||||
expect(body.split('\n')).toEqual([
|
||||
'Usage: 1 calls · 10 in / 20 out · 30 total',
|
||||
'Nous credits: 8,420 remaining',
|
||||
'Resets: 2026-08-01'
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('agents.list', () => {
|
||||
it('reports no running tasks when the array is empty', () => {
|
||||
expect(renderRpcResult({ processes: [] }, 'agents')).toBe('No background tasks running.')
|
||||
})
|
||||
|
||||
it('formats each process with status, command, and metadata', () => {
|
||||
expect(
|
||||
renderRpcResult(
|
||||
{
|
||||
processes: [
|
||||
{ session_id: 's-1', command: 'npm test', status: 'running', uptime: 42 },
|
||||
{ session_id: 's-2', command: 'vitest', status: 'completed' }
|
||||
]
|
||||
},
|
||||
'agents'
|
||||
)
|
||||
).toBe(['• [running] npm test (42s · s-1)', '• [completed] vitest (s-2)'].join('\n'))
|
||||
})
|
||||
})
|
||||
|
||||
describe('fallback', () => {
|
||||
it('serialises unknown shapes as JSON so we never lose data', () => {
|
||||
expect(renderRpcResult({ custom: 'value', nested: { a: 1 } }, 'mystery')).toBe(
|
||||
'/mystery: {"custom":"value","nested":{"a":1}}'
|
||||
)
|
||||
})
|
||||
|
||||
it('returns an empty string for null and primitive payloads', () => {
|
||||
expect(renderRpcResult(null, 'x')).toBe('')
|
||||
expect(renderRpcResult(undefined, 'x')).toBe('')
|
||||
expect(renderRpcResult('plain string', 'x')).toBe('')
|
||||
expect(renderRpcResult(42, 'x')).toBe('')
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -192,140 +192,6 @@ export function slashStatusText(command: string, output: string): string {
|
||||
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format the JSON reply from a gateway RPC surfaced as `kind: 'rpc'` in
|
||||
* desktop-slash-commands.ts. Kept here (instead of slash.ts) so it has no
|
||||
* React / store dependencies and can be unit-tested in isolation.
|
||||
*
|
||||
* The renderer follows the field conventions shared by the gateway handlers
|
||||
* we route to today:
|
||||
* - `session.compress`: { summary: { headline, token_line, note, noop } }
|
||||
* (only used if /compress ever moves to `rpc`; it's an `action` today
|
||||
* because it needs transcript replacement)
|
||||
* - `session.status`: { output: "<multi-line plain text>" }
|
||||
* - `session.save`: { file: "<absolute path>" }
|
||||
* - `session.usage`: { calls, input, output, total, credits_lines? }
|
||||
* - `session.steer`: { status: 'queued' | 'rejected', text }
|
||||
* - `process.stop`: { killed: boolean }
|
||||
* - `agents.list`: { processes: [{ session_id, command, status, uptime }] }
|
||||
*
|
||||
* Any RPC whose response doesn't match these shapes falls through to a
|
||||
* JSON.stringify dump so we never silently swallow data.
|
||||
*/
|
||||
export function renderRpcResult(response: unknown, name: string): string {
|
||||
if (!response || typeof response !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const r = response as Record<string, unknown>
|
||||
|
||||
const summary = r.summary as { headline?: string; token_line?: string; note?: string; noop?: boolean } | undefined
|
||||
|
||||
if (summary && typeof summary === 'object' && typeof summary.headline === 'string' && summary.headline) {
|
||||
const lines: string[] = [`${summary.noop ? '' : '✓ '}${summary.headline}`]
|
||||
|
||||
if (summary.token_line) {
|
||||
lines.push(` ${summary.token_line}`)
|
||||
}
|
||||
|
||||
if (summary.note) {
|
||||
lines.push(` ${summary.note}`)
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// session.steer — { status: 'queued' | 'rejected', text }
|
||||
if (r.status === 'queued' || r.status === 'rejected') {
|
||||
const text = typeof r.text === 'string' ? r.text : ''
|
||||
|
||||
if (r.status === 'queued') {
|
||||
return text ? `Steered · "${text}" queued for next tool call` : 'Steered next tool call'
|
||||
}
|
||||
|
||||
return 'Steer rejected — agent declined input'
|
||||
}
|
||||
|
||||
// process.stop — { killed: number }
|
||||
if ('killed' in r && typeof r.killed === 'number') {
|
||||
return r.killed > 0
|
||||
? `Stopped ${r.killed} background process${r.killed === 1 ? '' : 'es'}.`
|
||||
: 'No background processes to stop.'
|
||||
}
|
||||
|
||||
// session.save — { file }
|
||||
if (typeof r.file === 'string' && r.file) {
|
||||
return `Saved transcript to ${r.file}`
|
||||
}
|
||||
|
||||
// session.status — { output }
|
||||
if (typeof r.output === 'string' && r.output) {
|
||||
return r.output
|
||||
}
|
||||
|
||||
// session.usage — { calls, input, output, total, credits_lines? }
|
||||
if ('total' in r || 'input' in r || 'output' in r || 'calls' in r) {
|
||||
const calls = Number(r.calls ?? 0)
|
||||
const input = Number(r.input ?? 0)
|
||||
const output = Number(r.output ?? 0)
|
||||
const total = Number(r.total ?? 0)
|
||||
|
||||
const lines: string[] = [
|
||||
`Usage: ${calls.toLocaleString()} calls · ${input.toLocaleString()} in / ${output.toLocaleString()} out · ${total.toLocaleString()} total`
|
||||
]
|
||||
|
||||
if (Array.isArray(r.credits_lines)) {
|
||||
for (const credit of r.credits_lines) {
|
||||
if (typeof credit === 'string' && credit.trim()) {
|
||||
lines.push(credit.trim())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
// agents.list — { processes: [{ session_id, command, status, uptime }] }
|
||||
if (Array.isArray(r.processes)) {
|
||||
if (r.processes.length === 0) {
|
||||
return 'No background tasks running.'
|
||||
}
|
||||
|
||||
return r.processes
|
||||
.map(p => {
|
||||
if (!p || typeof p !== 'object') {
|
||||
return ''
|
||||
}
|
||||
|
||||
const proc = p as Record<string, unknown>
|
||||
const status = typeof proc.status === 'string' ? proc.status : 'unknown'
|
||||
const command = typeof proc.command === 'string' ? proc.command : ''
|
||||
const sessionId = typeof proc.session_id === 'string' ? proc.session_id : ''
|
||||
const uptime = proc.uptime
|
||||
|
||||
const meta: string[] = []
|
||||
|
||||
if (typeof uptime === 'number' && uptime >= 0) {
|
||||
meta.push(`${uptime}s`)
|
||||
}
|
||||
|
||||
if (sessionId) {
|
||||
meta.push(sessionId)
|
||||
}
|
||||
|
||||
const tail = meta.length ? ` (${meta.join(' · ')})` : ''
|
||||
|
||||
return `• [${status}] ${command}${tail}`
|
||||
})
|
||||
.filter(Boolean)
|
||||
.join('\n')
|
||||
}
|
||||
|
||||
// Generic fallback — keeps us honest if the gateway adds new fields we
|
||||
// haven't shaped yet.
|
||||
return `/${name}: ${JSON.stringify(r)}`
|
||||
}
|
||||
|
||||
export function appendText(message: AppendMessage): string {
|
||||
return message.content
|
||||
.map(part => ('text' in part ? part.text : ''))
|
||||
|
||||
@@ -60,11 +60,10 @@ import {
|
||||
} from '@/store/session-states'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
|
||||
import type { ClientSessionState, SidebarNavItem } from '../../../types'
|
||||
import { sessionContextDrift } from '../session-context-drift'
|
||||
|
||||
import {
|
||||
appendLiveSessionProjection,
|
||||
@@ -340,6 +339,7 @@ export function useSessionActions({
|
||||
|
||||
const createBackendSessionForSend = useCallback(
|
||||
async (preview: string | null = null): Promise<string | null> => {
|
||||
const startingActiveSessionId = activeSessionIdRef.current
|
||||
const startingStoredSessionId = selectedStoredSessionIdRef.current
|
||||
const startingRouteToken = getRouteToken()
|
||||
|
||||
@@ -362,24 +362,11 @@ export function useSessionActions({
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', params)
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
// Only a genuine move to a DIFFERENT chat mid-create should orphan the
|
||||
// session we just minted. The active runtime ref is deliberately not a
|
||||
// prong: background gateway events retarget it while other sessions
|
||||
// stream (#47709 class), and the seconds-long session.create round-trip
|
||||
// (server-side agent + MCP init) makes that churn near-certain — every
|
||||
// genuine user switch retargets selection AND route synchronously
|
||||
// anyway. submitTargetStoredId is the just-created stored session, so
|
||||
// our own upcoming re-home onto it never reads as drift.
|
||||
const drift = sessionContextDrift({
|
||||
startRouteToken: startingRouteToken,
|
||||
nowRouteToken: getRouteToken(),
|
||||
startSelectedStoredId: startingStoredSessionId,
|
||||
nowSelectedStoredId: selectedStoredSessionIdRef.current,
|
||||
submitTargetStoredId: stored
|
||||
})
|
||||
|
||||
if (drift) {
|
||||
console.warn('[submit-drift-abort]', drift, { phase: 'mid-create' })
|
||||
if (
|
||||
activeSessionIdRef.current !== startingActiveSessionId ||
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
|
||||
getRouteToken() !== startingRouteToken
|
||||
) {
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
|
||||
return null
|
||||
@@ -778,9 +765,8 @@ export function useSessionActions({
|
||||
setMessages([])
|
||||
}
|
||||
|
||||
// A history load is not a live turn. Toggling busy here and again in the
|
||||
// finally block re-renders the thread viewport after it has loaded.
|
||||
busyRef.current = true
|
||||
setBusy(true)
|
||||
setAwaitingResponse(false)
|
||||
clearNotifications()
|
||||
setSelectedStoredSessionId(storedSessionId)
|
||||
@@ -806,6 +792,7 @@ export function useSessionActions({
|
||||
: $messages.get()
|
||||
|
||||
let prefetchApplied = false
|
||||
let prefetchedMessageCount = 0
|
||||
let prefetchedStoredSessionId: string | null = null
|
||||
|
||||
// REST transcript prefetch and the gateway resume RPC are independent
|
||||
@@ -832,14 +819,24 @@ export function useSessionActions({
|
||||
// keeps it from surfacing as unhandled while the prefetch settles.
|
||||
resumePromise.catch(() => undefined)
|
||||
|
||||
// Keep both requests concurrent, but do not paint the REST result until
|
||||
// the runtime resume has also settled. An eager prefetch paint followed
|
||||
// by the runtime projection rebuilds large transcripts during resume.
|
||||
let prefetchedResult: { messages: SessionMessage[]; session_id?: string } | null = null
|
||||
|
||||
try {
|
||||
if (prefetchPromise) {
|
||||
prefetchedResult = await prefetchPromise
|
||||
const storedMessages = await prefetchPromise
|
||||
|
||||
if (isCurrentResume()) {
|
||||
const previousMessages = resumedSameSelectedSession
|
||||
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
|
||||
: $messages.get()
|
||||
|
||||
localSnapshot = reconcileAuthoritativeMessages(storedMessages.messages, previousMessages)
|
||||
prefetchApplied = true
|
||||
prefetchedMessageCount = storedMessages.messages.length
|
||||
prefetchedStoredSessionId = storedMessages.session_id || storedSessionId
|
||||
|
||||
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
|
||||
setMessages(localSnapshot)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: gateway resume below can still hydrate the session.
|
||||
@@ -851,16 +848,6 @@ export function useSessionActions({
|
||||
return
|
||||
}
|
||||
|
||||
if (prefetchedResult) {
|
||||
const previousMessages = resumedSameSelectedSession
|
||||
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
|
||||
: $messages.get()
|
||||
|
||||
localSnapshot = reconcileAuthoritativeMessages(prefetchedResult.messages, previousMessages)
|
||||
prefetchApplied = true
|
||||
prefetchedStoredSessionId = prefetchedResult.session_id || storedSessionId
|
||||
}
|
||||
|
||||
const currentMessages = $messages.get()
|
||||
|
||||
// Keep the local snapshot when resume would only reshuffle runtime
|
||||
@@ -876,7 +863,10 @@ export function useSessionActions({
|
||||
const hasLiveProjection = Boolean(resumed.inflight || resumed.queued)
|
||||
|
||||
const preferredMessages =
|
||||
prefetchApplied && prefetchMatchesResumedSession && !hasLiveProjection
|
||||
prefetchApplied &&
|
||||
prefetchMatchesResumedSession &&
|
||||
!hasLiveProjection &&
|
||||
resumed.messages.length <= prefetchedMessageCount
|
||||
? localSnapshot
|
||||
: (() => {
|
||||
const previousMessages = resumedSameSelectedSession
|
||||
@@ -924,14 +914,6 @@ export function useSessionActions({
|
||||
}),
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
// updateSessionState stages its view sync through requestAnimationFrame.
|
||||
// Commit the final, already-reconciled transcript now so resume has one
|
||||
// additive DOM build instead of an eager prefetch build plus a later
|
||||
// runtime projection build.
|
||||
if (!chatMessageArraysEquivalent($messages.get(), messagesForView)) {
|
||||
setMessages(messagesForView)
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCurrentResume()) {
|
||||
return
|
||||
|
||||
@@ -327,42 +327,6 @@ describe('preserveLocalPendingTurnMessages', () => {
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
|
||||
})
|
||||
|
||||
it('drops stale optimistic history after compression and keeps only the live tail', () => {
|
||||
const compressedAuthority = [
|
||||
msg('stored-user', 'user', 'first turn that survived compression'),
|
||||
msg('stored-assistant', 'assistant', 'latest authoritative reply')
|
||||
]
|
||||
|
||||
const pollutedWarmCache = [
|
||||
msg('user-old-1', 'user', 'compressed-away prompt one'),
|
||||
msg('assistant-old-1', 'assistant', 'compressed-away reply one'),
|
||||
msg('user-old-2', 'user', 'compressed-away prompt two'),
|
||||
msg('assistant-old-2', 'assistant', 'compressed-away reply two'),
|
||||
msg('user-inflight', 'user', 'the one genuinely in-flight prompt')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache).map(message => message.id)).toEqual([
|
||||
'stored-user',
|
||||
'stored-assistant',
|
||||
'user-inflight'
|
||||
])
|
||||
})
|
||||
|
||||
it('drops the live tail once the latest authoritative user has persisted it after compression', () => {
|
||||
const compressedAuthority = [
|
||||
msg('stored-user', 'user', 'the one genuinely in-flight prompt'),
|
||||
msg('stored-assistant', 'assistant', 'its authoritative reply')
|
||||
]
|
||||
|
||||
const pollutedWarmCache = [
|
||||
msg('user-old-1', 'user', 'compressed-away prompt one'),
|
||||
msg('assistant-old-1', 'assistant', 'compressed-away reply one'),
|
||||
msg('user-inflight', 'user', 'the one genuinely in-flight prompt')
|
||||
]
|
||||
|
||||
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority)
|
||||
})
|
||||
})
|
||||
|
||||
describe('appendLiveSessionProjection', () => {
|
||||
@@ -390,32 +354,6 @@ describe('appendLiveSessionProjection', () => {
|
||||
expect(restored[3]).toMatchObject({ id: 'assistant-stream-runtime-1', pending: true })
|
||||
})
|
||||
|
||||
it('does not duplicate a persisted inflight user after consecutive canceled user turns', () => {
|
||||
const stored = [
|
||||
msg('stored-user-1', 'user', 'canceled prompt one'),
|
||||
msg('stored-user-2', 'user', 'canceled prompt two'),
|
||||
msg('stored-user-3', 'user', 'current running prompt')
|
||||
]
|
||||
|
||||
const restored = appendLiveSessionProjection(stored, {
|
||||
session_id: 'runtime-1',
|
||||
inflight: {
|
||||
user: 'current running prompt',
|
||||
assistant: 'partial answer',
|
||||
streaming: true
|
||||
}
|
||||
})
|
||||
|
||||
expect(restored.map(message => message.role)).toEqual(['user', 'user', 'user', 'assistant'])
|
||||
expect(restored.map(message => message.parts.map(part => ('text' in part ? part.text : '')).join(''))).toEqual([
|
||||
'canceled prompt one',
|
||||
'canceled prompt two',
|
||||
'current running prompt',
|
||||
'partial answer'
|
||||
])
|
||||
expect(restored[3]).toMatchObject({ id: 'assistant-stream-runtime-1', pending: true })
|
||||
})
|
||||
|
||||
it('preserves the original array when no live projection exists', () => {
|
||||
const stored = [msg('stored-user', 'user', 'earlier')]
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ const _chatMessageFieldsExhaustive: {
|
||||
[K in Exclude<keyof ChatMessage, (typeof COMPARED_FIELDS)[number] | (typeof IGNORED_FIELDS)[number]>]: never
|
||||
} = {}
|
||||
|
||||
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const
|
||||
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId'] as const
|
||||
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const
|
||||
|
||||
// Compile-time check: every ChatMessagePart discriminant must be handled by
|
||||
@@ -154,10 +154,7 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean
|
||||
a.pending !== b.pending ||
|
||||
a.error !== b.error ||
|
||||
a.hidden !== b.hidden ||
|
||||
a.branchGroupId !== b.branchGroupId ||
|
||||
// Interim gates the action footer, so flipping it must repaint (e.g. a
|
||||
// previewed final settling onto a sealed interim bubble restores the bar).
|
||||
(a.interim ?? false) !== (b.interim ?? false)
|
||||
a.branchGroupId !== b.branchGroupId
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -234,12 +231,9 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
* dropping either makes an accepted turn appear to vanish during transport
|
||||
* churn.
|
||||
*
|
||||
* A lagging projection can be behind by one live turn, never a whole local
|
||||
* history window. Preserve only the newest optimistic user row: compression
|
||||
* rewrites past context, so older `user-*` rows in a warm cache are stale
|
||||
* history, not in-flight work. The latest authoritative user confirms whether
|
||||
* that tail has persisted; any authoritative assistant at the same ordinal
|
||||
* supersedes the local stream.
|
||||
* Authoritative rows use different ids, so match by role ordinal. A matching
|
||||
* user row is considered committed only when its visible text also matches;
|
||||
* any authoritative assistant at the same ordinal supersedes the local stream.
|
||||
*/
|
||||
export function preserveLocalPendingTurnMessages(
|
||||
nextMessages: ChatMessage[],
|
||||
@@ -260,10 +254,6 @@ export function preserveLocalPendingTurnMessages(
|
||||
|
||||
const nextIds = new Set(nextMessages.map(message => message.id))
|
||||
const previousRoleCounts = new Map<ChatMessage['role'], number>()
|
||||
const newestOptimisticUser = [...previousMessages]
|
||||
.reverse()
|
||||
.find(message => message.role === 'user' && message.id.startsWith('user-'))
|
||||
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
|
||||
const preserved: ChatMessage[] = []
|
||||
|
||||
for (const message of previousMessages) {
|
||||
@@ -279,14 +269,6 @@ export function preserveLocalPendingTurnMessages(
|
||||
continue
|
||||
}
|
||||
|
||||
if (isOptimisticUser && message !== newestOptimisticUser) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isOptimisticUser && latestAuthoritativeUser && chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()) {
|
||||
continue
|
||||
}
|
||||
|
||||
const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`)
|
||||
|
||||
if (authoritative) {
|
||||
@@ -329,15 +311,8 @@ export function appendLiveSessionProjection(
|
||||
|
||||
const sessionId = projection.session_id || 'session'
|
||||
const projected: ChatMessage[] = []
|
||||
// A turn normally persists its user row before inference begins. session.resume
|
||||
// then returns that stored row *and* the still-live inflight projection; adding
|
||||
// both makes a backgrounded prompt appear twice when its session is reopened.
|
||||
// Only suppress the projection when the latest authoritative user row is the
|
||||
// same turn — older identical prompts must not hide a newly accepted repeat.
|
||||
const latestUser = [...messages].reverse().find(message => message.role === 'user')
|
||||
const inflightUserAlreadyPersisted = latestUser && chatMessageText(latestUser).trim() === inflightUser
|
||||
|
||||
if (inflightUser && !inflightUserAlreadyPersisted) {
|
||||
if (inflightUser) {
|
||||
projected.push({
|
||||
id: `user-inflight-${sessionId}`,
|
||||
role: 'user',
|
||||
|
||||
@@ -21,7 +21,27 @@ import { publishSessionState } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
import { chatMessageArraysEquivalent } from './use-session-actions/utils'
|
||||
// Shallow per-message identity check. When a flush carries no transcript
|
||||
// changes, `preserveLocalAssistantErrors` returns the same message objects in
|
||||
// the same order, so reference equality per slot is enough to detect "nothing
|
||||
// to publish" and avoid a needless `$messages` churn.
|
||||
function sameMessageList(a: ChatMessage[], b: ChatMessage[]): boolean {
|
||||
if (a === b) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (a.length !== b.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
for (let index = 0; index < a.length; index += 1) {
|
||||
if (a[index] !== b[index]) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
interface SessionStateCacheOptions {
|
||||
activeSessionId: string | null
|
||||
@@ -139,12 +159,7 @@ export function useSessionStateCache({
|
||||
// the transcript. That churns ChatView → runtimeMessageRepository → the
|
||||
// assistant-ui runtime → the virtualizer, which re-measures and visibly
|
||||
// jerks the scroll position while the user is reading. Skip the publish when
|
||||
// the merged result is content-equivalent to what's already on screen.
|
||||
// Deep comparison (not just reference equality) is needed because the warm
|
||||
// resume path's `reconcileAuthoritativeMessages` creates new message objects
|
||||
// via `toChatMessages` even when the content hasn't changed — reference
|
||||
// equality would fail and cause a redundant second paint (the "warm resume
|
||||
// jitter" bug).
|
||||
// the merged result is content-identical to what's already on screen.
|
||||
const currentMessages = $messages.get()
|
||||
|
||||
// On a thread switch `$messages` still holds the *previous* thread, so
|
||||
@@ -157,7 +172,7 @@ export function useSessionStateCache({
|
||||
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
|
||||
: pending.state.messages
|
||||
|
||||
if (!chatMessageArraysEquivalent(nextMessages, currentMessages)) {
|
||||
if (!sameMessageList(nextMessages, currentMessages)) {
|
||||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
todayBillingState,
|
||||
todaySubscriptionState
|
||||
} from './fixtures.test-util'
|
||||
import { formatUsageUpdatedAgo } from './use-billing-state'
|
||||
|
||||
import { BillingSettings } from './index'
|
||||
|
||||
@@ -120,9 +121,7 @@ describe('BillingSettings', () => {
|
||||
|
||||
renderBilling()
|
||||
|
||||
// No card → the payment row collapses to a single "Add payment method" link.
|
||||
expect(await screen.findByRole('button', { name: /Add payment method/ })).toBeTruthy()
|
||||
expect(screen.queryByText('No card on file')).toBeNull()
|
||||
expect(await screen.findByText('No card on file')).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: '$25' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '$50' }).hasAttribute('disabled')).toBe(true)
|
||||
expect(screen.getByRole('button', { name: '$100' }).hasAttribute('disabled')).toBe(true)
|
||||
@@ -266,7 +265,7 @@ describe('BillingSettings', () => {
|
||||
// plans capability, so the URL must not surface a grid of Choose buttons.
|
||||
renderBilling(['/settings?tab=billing&bview=plans'])
|
||||
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
|
||||
})
|
||||
@@ -278,7 +277,7 @@ describe('BillingSettings', () => {
|
||||
|
||||
renderBilling(['/settings?tab=billing&bview=plans'])
|
||||
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
expect(screen.queryByRole('button', { name: /Choose/ })).toBeNull()
|
||||
})
|
||||
@@ -315,7 +314,7 @@ describe('BillingSettings', () => {
|
||||
await waitFor(() => expect(apiMocks.scheduleSubscriptionChange).toHaveBeenCalledWith('cltier000free0000personal'))
|
||||
await waitFor(() => expect(invalidate).toHaveBeenCalledWith({ queryKey: ['billing', 'subscription'] }))
|
||||
// Scheduled → back on the overview.
|
||||
expect(await screen.findByText('Payment & credits')).toBeTruthy()
|
||||
expect(await screen.findByText('Payment')).toBeTruthy()
|
||||
expect(screen.queryByText('Plans')).toBeNull()
|
||||
})
|
||||
|
||||
@@ -618,11 +617,9 @@ describe('BillingSettings', () => {
|
||||
expect((await screen.findByText('$0 of $220 left · $0.79 over')).classList.contains('text-destructive')).toBe(true)
|
||||
const subscriptionTrack = screen.getByRole('progressbar', { name: 'Subscription credits remaining' })
|
||||
|
||||
// Plain shared primitive track (no bespoke dither/tinted chrome); the
|
||||
// over-limit signal rides the destructive fill instead.
|
||||
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
|
||||
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
|
||||
expect(subscriptionTrack.querySelector('.bg-destructive')).toBeTruthy()
|
||||
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
|
||||
expect(subscriptionTrack.classList.contains('text-destructive/60')).toBe(true)
|
||||
expect(subscriptionTrack.classList.contains('bg-destructive/10')).toBe(true)
|
||||
})
|
||||
|
||||
it('renders an empty neutral usage track when a row has no bar data', async () => {
|
||||
@@ -647,45 +644,74 @@ describe('BillingSettings', () => {
|
||||
|
||||
expect(subscriptionTrack.getAttribute('aria-valuenow')).toBe('0')
|
||||
expect(subscriptionTrack.classList.contains('text-destructive')).toBe(false)
|
||||
// Empty tracks are the plain shared primitive now — no hatched placeholder.
|
||||
expect(subscriptionTrack.classList.contains('dither')).toBe(false)
|
||||
expect(subscriptionTrack.classList.contains('bg-muted')).toBe(true)
|
||||
expect(subscriptionTrack.classList.contains('dither')).toBe(true)
|
||||
|
||||
const monthlyCapTrack = screen.getByRole('progressbar', { name: 'Monthly spend cap used' })
|
||||
|
||||
expect(monthlyCapTrack.getAttribute('aria-valuenow')).toBe('0')
|
||||
expect(monthlyCapTrack.classList.contains('dither')).toBe(false)
|
||||
expect(monthlyCapTrack.classList.contains('bg-muted')).toBe(true)
|
||||
expect(monthlyCapTrack.classList.contains('dither')).toBe(true)
|
||||
expect(monthlyCapTrack.classList.contains('bg-(--ui-bg-elevated)')).toBe(true)
|
||||
})
|
||||
|
||||
it('shows a warn notice that names the no-card blocker with a portal link', async () => {
|
||||
const fixture = billingDevFixtures['no-card']
|
||||
|
||||
apiMocks.fetchBillingState.mockResolvedValue(fixture.billing)
|
||||
apiMocks.fetchSubscriptionState.mockResolvedValue(fixture.subscription)
|
||||
|
||||
renderBilling()
|
||||
|
||||
expect(await screen.findByText('No payment method on file')).toBeTruthy()
|
||||
expect(
|
||||
screen.getByText('Buying top-up credits and auto-refill stay disabled until a card is on file. Add one on the portal.')
|
||||
).toBeTruthy()
|
||||
expect(screen.getByRole('button', { name: /Add card/ })).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not show the no-card notice when a card is on file', async () => {
|
||||
renderBilling()
|
||||
|
||||
await screen.findByText('$996.47')
|
||||
expect(screen.queryByText('No payment method on file')).toBeNull()
|
||||
})
|
||||
|
||||
it('polls billing on an interval without a manual refresh control', async () => {
|
||||
it('refreshes both billing queries from the usage refresh button', async () => {
|
||||
renderBilling()
|
||||
|
||||
await screen.findByText('$120 of $220 left')
|
||||
// The manual refresh affordance is gone — the queries poll on their own.
|
||||
expect(screen.queryByRole('button', { name: 'Refresh' })).toBeNull()
|
||||
expect(screen.queryByText(/Updated/)).toBeNull()
|
||||
expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(1)
|
||||
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(1)
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: 'Refresh' }))
|
||||
|
||||
await waitFor(() => expect(apiMocks.fetchBillingState).toHaveBeenCalledTimes(2))
|
||||
expect(apiMocks.fetchSubscriptionState).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
|
||||
it('disables the usage refresh button while either query is fetching', async () => {
|
||||
let settleBilling: (value: unknown) => void = () => {}
|
||||
|
||||
let settleSubscription: (value: unknown) => void = () => {}
|
||||
|
||||
apiMocks.fetchBillingState.mockResolvedValueOnce(okBilling(todayBillingState)).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
settleBilling = resolve
|
||||
})
|
||||
)
|
||||
apiMocks.fetchSubscriptionState.mockResolvedValueOnce(okSubscription(todaySubscriptionState)).mockReturnValueOnce(
|
||||
new Promise(resolve => {
|
||||
settleSubscription = resolve
|
||||
})
|
||||
)
|
||||
|
||||
renderBilling()
|
||||
|
||||
const refresh = await screen.findByRole('button', { name: 'Refresh' })
|
||||
|
||||
fireEvent.click(refresh)
|
||||
|
||||
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(true))
|
||||
|
||||
settleBilling(okBilling(todayBillingState))
|
||||
settleSubscription(okSubscription(todaySubscriptionState))
|
||||
|
||||
await waitFor(() => expect(refresh.hasAttribute('disabled')).toBe(false))
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatUsageUpdatedAgo', () => {
|
||||
it('formats sub-second and current timestamps as just now', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 1_000)).toBe('just now')
|
||||
expect(formatUsageUpdatedAgo(1_500, 1_000)).toBe('just now')
|
||||
})
|
||||
|
||||
it('formats seconds below a minute', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 60_000)).toBe('59s ago')
|
||||
})
|
||||
|
||||
it('rounds elapsed time to whole minutes from 61 seconds', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 62_000)).toBe('1m ago')
|
||||
})
|
||||
|
||||
it('formats one hour and later as hours', () => {
|
||||
expect(formatUsageUpdatedAgo(1_000, 3_601_000)).toBe('1h ago')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -3,22 +3,13 @@ import { useEffect, useMemo, useState } from 'react'
|
||||
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Progress } from '@/components/ui/progress'
|
||||
import { SegmentedControl } from '@/components/ui/segmented-control'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Skeleton } from '@/components/ui/skeleton'
|
||||
import { BarChart3, CreditCard, ExternalLink, Package, Wrench } from '@/lib/icons'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { BarChart3, ExternalLink, Lock, Package, Plus, RefreshCw } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { useRouteEnumParam } from '../../hooks/use-route-enum-param'
|
||||
import {
|
||||
ListRow,
|
||||
ListRowSkeleton,
|
||||
SectionHeading,
|
||||
SectionHeadingSkeleton,
|
||||
SettingsContent,
|
||||
SettingsSection
|
||||
} from '../primitives'
|
||||
import { ListRow, SectionHeading, SettingsContent } from '../primitives'
|
||||
|
||||
import { RowValue } from './account-row-value'
|
||||
import { BillingApiProvider } from './api'
|
||||
@@ -36,6 +27,7 @@ import {
|
||||
type BillingNoticeView,
|
||||
type BillingUsageRowView,
|
||||
deriveBillingView,
|
||||
formatUsageUpdatedAgo,
|
||||
useBillingState,
|
||||
useSubscriptionState
|
||||
} from './use-billing-state'
|
||||
@@ -72,18 +64,9 @@ function SummaryCard({ label, value, tone }: { label: string; tone?: 'muted' | '
|
||||
}
|
||||
|
||||
function NoticeCard({ notice }: { notice: BillingNoticeView }) {
|
||||
const warn = notice.tone === 'warn'
|
||||
|
||||
return (
|
||||
<div className={cn('mb-6 rounded-xl p-4', warn ? 'bg-(--ui-yellow)/10' : 'bg-(--ui-bg-quaternary)')}>
|
||||
<div
|
||||
className={cn(
|
||||
'text-[length:var(--conversation-text-font-size)] font-medium',
|
||||
warn ? 'text-(--ui-yellow)' : 'text-foreground'
|
||||
)}
|
||||
>
|
||||
{notice.title}
|
||||
</div>
|
||||
<div className="mb-5 rounded-lg border border-border/70 bg-muted/20 p-4">
|
||||
<div className="text-[length:var(--conversation-text-font-size)] font-medium text-foreground">{notice.title}</div>
|
||||
<div className="mt-1 text-[length:var(--conversation-caption-font-size)] leading-(--conversation-caption-line-height) text-(--ui-text-tertiary)">
|
||||
{notice.message}
|
||||
</div>
|
||||
@@ -103,31 +86,6 @@ function NoticeCard({ notice }: { notice: BillingNoticeView }) {
|
||||
)
|
||||
}
|
||||
|
||||
// The payment method as it rides in the "Payment & credits" heading: the current
|
||||
// card (muted) plus a single underline text action (Update / Add payment method).
|
||||
function PaymentMethodAside({ row }: { row: BillingAccountRowView }) {
|
||||
return (
|
||||
<div className="flex min-w-0 items-center gap-2.5">
|
||||
{row.value && (
|
||||
<span className="min-w-0 truncate text-[length:var(--conversation-caption-font-size)] font-normal text-(--ui-text-tertiary)">
|
||||
{row.value}
|
||||
</span>
|
||||
)}
|
||||
{row.action && (
|
||||
<Button
|
||||
disabled={row.action.disabled}
|
||||
onClick={row.action.url ? () => openExternal(row.action?.url) : undefined}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="textStrong"
|
||||
>
|
||||
{row.action.label}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function AccountRow({ billing, row }: { billing?: BillingStateResponse; row: BillingAccountRowView }) {
|
||||
if (row.id === 'buy_credits' && row.action && row.chips && billing?.can_charge && billing.cli_billing_enabled) {
|
||||
return <BuyCreditsRow billing={billing} row={row} />
|
||||
@@ -185,15 +143,22 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
||||
<ListRow
|
||||
action={
|
||||
<div className="flex min-w-0 flex-wrap items-center justify-start gap-2 @2xl:justify-end">
|
||||
<SegmentedControl
|
||||
disabled={controlsDisabled}
|
||||
onChange={value => setAmount(value)}
|
||||
options={presets.map(preset => ({ id: preset.amount, label: preset.label }))}
|
||||
value={amount}
|
||||
/>
|
||||
{presets.map(preset => (
|
||||
<Button
|
||||
aria-pressed={amount === preset.amount}
|
||||
disabled={controlsDisabled}
|
||||
key={preset.amount}
|
||||
onClick={() => setAmount(preset.amount)}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant={amount === preset.amount ? 'default' : 'outline'}
|
||||
>
|
||||
{preset.label}
|
||||
</Button>
|
||||
))}
|
||||
<Input
|
||||
aria-label="Custom credit amount"
|
||||
containerClassName="w-16"
|
||||
className="w-24 py-[3px]"
|
||||
disabled={controlsDisabled}
|
||||
inputMode="decimal"
|
||||
max={billing.max_usd ?? undefined}
|
||||
@@ -203,14 +168,13 @@ function BuyCreditsRow({ billing, row }: { billing: BillingStateResponse; row: B
|
||||
flow.reset()
|
||||
setAmount(event.target.value)
|
||||
}}
|
||||
placeholder={billing.min_usd ?? ''}
|
||||
prefix="$"
|
||||
size="xs"
|
||||
placeholder={billing.min_usd ? formatMoney(billing.min_usd) : '$'}
|
||||
size="sm"
|
||||
step="0.01"
|
||||
type="number"
|
||||
value={amount}
|
||||
/>
|
||||
<Button disabled={!canBuy} onClick={startBuy} size="xs" type="button" variant="secondary">
|
||||
<Button disabled={!canBuy} onClick={startBuy} size="sm" type="button" variant="outline">
|
||||
Buy
|
||||
</Button>
|
||||
</div>
|
||||
@@ -319,20 +283,43 @@ function UsageBar({ bar, fallbackLabel }: { bar?: BillingUsageRowView['bar']; fa
|
||||
value: 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')
|
||||
const width = Math.round(resolvedBar.value * 100)
|
||||
const isEmpty = resolvedBar.value === 0
|
||||
const showDangerNub = resolvedBar.track === 'danger' && resolvedBar.state === 'danger' && width === 0
|
||||
|
||||
return (
|
||||
<Progress
|
||||
<div
|
||||
aria-label={resolvedBar.label}
|
||||
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}
|
||||
/>
|
||||
aria-valuemax={100}
|
||||
aria-valuemin={0}
|
||||
aria-valuenow={width}
|
||||
className={cn(
|
||||
// Radius follows the app-wide rounded-full progress-bar idiom.
|
||||
'relative h-2 w-full overflow-hidden rounded-full',
|
||||
resolvedBar.track === 'danger'
|
||||
? 'dither text-destructive/60 bg-destructive/10'
|
||||
: isEmpty
|
||||
? 'dither bg-(--ui-bg-elevated)'
|
||||
: 'bg-muted shadow-[inset_0_0_0_1px_color-mix(in_srgb,var(--ui-stroke-secondary)_50%,transparent)]'
|
||||
)}
|
||||
role="progressbar"
|
||||
>
|
||||
{showDangerNub && <div className="absolute inset-y-0 left-0 z-10 w-2 rounded-full bg-destructive" />}
|
||||
<div
|
||||
className={cn(
|
||||
'relative h-full rounded-full transition-[width] duration-300 ease-out',
|
||||
resolvedBar.state === 'danger'
|
||||
? 'bg-destructive'
|
||||
: resolvedBar.state === 'ok' && (resolvedBar.tone === 'subscription' || resolvedBar.tone === 'topup')
|
||||
? 'bg-(--ui-green)'
|
||||
: 'bg-muted-foreground/45'
|
||||
)}
|
||||
style={{
|
||||
minWidth: resolvedBar.value > 0 ? 4 : undefined,
|
||||
width: `${width}%`
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -364,10 +351,53 @@ function UsageRow({ row }: { row: BillingUsageRowView }) {
|
||||
)
|
||||
}
|
||||
|
||||
// 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 UsageRefreshRow({
|
||||
fixtureName,
|
||||
isFetching,
|
||||
onRefresh,
|
||||
updatedAt
|
||||
}: {
|
||||
fixtureName?: BillingFixtureSelection
|
||||
isFetching: boolean
|
||||
onRefresh: () => void
|
||||
updatedAt: number
|
||||
}) {
|
||||
const [now, setNow] = useState(() => Date.now())
|
||||
|
||||
useEffect(() => {
|
||||
const interval = window.setInterval(() => setNow(Date.now()), 30_000)
|
||||
|
||||
return () => window.clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
if (fixtureName && fixtureName !== 'live') {
|
||||
return (
|
||||
<div className="flex items-center justify-end pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
fixture: {fixtureName}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-w-0 items-center justify-end gap-1.5 pt-1 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<span>Updated {formatUsageUpdatedAgo(updatedAt, now)}</span>
|
||||
<Tip label="Refresh">
|
||||
<Button
|
||||
aria-label="Refresh"
|
||||
className="size-7 p-0 text-(--ui-text-tertiary)"
|
||||
disabled={isFetching}
|
||||
onClick={onRefresh}
|
||||
size="sm"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<RefreshCw className={cn('size-3.5', isFetching && 'animate-spin')} />
|
||||
</Button>
|
||||
</Tip>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingFixtureSelect({
|
||||
onValueChange,
|
||||
value
|
||||
@@ -376,27 +406,23 @@ function BillingFixtureSelect({
|
||||
value: BillingFixtureSelection
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 text-(--ui-text-tertiary)">
|
||||
<Wrench className="size-3.5 shrink-0" />
|
||||
<span className="text-xs font-normal">preview</span>
|
||||
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
|
||||
<SelectTrigger
|
||||
aria-label="Billing preview fixture (dev only)"
|
||||
className="h-7 w-36 border-dashed border-(--ui-stroke-secondary) bg-transparent px-2 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-(--ui-bg-tertiary) focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-(--ui-bg-tertiary)"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="live">live</SelectItem>
|
||||
{BILLING_DEV_FIXTURE_NAMES.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Select onValueChange={value => onValueChange(value as BillingFixtureSelection)} value={value}>
|
||||
<SelectTrigger
|
||||
aria-label="Billing fixture"
|
||||
className="h-7 w-32 border-transparent bg-transparent px-1.5 text-xs font-normal text-(--ui-text-tertiary) shadow-none hover:bg-muted/40 focus-visible:ring-0 focus-visible:ring-offset-0 data-[state=open]:bg-muted/40"
|
||||
size="sm"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent align="end">
|
||||
<SelectItem value="live">live</SelectItem>
|
||||
{BILLING_DEV_FIXTURE_NAMES.map(name => (
|
||||
<SelectItem key={name} value={name}>
|
||||
{name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -420,34 +446,6 @@ 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 (
|
||||
<>
|
||||
<div className="@container mb-6">
|
||||
<div className="grid gap-3 @2xl:grid-cols-3">
|
||||
{[0, 1, 2].map(i => (
|
||||
<div className="min-w-0 space-y-2" key={i}>
|
||||
<Skeleton className="h-3 w-24" />
|
||||
<Skeleton className="h-6 w-20" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{[0, 1, 2].map(section => (
|
||||
<section className="mb-6" key={section}>
|
||||
<SectionHeadingSkeleton />
|
||||
<div className="grid gap-1">
|
||||
<ListRowSkeleton />
|
||||
<ListRowSkeleton />
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingSettingsContent({
|
||||
fixtureName,
|
||||
onFixtureChange
|
||||
@@ -462,30 +460,19 @@ 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 (
|
||||
<SettingsContent>
|
||||
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
|
||||
<BillingSkeleton />
|
||||
</SettingsContent>
|
||||
)
|
||||
}
|
||||
|
||||
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.
|
||||
@@ -504,42 +491,59 @@ function BillingSettingsContent({
|
||||
<SettingsContent>
|
||||
<BillingHeader fixtureName={fixtureName} onFixtureChange={onFixtureChange} />
|
||||
|
||||
{view.notice && <NoticeCard notice={view.notice} />}
|
||||
|
||||
<div className="@container mb-6">
|
||||
<div className="grid gap-3 @2xl:grid-cols-3">
|
||||
<div className="@container mb-5">
|
||||
<div className="grid gap-3 rounded-lg border border-border/70 bg-muted/20 p-4 @2xl:grid-cols-3">
|
||||
{view.summary.map(item => (
|
||||
<SummaryCard key={item.label} label={item.label} tone={item.tone} value={item.value} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{view.notice && <NoticeCard notice={view.notice} />}
|
||||
|
||||
{view.plan && (
|
||||
<SettingsSection icon={Package} title="Plan">
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Package} title="Plan" />
|
||||
<CurrentPlanCard onViewPlans={() => setSubView('plans')} plan={view.plan} />
|
||||
</SettingsSection>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(paymentRow || accountRows.length > 0) && (
|
||||
<SettingsSection
|
||||
aside={paymentRow ? <PaymentMethodAside row={paymentRow} /> : undefined}
|
||||
icon={CreditCard}
|
||||
title="Payment & credits"
|
||||
>
|
||||
{accountRows.map(row => (
|
||||
<AccountRow billing={billing} key={row.id} row={row} />
|
||||
))}
|
||||
</SettingsSection>
|
||||
{paymentRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Lock} title="Payment" />
|
||||
<AccountRow billing={billing} row={paymentRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{topupRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={Plus} title="One-time top-up" />
|
||||
<AccountRow billing={billing} row={topupRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{refillRow && (
|
||||
<div className="mb-5">
|
||||
<SectionHeading icon={RefreshCw} title="Automatic refill" />
|
||||
<AccountRow billing={billing} row={refillRow} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{view.usageRows.length > 0 && (
|
||||
<SettingsSection icon={BarChart3} title="Usage">
|
||||
<div className="@container">
|
||||
<>
|
||||
<SectionHeading icon={BarChart3} title="Usage" />
|
||||
<div className="@container rounded-lg border border-border/70 bg-muted/20 px-4 py-2">
|
||||
{view.usageRows.map(row => (
|
||||
<UsageRow key={row.id} row={row} />
|
||||
))}
|
||||
<UsageRefreshRow
|
||||
fixtureName={fixtureName}
|
||||
isFetching={usageIsFetching}
|
||||
onRefresh={refreshUsage}
|
||||
updatedAt={usageUpdatedAt}
|
||||
/>
|
||||
</div>
|
||||
</SettingsSection>
|
||||
</>
|
||||
)}
|
||||
|
||||
{
|
||||
@@ -582,3 +586,9 @@ export function BillingSettings() {
|
||||
|
||||
return <BillingSettingsContent />
|
||||
}
|
||||
|
||||
function oldestUpdatedAt(...timestamps: number[]): number {
|
||||
const populated = timestamps.filter(timestamp => timestamp > 0)
|
||||
|
||||
return populated.length > 0 ? Math.min(...populated) : Date.now()
|
||||
}
|
||||
|
||||
@@ -79,7 +79,7 @@ function DowngradeConfirm({ flow, tier }: { flow: DowngradeFlow; tier: BillingPl
|
||||
return (
|
||||
<div
|
||||
aria-live="polite"
|
||||
className="flex min-w-0 flex-col gap-2 rounded-md bg-(--ui-bg-elevated) p-3 outline-none"
|
||||
className="flex min-w-0 flex-col gap-2 rounded-md border border-border/70 bg-background/60 p-3 outline-none"
|
||||
ref={panelRef}
|
||||
role="status"
|
||||
tabIndex={-1}
|
||||
@@ -129,8 +129,8 @@ function PlanCard({ flow, tier }: { flow: DowngradeFlow; tier: BillingPlanTierVi
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'flex min-w-0 flex-col gap-3 rounded-lg p-4 outline-none',
|
||||
isCurrent ? 'bg-(--ui-green)/10' : 'bg-(--ui-bg-quaternary)'
|
||||
'flex min-w-0 flex-col gap-3 rounded-lg border p-4 outline-none',
|
||||
isCurrent ? 'border-(--ui-green)/60 bg-(--ui-green)/5' : 'border-border/70 bg-muted/20'
|
||||
)}
|
||||
ref={cardRef}
|
||||
tabIndex={-1}
|
||||
@@ -219,7 +219,7 @@ export function BillingPlansView({ onBack, tiers }: { onBack: () => void; tiers:
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="rounded-xl bg-(--ui-bg-quaternary) p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
<div className="rounded-lg border border-border/70 bg-muted/20 p-4 text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
No plans are available to change to right now.
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -147,14 +147,11 @@ describe('deriveBillingView', () => {
|
||||
|
||||
expect(buyCredits).toMatchObject({
|
||||
action: { disabled: true, label: 'Buy' },
|
||||
// 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.'
|
||||
description:
|
||||
'💳 No saved card for terminal charges yet. Set one up on the portal ' +
|
||||
"(one-time credit buys don't save a reusable card)."
|
||||
})
|
||||
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', () => {
|
||||
|
||||
@@ -11,12 +11,7 @@ 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'
|
||||
|
||||
// Billing polls on its own while the page is mounted (react-query only ticks an
|
||||
// active observer), so the view stays live without a manual refresh control —
|
||||
// matching every other data view in the app. It pauses when the window is
|
||||
// backgrounded (refetchIntervalInBackground defaults to false).
|
||||
const BILLING_QUERY_OPTIONS = {
|
||||
refetchInterval: 30_000,
|
||||
refetchOnWindowFocus: true,
|
||||
retry: false,
|
||||
staleTime: 30_000
|
||||
@@ -35,8 +30,6 @@ export interface BillingNoticeView {
|
||||
}
|
||||
message: string
|
||||
title: string
|
||||
/** `warn` = an actionable blocker (e.g. no card); `info` = neutral guidance. */
|
||||
tone?: 'info' | 'warn'
|
||||
}
|
||||
|
||||
export interface BillingRowActionView {
|
||||
@@ -214,7 +207,7 @@ export function deriveBillingView(
|
||||
const tiers = derivePlanTiers(subscription, billing.portal_url, capable, pending)
|
||||
|
||||
return {
|
||||
notice: noCardNotice(billing),
|
||||
notice: undefined,
|
||||
paymentRow: paymentMethodRow(billing),
|
||||
plan: derivePlanCard(billing, subscription, subscriptionResult, tiers, capable, pending),
|
||||
refillRow: autoReloadRow(billing),
|
||||
@@ -283,6 +276,26 @@ 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 },
|
||||
@@ -298,24 +311,7 @@ function refusalNotice(refusal: BillingRefusal): BillingNoticeView {
|
||||
return {
|
||||
action: portalUrl ? { label: 'Open portal ↗', url: portalUrl } : undefined,
|
||||
message: resolved.message,
|
||||
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'
|
||||
title: resolved.title
|
||||
}
|
||||
}
|
||||
|
||||
@@ -534,19 +530,17 @@ 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: 'Add payment method', url: portalUrl },
|
||||
description: '',
|
||||
action: { label: 'Update ↗', url: portalUrl },
|
||||
description: 'Add a payment method on the portal before buying top-up credits.',
|
||||
id: 'payment_method',
|
||||
title: 'Payment method'
|
||||
title: 'Payment method',
|
||||
value: 'No card on file'
|
||||
}
|
||||
}
|
||||
|
||||
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',
|
||||
@@ -556,13 +550,14 @@ 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: 'A single charge on your card, added to your balance today.',
|
||||
description: resolveRefusal({
|
||||
kind: 'no_payment_method',
|
||||
message: '',
|
||||
portalUrl: billing.portal_url ?? undefined
|
||||
}).message,
|
||||
id: 'buy_credits',
|
||||
title: 'Buy credits now'
|
||||
}
|
||||
|
||||
@@ -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, SettingsContent, SettingsSkeleton, ToggleRow } from './primitives'
|
||||
import { EmptyState, LoadingState, SettingsContent, ToggleRow } from './primitives'
|
||||
|
||||
// On the Voice page, only surface the sub-fields of the *selected* TTS/STT
|
||||
// provider — otherwise every provider's options render at once (the "totally
|
||||
@@ -264,8 +264,8 @@ export function ConfigSettings({
|
||||
)
|
||||
}
|
||||
|
||||
// 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.
|
||||
// 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.
|
||||
if (activeSectionId === 'model') {
|
||||
return (
|
||||
<SettingsContent>
|
||||
@@ -276,7 +276,7 @@ export function ConfigSettings({
|
||||
)
|
||||
}
|
||||
|
||||
return <SettingsSkeleton sections={[{ rows: 6 }]} />
|
||||
return <LoadingState label={c.loading} />
|
||||
}
|
||||
|
||||
const visibleFields = activeSectionId === 'voice' ? fields.filter(([key]) => voiceFieldVisible(key, config)) : fields
|
||||
|
||||
@@ -247,11 +247,10 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
|
||||
'code_execution.mode': ['project', 'strict'],
|
||||
'context.engine': ['compressor', 'default', 'custom'],
|
||||
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
|
||||
// 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).
|
||||
// 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'],
|
||||
// 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).
|
||||
|
||||
@@ -16,7 +16,7 @@ import { cn } from '@/lib/utils'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
import type { CustomEndpoint, CustomEndpointUpdate } from '@/types/hermes'
|
||||
|
||||
import { EmptyState, Pill, SectionHeading, SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { EmptyState, LoadingState, Pill, SectionHeading, SettingsContent } from './primitives'
|
||||
|
||||
interface CustomEndpointsSettingsProps {
|
||||
onConfigSaved?: () => void
|
||||
@@ -218,7 +218,7 @@ export function CustomEndpointsSettings({ onConfigSaved, onMainModelChanged }: C
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }]} />
|
||||
return <LoadingState label="Loading custom endpoints..." />
|
||||
}
|
||||
|
||||
const allModelOptions = Array.from(new Set([...discoveredModels, form.model].filter(Boolean)))
|
||||
|
||||
@@ -27,7 +27,7 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import { $profiles, refreshActiveProfile } from '@/store/profile'
|
||||
|
||||
import { CONTROL_TEXT } from './constants'
|
||||
import { EmptyState, ListRow, Pill, SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { EmptyState, ListRow, LoadingState, Pill, SettingsContent } from './primitives'
|
||||
import { enrichSelectedSshHost, selectSshHost } from './ssh-host-selection'
|
||||
|
||||
type Mode = 'local' | 'remote' | 'cloud' | 'ssh'
|
||||
@@ -985,7 +985,7 @@ export function GatewaySettings({ embedded = false }: { embedded?: boolean } = {
|
||||
}
|
||||
|
||||
if (loading) {
|
||||
return <SettingsSkeleton sections={[{ heading: true, rows: 3 }, { heading: true, rows: 3 }]} />
|
||||
return <LoadingState label={g.loading} />
|
||||
}
|
||||
|
||||
if (!window.hermesDesktop?.getConnectionConfig) {
|
||||
|
||||
@@ -30,12 +30,18 @@ describe('settings helpers', () => {
|
||||
expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy()
|
||||
})
|
||||
|
||||
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()
|
||||
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'])
|
||||
})
|
||||
|
||||
describe('isExternalMemoryProvider', () => {
|
||||
|
||||
@@ -113,7 +113,7 @@ export function inferFieldSchema(value: unknown): ConfigFieldSchema {
|
||||
return { type: 'string' }
|
||||
}
|
||||
|
||||
// Backend schema omits some declared keys; config presence is the availability signal.
|
||||
// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal.
|
||||
export function sectionFieldEntries(
|
||||
schema: Record<string, ConfigFieldSchema>,
|
||||
config: HermesConfigRecord
|
||||
|
||||
@@ -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 { SettingsContent, SettingsSkeleton } from './primitives'
|
||||
import { LoadingState, SettingsContent } 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 <SettingsSkeleton sections={[{ rows: 5 }]} />
|
||||
return <LoadingState label={t.settings.keys.loading} />
|
||||
}
|
||||
|
||||
const visible = groups.filter(g => g.category === view)
|
||||
|
||||
@@ -8,7 +8,6 @@ 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'
|
||||
@@ -140,21 +139,7 @@ export function PetSettings() {
|
||||
{/* Fixed-height scroll area so filtering never grows/shrinks the
|
||||
page (no layout thrash); the grid scrolls inside it. */}
|
||||
<div className="mt-3 h-72 overflow-y-auto pr-1">
|
||||
{status === 'loading' && pets.length === 0 ? (
|
||||
// First load keeps the grid's shape rather than flashing the
|
||||
// "unreachable" copy before the gallery has even arrived.
|
||||
<div className="grid gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{Array.from({ length: 6 }, (_, i) => (
|
||||
<div className="flex items-center gap-2.5 px-2.5 py-2" key={i}>
|
||||
<Skeleton className="size-10 shrink-0 rounded-md" />
|
||||
<div className="min-w-0 flex-1 space-y-1.5">
|
||||
<Skeleton className="h-3.5 w-24 max-w-full" />
|
||||
<Skeleton className="h-3 w-16 max-w-full" />
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : pets.length === 0 ? (
|
||||
{pets.length === 0 ? (
|
||||
<p className="text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)">
|
||||
{copy.unreachable}
|
||||
</p>
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user