Merge upstream main into feat/hermes-relay-shared-metrics

# Conflicts:
#	MANIFEST.in
#	pyproject.toml
#	tests/test_project_metadata.py
This commit is contained in:
Alex Fournier 2026-07-23 07:22:03 -07:00
commit b4e105031a
506 changed files with 33840 additions and 5678 deletions

View file

@ -97,9 +97,6 @@ packaging/
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc

View file

@ -39,6 +39,9 @@ outputs:
ci_review:
description: Require CI-sensitive file review label.
value: ${{ steps.classify.outputs.ci_review }}
ci_review_files:
description: JSON list of CI-sensitive files changed by the pull request.
value: ${{ steps.classify.outputs.ci_review_files }}
runs:
using: composite

View file

@ -5,24 +5,32 @@ description: >-
5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN)
and are scoped to the App's installation permissions, not a user account.
Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set —
this happens on fork PRs where repo secrets are unavailable. The fallback
ensures classification, timings, and review comments still work on
forks (with the lower GITHUB_TOKEN rate limit).
Callers must source App credentials from a protected, main-only environment.
Never pass an App private key to a pull_request job, a local action, or a
reusable workflow resolved from an untrusted PR ref. The fallback keeps a
trusted caller functional when its protected environment is misconfigured.
Composite actions cannot access the secrets context directly, so the
calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY
as inputs. When both are empty (fork PRs), the fallback fires.
Composite actions cannot access contexts directly, so callers pass the
public vars.APP_CLIENT_ID and protected secrets.APP_PRIVATE_KEY as inputs.
When the private key is empty, the fallback fires.
inputs:
client-id:
description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow.
description: GitHub App Client ID. Pass vars.APP_CLIENT_ID from the calling workflow.
required: false
default: ''
private-key:
description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow.
required: false
default: ''
owner:
description: GitHub App installation owner. Empty scopes the token to the current repository.
required: false
default: ''
repositories:
description: Comma- or newline-separated repositories to scope within the installation owner.
required: false
default: ''
outputs:
token:
@ -51,6 +59,8 @@ runs:
with:
client-id: ${{ inputs.client-id }}
private-key: ${{ inputs.private-key }}
owner: ${{ inputs.owner }}
repositories: ${{ inputs.repositories }}
- name: Fall back to GITHUB_TOKEN
id: fallback

View file

@ -9,6 +9,10 @@ name: CI
# definitions, matrices, and concurrency settings. They no longer have
# ``push:`` / ``pull_request:`` triggers of their own — everything flows
# through this file.
#
# SECURITY: this workflow runs PR-controlled actions, workflows, and code.
# Do not add ``secrets: inherit`` or GitHub App credentials here. Trusted
# main-only automation uses protected environments in its own workflows.
on:
pull_request:
@ -46,22 +50,15 @@ jobs:
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
ci_review: ${{ steps.classify.outputs.ci_review }}
ci_review_files: ${{ steps.classify.outputs.ci_review_files }}
event_name: ${{ github.event_name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Detect affected areas
id: classify
uses: ./.github/actions/detect-changes
with:
# The get-app-token composite action falls back to GITHUB_TOKEN
# on fork PRs where APP_ID is unavailable.
github-token: ${{ steps.app-token.outputs.token }}
github-token: ${{ github.token }}
# ─────────────────────────────────────────────────────────────────────
# Lane-gated sub-workflows. Each runs in parallel after detect finishes.
@ -74,7 +71,6 @@ jobs:
uses: ./.github/workflows/tests.yml
with:
slice_count: 8
secrets: inherit
lint:
name: Python lints
@ -83,14 +79,12 @@ jobs:
uses: ./.github/workflows/lint.yml
with:
event_name: ${{ needs.detect.outputs.event_name }}
secrets: inherit
js-tests:
name: JS & TS checks
needs: detect
if: needs.detect.outputs.frontend == 'true'
uses: ./.github/workflows/js-tests.yml
secrets: inherit
e2e-desktop:
name: Desktop E2E
@ -103,48 +97,44 @@ jobs:
needs: detect
if: needs.detect.outputs.site == 'true'
uses: ./.github/workflows/docs-site-checks.yml
secrets: inherit
history-check:
name: Deny unrelated histories
needs: detect
if: needs.detect.outputs.event_name == 'pull_request'
uses: ./.github/workflows/history-check.yml
secrets: inherit
contributor-check:
name: Check contributors
needs: detect
if: needs.detect.outputs.python == 'true'
uses: ./.github/workflows/contributor-check.yml
secrets: inherit
uv-lockfile:
name: Check uv.lock
needs: detect
uses: ./.github/workflows/uv-lockfile-check.yml
secrets: inherit
lockfile-diff:
name: package-lock.json diff
needs: detect
if: needs.detect.outputs.event_name == 'pull_request' && needs.detect.outputs.npm_lock == 'true'
uses: ./.github/workflows/lockfile-diff.yml
secrets: inherit
docker-lint:
name: Lint Docker scripts
needs: detect
if: needs.detect.outputs.docker_meta == 'true'
uses: ./.github/workflows/docker-lint.yml
secrets: inherit
docker:
name: Build&Test Docker image
needs: detect
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true'
# Trusted main pushes run docker.yml directly so its container-publish
# environment secrets never cross this reusable-workflow call. PR runs
# remain build/test-only and secret-free.
if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true' || needs.detect.outputs.docker_meta == 'true')
uses: ./.github/workflows/docker.yml
secrets: inherit
supply-chain:
name: Supply-chain scan
@ -163,14 +153,13 @@ jobs:
uses: ./.github/workflows/review-labels.yml
with:
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
ci_review_files: ${{ needs.detect.outputs.ci_review_files }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
secrets: inherit
osv-scanner:
name: OSV scan
uses: ./.github/workflows/osv-scanner.yml
secrets: inherit
# ─────────────────────────────────────────────────────────────────────
# Live-updating PR review comment.
@ -180,19 +169,21 @@ jobs:
# whatever results are available, and upserts it via the
# ``<!-- hermes-ci-review-bot -->`` marker.
#
# The poller exits when all non-infra jobs are completed (or on
# timeout). ci-timings' review_status is picked up automatically when
# its artifact becomes available — the poller downloads and merges it.
# When the visible job set goes quiet, the poller waits 10 seconds and polls
# once more so downstream jobs created by an aggregate gate get included.
# ─────────────────────────────────────────────────────────────────────
comment-live:
name: CI review comment (live)
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check]
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check, e2e-desktop]
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
runs-on: ubuntu-latest
timeout-minutes: 40
steps:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
- name: Run live comment poller
env:
@ -313,8 +304,8 @@ jobs:
# report with a gantt chart + per-step breakdown. The report is uploaded
# as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY.
#
# The live comment poller picks up ci-timings' completion automatically —
# it reads review-status.json from the artifact when the job finishes.
# The live comment poller can read the standalone review-status artifact
# after the HTML report is uploaded, so its link points straight at that report.
# ─────────────────────────────────────────────────────────────────────
ci-timings:
name: CI timing report
@ -326,13 +317,6 @@ jobs:
- name: Checkout code
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Restore baseline cache (PR only)
if: github.event_name == 'pull_request'
uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5
@ -346,38 +330,52 @@ jobs:
- name: Collect timings and generate report
env:
# Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to
# the built-in read-only token so the timings API read still works
# there instead of hard-failing this advisory job on every fork PR.
# The get-app-token composite action falls back to GITHUB_TOKEN
# on fork PRs where APP_ID is unavailable.
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
GITHUB_TOKEN: ${{ github.token }}
run: |
python3 scripts/ci/timings_report.py \
--baseline ci-timings-baseline.json \
--output ci-timings-report.html \
--json-out ci-timings.json \
--summary-out ci-timings-summary.md \
--review-status-out review-status.json
--summary-out ci-timings-summary.md
- name: Upload HTML report + review status
- name: Upload HTML report
# Advisory report — artifact-service blips must not fail the job.
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
id: ci-timings-artifact
id: ci-timings-html
with:
name: ci-timings-report
path: |
ci-timings-report.html
review-status.json
path: ci-timings-report.html
retention-days: 14
- name: Build linked review status
if: hashFiles('ci-timings.json') != ''
env:
CI_TIMINGS_REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url }}
run: |
python3 scripts/ci/timings_report.py \
--from-json ci-timings.json \
--baseline ci-timings-baseline.json \
--review-status-out review-status.json \
--review-status-only
- name: Upload review status
if: hashFiles('review-status.json') != ''
continue-on-error: true
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: ci-timings-review-status
path: review-status.json
retention-days: 14
- name: Output summary
env:
REPORT_URL: ${{ steps.ci-timings-artifact.outputs.artifact-url}}
REPORT_URL: ${{ steps.ci-timings-html.outputs.artifact-url}}
run: |
echo "# CI Timing report" >> "$GITHUB_STEP_SUMMARY"
echo "[View the full interactive report]($REPORT_URL)" >> "$GITHUB_STEP_SUMMARY"
{
echo "# CI Timing report"
echo "[View the full interactive report]($REPORT_URL)"
} >> "$GITHUB_STEP_SUMMARY"
cat ci-timings-summary.md >> "$GITHUB_STEP_SUMMARY"
- name: Save baseline cache (main only)

View file

@ -60,7 +60,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4

View file

@ -1,8 +1,15 @@
name: Docker Build, Test, and Publish
on:
# Trusted main pushes run this workflow directly so environment-scoped
# Docker Hub secrets are resolved by the top-level workflow, never across
# a reusable-workflow boundary.
push:
branches: [main]
release:
types: [published]
# CI calls this only for untrusted PR build/test coverage. Those runs never
# reach the protected publish or merge jobs below.
workflow_call:
permissions:
@ -20,7 +27,9 @@ env:
IMAGE_NAME: nousresearch/hermes-agent
jobs:
# Build, test, and optionally push the image for each architecture.
# Build and test the image for each architecture. This job runs PR code,
# so it must remain secret-free. Publishing happens in the separate,
# protected publish job after these tests pass.
build:
if: github.repository == 'NousResearch/hermes-agent'
strategy:
@ -62,49 +71,6 @@ jobs:
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ (github.event_name != 'pull_request') && matrix.cache-to || '' }}
- name: Log in to Docker Hub
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push by digest only (no tag). The merge job assembles the
# tagged manifest list. `push-by-digest=true` is docker's recommended
# pattern for multi-runner multi-platform builds.
- name: Push ${{ matrix.arch }} by digest
id: push
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
# Write the digest to a file and upload it as an artifact so the
# merge job can stitch both per-arch digests into a manifest list.
- name: Export digest
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
if: github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# Run the docker-integration test suite against the freshly-built
# image already loaded into the local daemon (`:test`).
@ -147,6 +113,74 @@ jobs:
run: |
scripts/run_tests.sh tests/docker/ --file-timeout 600
# ---------------------------------------------------------------------------
# Rebuild and push each architecture only after the unprivileged build/test
# matrix passes. This job is the sole Docker Hub credential boundary.
# ---------------------------------------------------------------------------
publish:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
needs: [build]
environment: container-publish
strategy:
fail-fast: false
matrix:
include:
- arch: amd64
runner: ubuntu-latest
platform: linux/amd64
cache-from: type=gha,scope=docker-amd64
cache-to: type=gha,mode=max,scope=docker-amd64
- arch: arm64
runner: ubuntu-24.04-arm
platform: linux/arm64
cache-from: type=gha,scope=docker-arm64
cache-to: type=gha,mode=max,scope=docker-arm64
runs-on: ${{ matrix.runner }}
timeout-minutes: 30
steps:
- name: Checkout trusted source
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@8d2750c68a42422c14e847fe6c8ac0403b4cbd6f # v3
- name: Log in to Docker Hub
uses: docker/login-action@4907a6ddec9925e35a0a9e82d7399ccc52663121 # v4.1.0
with:
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
# Push by digest only (no tag). The merge job assembles the tagged
# manifest list after both architecture publishers complete.
- name: Push ${{ matrix.arch }} by digest
id: push
uses: docker/build-push-action@bcafcacb16a39f128d818304e6c9c0c18556b85f # v7.1.0
with:
context: .
file: Dockerfile
platforms: ${{ matrix.platform }}
labels: |
org.opencontainers.image.revision=${{ github.sha }}
build-args: |
HERMES_GIT_SHA=${{ github.sha }}
outputs: type=image,name=${{ env.IMAGE_NAME }},push-by-digest=true,name-canonical=true,push=true
cache-from: ${{ matrix.cache-from }}
cache-to: ${{ matrix.cache-to }}
- name: Export digest
run: |
mkdir -p /tmp/digests
digest="${{ steps.push.outputs.digest }}"
touch "/tmp/digests/${digest#sha256:}"
- name: Upload digest artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: digest-${{ matrix.arch }}
path: /tmp/digests/*
if-no-files-found: error
retention-days: 1
# ---------------------------------------------------------------------------
# Stitch both per-arch digests into a single tagged multi-arch manifest.
# This is a registry-side operation — no building, no layer re-push —
@ -158,8 +192,9 @@ jobs:
merge:
if: github.repository == 'NousResearch/hermes-agent' && (github.event_name == 'push' && github.ref == 'refs/heads/main' || github.event_name == 'release')
runs-on: ubuntu-latest
needs: [build]
needs: [publish]
timeout-minutes: 10
environment: container-publish
steps:
- name: Download digests
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4

View file

@ -2,6 +2,10 @@ name: E2E Desktop
on:
workflow_call:
outputs:
review_status:
description: Screenshot and visual-diff status for the CI review comment.
value: ${{ jobs.e2e.outputs.review_status }}
permissions:
contents: read
@ -15,6 +19,8 @@ jobs:
name: Playwright E2E (Linux)
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
review_status: ${{ steps.review-status.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -58,7 +64,8 @@ jobs:
command: uv sync --locked --python 3.11 --extra all --extra dev
# ── Build desktop app ─────────────────────────────────────────────
- run: npm run --prefix apps/desktop build
# The Playwright step below runs `npm run build` before testing so
# dist/ is always fresh — no separate build step needed here.
# ── Restore visual baseline screenshots from main ──────────────────
# Baselines are generated on main (via --update-snapshots) and cached.
@ -79,16 +86,18 @@ jobs:
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
# window always has a consistent viewport for screenshot comparison.
# On main, we run with --update-snapshots to generate baselines.
# `npm run test:e2e` builds dist/ as a pretest hook so the renderer
# is always fresh — no separate build step needed.
- name: Run Playwright E2E tests
working-directory: apps/desktop
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "On main — generating/updating baseline screenshots"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
env:
@ -143,6 +152,38 @@ jobs:
overwrite: true
if-no-files-found: ignore
- name: Build screenshot review status
id: review-status
if: always()
working-directory: apps/desktop
env:
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
run: |
python3 ../../scripts/ci/e2e_screenshot_status.py \
--results-dir test-results \
--manifest-output /tmp/e2e-screenshot-manifest.json \
--evidence-dir /tmp/e2e-evidence \
--artifact-url "$RESULTS_URL" \
--output /tmp/e2e-review-status.json
{
echo 'review_status<<__E2E_REVIEW_STATUS__'
cat /tmp/e2e-review-status.json
echo '__E2E_REVIEW_STATUS__'
} >> "$GITHUB_OUTPUT"
# The trusted workflow_run publisher consumes only this flat, bounded
# artifact. It turns selected images into GitHub attachment URLs; it
# never checks out or runs this PR's code.
- name: Upload inline E2E evidence
if: always() && github.ref_name != 'main'
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
with:
name: e2e-evidence-${{ github.sha }}
path: /tmp/e2e-evidence
retention-days: 14
overwrite: true
if-no-files-found: error
# ── Generate step summary with visual diff info ───────────────────
# Parse the JSON report + scan for diff images, then post a summary
# to the GitHub Actions step output so reviewers can see what changed
@ -156,49 +197,50 @@ jobs:
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
run: |
echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
{
echo "## Desktop E2E — Visual Diff Report"
echo ""
# Count diff images (playwright writes *-diff.png on mismatch)
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
if [ "$DIFF_COUNT" -eq 0 ]; then
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY"
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)."
else
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY"
echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY"
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**"
echo ""
echo "| Test | Diff | Actual | Expected |"
echo "|------|------|--------|----------|"
# List each diff image with a link to the artifact
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
base=$(echo "$diff" | sed 's/-diff\.png$//')
base=${diff%-diff.png}
test_name=$(basename "$base")
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |"
done
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo ""
echo "📥 **Artifacts:**"
echo ""
if [ -n "$RESULTS_URL" ]; then
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces"
fi
if [ -n "$REPORT_URL" ]; then
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
echo "- [playwright-report]($REPORT_URL) — interactive HTML report"
fi
if [ -n "$DIFFS_URL" ]; then
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)"
fi
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY"
echo ""
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally."
# Also parse the JSON report for pass/fail counts
if [ -f playwright-report/results.json ]; then
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Test Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo ""
echo "### Test Results"
echo ""
node -e "
const r = require('./playwright-report/results.json');
const stats = r.stats || {};
@ -208,5 +250,6 @@ jobs:
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
" 2>/dev/null || true
fi
} >> "$GITHUB_STEP_SUMMARY"

View file

@ -122,6 +122,7 @@ jobs:
if: needs.generate-patch.outputs.has-fixes == 'true'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
permissions:
contents: write # needed to push to bot/js-autofix
pull-requests: write # needed for PR creation + auto-merge
@ -132,7 +133,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Download patch

View file

@ -48,6 +48,9 @@ jobs:
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=website/package-lock.json
# The upstream reusable workflow uploads this exact file under its
# fixed artifact name, which the wrapper downloads below.
results-file-name: osv-results.sarif
fail-on-vuln: false
emit-status:
@ -64,7 +67,7 @@ jobs:
- name: Download SARIF result
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: osv-results
name: OSV Scanner SARIF file
path: /tmp/osv-results
continue-on-error: true

View file

@ -0,0 +1,71 @@
name: Publish E2E evidence
# This runs only from the default branch after CI completes. It intentionally
# checks out main, never the PR ref, and treats the downloaded artifact as
# untrusted input before uploading validated GitHub attachments.
on:
workflow_run:
workflows: [CI]
types: [completed]
permissions:
actions: read
contents: read
pull-requests: write
concurrency:
group: publish-e2e-evidence-${{ github.event.workflow_run.id }}
cancel-in-progress: false
jobs:
publish:
name: Publish inline E2E evidence
if: github.event.workflow_run.event == 'pull_request'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: gh-image
steps:
- name: Check out trusted publisher
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
ref: ${{ github.event.repository.default_branch }}
persist-credentials: false
# v1.2.0 resolves to 44f4b93ecbbe22de6c45fa2f62f519aee564ca8c.
- name: Install gh-image
env:
GH_TOKEN: ${{ github.token }}
run: gh extension install drogers0/gh-image --pin v1.2.0
- name: Download and attach evidence
env:
GH_TOKEN: ${{ github.token }}
GITHUB_TOKEN: ${{ github.token }}
GH_SESSION_TOKEN: ${{ secrets.GH_IMAGE_SESSION_TOKEN }}
SOURCE_REPO: ${{ github.repository }}
SOURCE_RUN_ID: ${{ github.event.workflow_run.id }}
run: |
set -euo pipefail
PR_NUMBER=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID" --jq '.pull_requests[0].number // empty')
if [ -z "$PR_NUMBER" ]; then
echo "No pull request is associated with CI run $SOURCE_RUN_ID."
exit 0
fi
ARTIFACT_NAME=$(gh api "repos/$SOURCE_REPO/actions/runs/$SOURCE_RUN_ID/artifacts" \
--jq '.artifacts[] | select(.expired == false and (.name | startswith("e2e-evidence-"))) | .name' \
| python3 -c 'import sys; print(next(iter(sys.stdin), "").strip())')
if [ -z "$ARTIFACT_NAME" ]; then
echo "No E2E evidence artifact was produced for CI run $SOURCE_RUN_ID."
exit 0
fi
EVIDENCE_DIR="$RUNNER_TEMP/e2e-evidence"
mkdir -p "$EVIDENCE_DIR"
gh run download "$SOURCE_RUN_ID" --repo "$SOURCE_REPO" --name "$ARTIFACT_NAME" --dir "$EVIDENCE_DIR"
python3 scripts/ci/publish_e2e_evidence.py \
--evidence-dir "$EVIDENCE_DIR" \
--source-repo "$SOURCE_REPO" \
--pr-number "$PR_NUMBER"

View file

@ -23,6 +23,10 @@ on:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
type: boolean
default: false
ci_review_files:
description: JSON list of CI-sensitive files changed by the pull request.
type: string
default: '[]'
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
@ -78,18 +82,25 @@ jobs:
id: build-status
env:
CI_REVIEW: ${{ inputs.ci_review }}
CI_REVIEW_FILES: ${{ inputs.ci_review_files }}
MCP_CATALOG: ${{ inputs.mcp_catalog }}
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
args=()
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
args+=(--ci-review-files "$CI_REVIEW_FILES")
if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT"
python3 scripts/ci/emit_review_status.py "${args[@]}" \
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
--output "$GITHUB_OUTPUT"
- name: Fail on missing label
if: steps.label-check.outputs.ci_reviewed != 'true'

View file

@ -21,6 +21,7 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 10
environment: trusted-automation
steps:
- name: Probe live index
id: probe
@ -113,7 +114,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Open issue on degraded / failed probe

View file

@ -21,6 +21,7 @@ jobs:
if: github.repository == 'NousResearch/hermes-agent'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@ -28,7 +29,7 @@ jobs:
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
@ -60,12 +61,13 @@ jobs:
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
runs-on: ubuntu-latest
timeout-minutes: 15
environment: trusted-automation
steps:
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
client-id: ${{ vars.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Trigger Deploy Site workflow
env:

View file

@ -65,17 +65,11 @@ jobs:
with:
fetch-depth: 0
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Scan diff for critical patterns
id: scan
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
GH_TOKEN: ${{ github.token }}
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
run: |
set -euo pipefail
@ -93,7 +87,7 @@ jobs:
# --- .pth files (auto-execute on Python startup) ---
# The exact mechanism used in the litellm supply chain attack:
# https://github.com/BerriAI/litellm/issues/24512
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
@ -141,8 +135,11 @@ jobs:
# auto-loaded by the interpreter via site.py. Any nested file with the
# same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated
# and produced false positives that trained reviewers to ignore the scanner.
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
if [ -n "$SETUP_HITS" ]; then
SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
# A maintainer-applied ci-reviewed label records the manual review
# required for intentional changes to an install hook. The scanner
# still blocks every unreviewed addition or modification.
if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: Install-hook file added or modified
These files can execute code during package installation or interpreter startup.

View file

@ -215,11 +215,6 @@ jobs:
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Packaged-wheel i18n smoke test
run: |
source .venv/bin/activate
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
- name: Run e2e tests
run: |
source .venv/bin/activate

View file

@ -1,188 +0,0 @@
name: Publish to PyPI
# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15)
# Can also be triggered manually from the Actions tab as an escape hatch.
on:
push:
tags:
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
workflow_dispatch:
inputs:
confirm_tag:
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
required: true
type: string
# Restrict default token to read-only; each job escalates as needed.
permissions:
contents: read
# Prevent overlapping publishes (e.g. two same-day tags pushed quickly).
concurrency:
group: pypi-publish
cancel-in-progress: false
jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
ref: ${{ inputs.confirm_tag || github.ref }}
fetch-tags: true
- name: Validate tag exists
if: github.event_name == 'workflow_dispatch'
run: |
if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then
echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo"
exit 1
fi
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Build web dashboard
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: web
- name: Compile web dashboard
run: npm run build
working-directory: web
- name: Build TUI bundle
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: ui-tui
- name: Compile TUI bundle
run: npm run build
working-directory: ui-tui
- name: Bundle TUI into hermes_cli
run: |
mkdir -p hermes_cli/tui_dist
cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js
- name: Verify frontend assets exist
run: |
test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; }
test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; }
- name: Bundle install scripts into wheel
run: |
mkdir -p hermes_cli/scripts
cp scripts/install.sh hermes_cli/scripts/install.sh
cp scripts/install.ps1 hermes_cli/scripts/install.ps1
- name: Build wheel and sdist
run: uv build --sdist --wheel
- name: Upload distribution artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-package-distributions
path: dist/
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: pypi
url: https://pypi.org/p/hermes-agent
permissions:
id-token: write # OIDC trusted publishing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
sign:
name: Sign and attach to GitHub Release
# Only runs on tag pushes — release.py creates the GitHub Release,
# and workflow_dispatch won't have a matching release to attach to.
if: startsWith(github.ref, 'refs/tags/')
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # attach assets to the existing release
id-token: write # sigstore signing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
for i in $(seq 1 30); do
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME found"
exit 0
fi
echo "Waiting for release... ($i/30)"
sleep 10
done
echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload"
echo "skip_sign=true" >> "$GITHUB_ENV"
- name: Sign with Sigstore
if: env.skip_sign != 'true'
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.tar.gz
./dist/*.whl
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
gh release upload
"$GITHUB_REF_NAME" dist/*.sigstore.json
--repo "$GITHUB_REPOSITORY"
--clobber

5
.gitignore vendored
View file

@ -150,6 +150,11 @@ docs/superpowers/*
.update-incomplete
.update-incomplete.lock
# Installer-written method stamp in the managed checkout root (scripts/install.sh).
# Runtime metadata only — never a code change. Ignore so `git status` stays clean
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
/.install_method
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/

View file

@ -1,17 +0,0 @@
graft skills
graft optional-skills
graft optional-mcps
graft hermes_cli/web_dist
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
# below covers the wheel; this covers the sdist. See #34034 / #28149.
recursive-include plugins plugin.yaml plugin.yml
# Include the closed shared-metrics package schema in downstream sdists so the
# smoke test, documentation, and external validators can inspect the contract.
recursive-include hermes_cli/observability/schemas *.json
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
recursive-include gateway/assets *
global-exclude __pycache__
global-exclude *.py[cod]

View file

@ -190,7 +190,7 @@ def _run_setup_browser(assume_yes: bool = False) -> int:
"""Bootstrap agent-browser + Chromium.
Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code
with ``hermes postinstall`` and the runtime lazy installer.
with the runtime lazy installer.
Returns 0 on success, 1 on failure.
"""

View file

@ -74,6 +74,10 @@ from acp_adapter.permissions import make_approval_callback
from acp_adapter.provenance import session_provenance_meta
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
from acp_adapter.tools import build_tool_complete, build_tool_start
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
ContextCompressor,
)
from tools.approval import (
reset_hermes_interactive_context,
set_hermes_interactive_context,
@ -969,11 +973,49 @@ class HermesACPAgent(acp.Agent):
return text
return ""
@staticmethod
def _history_summary_meta(message: dict[str, Any], text: str) -> dict[str, Any] | None:
"""Build the ``_meta`` payload for a replayed compaction summary.
Compaction summaries are persisted as ordinary history messages
standalone handoffs under ``role="user"`` OR ``role="assistant"``
(the compressor picks whichever role keeps alternation valid), and
merge-into-tail messages where the summary is appended after the
first preserved tail message's real content. Without a wire flag,
ACP frontends render all of these as ordinary turns.
Two distinct keys under ``_meta.hermes`` (ACP's extensibility
channel), so clients cannot accidentally hide real content:
* ``compactionSummary: true`` the entire chunk is the handoff
summary. Safe to restyle or collapse wholesale.
* ``containsCompactionSummary: true`` a merged-tail message: real
preserved turn content followed by the summary. Clients may style
it, but collapsing the whole chunk would hide the preserved
content, hence the separate key.
Detection honors the in-process ``_compressed_summary`` flag and
falls back to content classification, so it also works for a
DB-reloaded session that lost the in-memory flag.
"""
kind = ContextCompressor.classify_summary_content(text)
if kind is None and message.get(COMPRESSED_SUMMARY_METADATA_KEY):
# Flagged in-process but content didn't classify (e.g. future
# prefix drift): treat as a standalone summary — the flag is only
# ever set on summary-bearing messages.
kind = "standalone"
if kind == "standalone":
return {"hermes": {"compactionSummary": True}}
if kind == "merged":
return {"hermes": {"containsCompactionSummary": True}}
return None
@staticmethod
def _history_message_update(
*,
role: str,
text: str,
field_meta: dict[str, Any] | None = None,
) -> UserMessageChunk | AgentMessageChunk | None:
"""Build an ACP history replay update for a user/assistant message."""
block = TextContentBlock(type="text", text=text)
@ -981,11 +1023,13 @@ class HermesACPAgent(acp.Agent):
return UserMessageChunk(
session_update="user_message_chunk",
content=block,
field_meta=field_meta,
)
if role == "assistant":
return AgentMessageChunk(
session_update="agent_message_chunk",
content=block,
field_meta=field_meta,
)
return None
@ -1056,7 +1100,11 @@ class HermesACPAgent(acp.Agent):
if role == "user":
text = self._history_message_text(message)
if text:
update = self._history_message_update(role=role, text=text)
update = self._history_message_update(
role=role,
text=text,
field_meta=self._history_summary_meta(message, text),
)
if update is not None and not await _send(update):
return
continue
@ -1068,7 +1116,11 @@ class HermesACPAgent(acp.Agent):
text = self._history_message_text(message)
if text:
update = self._history_message_update(role=role, text=text)
update = self._history_message_update(
role=role,
text=text,
field_meta=self._history_summary_meta(message, text),
)
if update is not None and not await _send(update):
return
@ -1218,12 +1270,19 @@ class HermesACPAgent(acp.Agent):
with state.runtime_lock:
if state.is_running and state.current_prompt_text:
state.interrupted_prompt_text = state.current_prompt_text
state.cancel_event.set()
try:
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
state.agent.interrupt()
except Exception:
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
# Publish cancellation and hard-stop the agent before another
# prompt can acquire this lock and mistake the turn for
# redirectable work.
state.cancel_event.set()
try:
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
state.agent.interrupt()
except Exception:
logger.debug(
"Failed to interrupt ACP session %s",
session_id,
exc_info=True,
)
logger.info("Cancelled session %s", session_id)
async def fork_session(
@ -1352,6 +1411,26 @@ class HermesACPAgent(acp.Agent):
elif rewrite_idle:
user_text = steer_text
user_content = steer_text
elif (
text_only_prompt
and isinstance(user_content, str)
and not user_text.startswith("/")
):
# Some ACP clients implement "stop and send" as two protocol calls:
# cancel the active prompt, then submit plain correction text. Keep
# the cancelled request attached so deictic follow-ups ("not that
# file") still have an explicit target.
interrupted_prompt = ""
with state.runtime_lock:
if not state.is_running and state.interrupted_prompt_text:
interrupted_prompt = state.interrupted_prompt_text
state.interrupted_prompt_text = ""
if interrupted_prompt:
user_text = (
f"{interrupted_prompt}\n\n"
f"User correction/guidance after interrupt: {user_text}"
)
user_content = user_text
# Intercept slash commands — handle locally without calling the LLM.
# Slash commands are text-only; if the client included images/resources,
@ -1366,23 +1445,54 @@ class HermesACPAgent(acp.Agent):
await self._send_usage_update(state)
return PromptResponse(stop_reason="end_turn")
# If Zed sends another regular prompt while the same ACP session is
# still running, queue it instead of racing two AIAgent loops against
# the same state.history. /steer and /queue are handled above and can
# land immediately.
# If the client sends another regular text prompt while this ACP session
# is running, route it through the core active-turn redirect. Rich media
# and older runtimes retain the proven next-turn queue fallback.
redirected = False
queued_depth: int | None = None
with state.runtime_lock:
if state.is_running:
queued_text = user_text or "[Image attachment]"
state.queued_prompts.append(queued_text)
depth = len(state.queued_prompts)
if self._conn:
update = acp.update_agent_message_text(
f"Queued for the next turn. ({depth} queued)"
if (
text_only_prompt
and isinstance(user_content, str)
and getattr(
state.agent,
"_supports_active_turn_redirect",
False,
)
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
state.is_running = True
state.current_prompt_text = user_text or "[Image attachment]"
is True
and hasattr(state.agent, "redirect")
):
try:
redirected = bool(state.agent.redirect(user_content))
except Exception:
logger.debug(
"ACP active-turn redirect failed for %s",
session_id,
exc_info=True,
)
if not redirected:
queued_text = user_text or "[Image attachment]"
state.queued_prompts.append(queued_text)
queued_depth = len(state.queued_prompts)
else:
state.is_running = True
state.current_prompt_text = user_text or "[Image attachment]"
if redirected:
if self._conn:
update = acp.update_agent_message_text(
"Redirected the active turn with your correction."
)
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
if queued_depth is not None:
if self._conn:
update = acp.update_agent_message_text(
f"Queued for the next turn. ({queued_depth} queued)"
)
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
@ -1911,7 +2021,10 @@ class HermesACPAgent(acp.Agent):
lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens")
if getattr(agent, "compression_enabled", True) is False:
lines.append("Compression is disabled for this agent.")
lines.append(
"Auto-compaction is disabled (compression.enabled: false); "
"/compress still compresses manually."
)
else:
lines.append("Tip: run /compress to compress manually before the threshold.")
@ -1938,8 +2051,9 @@ class HermesACPAgent(acp.Agent):
return "Nothing to compress — conversation is empty."
try:
agent = state.agent
if not getattr(agent, "compression_enabled", True):
return "Context compression is disabled for this agent."
# No compression_enabled gate: the flag disables *automatic*
# compaction only; manual /compress must keep working (matches
# the CLI /compress and gateway handlers).
if not hasattr(agent, "_compress_context"):
return "Context compression not available for this agent."
@ -1964,6 +2078,7 @@ class HermesACPAgent(acp.Agent):
getattr(agent, "_cached_system_prompt", "") or "",
approx_tokens=approx_tokens,
task_id=state.session_id,
force=True,
)
finally:
agent._session_db = original_session_db

View file

@ -1,16 +0,0 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.19.0",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
"authors": ["Nous Research"],
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.19.0",
"args": ["hermes-acp"]
}
}
}

View file

@ -1,8 +0,0 @@
<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>

Before

Width:  |  Height:  |  Size: 882 B

View file

@ -701,6 +701,18 @@ def redeem_codex_reset_credit(
remaining = max(0, available - 1)
plural = "s" if remaining != 1 else ""
if code == "reset":
# The redeemed reset restores the account's quota upstream — lift any
# persisted pool cooldowns so Hermes doesn't keep the credential
# frozen behind the now-stale ``last_error_reset_at`` (issue #43747).
try:
from hermes_cli.auth import clear_codex_pool_quota_cooldowns
clear_codex_pool_quota_cooldowns()
except Exception:
logger.debug(
"Failed to clear Codex pool cooldowns after reset redemption",
exc_info=True,
)
return CodexResetRedeemResult(
status="reset",
message=(

View file

@ -480,6 +480,7 @@ def init_agent(
checkpoint_max_total_size_mb: int = 500,
checkpoint_max_file_size_mb: int = 10,
pass_session_id: bool = False,
requested_provider: str = None,
):
"""
Initialize the AI Agent.
@ -488,6 +489,7 @@ def init_agent(
base_url (str): Base URL for the model API (optional)
api_key (str): API key for authentication (optional, uses env var if not provided)
provider (str): Provider identifier (optional; used for telemetry/routing hints)
requested_provider (str): Original provider identity before runtime canonicalization
api_mode (str): API mode override: "chat_completions" or "codex_responses"
model (str): Model name to use (default: "anthropic/claude-opus-4.6")
max_iterations (int): Maximum number of tool calling iterations (default: 90)
@ -568,6 +570,11 @@ def init_agent(
agent.base_url = base_url or ""
provider_name = provider.strip().lower() if isinstance(provider, str) and provider.strip() else None
agent.provider = provider_name or ""
agent.requested_provider = (
requested_provider.strip().lower()
if isinstance(requested_provider, str) and requested_provider.strip()
else agent.provider
)
agent._credential_pool = credential_pool
agent.acp_command = acp_command or command
agent.acp_args = list(acp_args or args or [])
@ -720,6 +727,8 @@ def init_agent(
agent._execution_thread_id: int | None = None # Set at run_conversation() start
agent._interrupt_thread_signal_pending = False
agent._client_lock = threading.RLock()
agent._model_request_active = threading.Event()
agent._supports_active_turn_redirect = True
# /steer mechanism — inject a user note into the next tool result
# without interrupting the agent. Unlike interrupt(), steer() does
@ -731,6 +740,13 @@ def init_agent(
agent._pending_steer: Optional[str] = None
agent._pending_steer_lock = threading.Lock()
# Active-turn redirect mechanism. A regular follow-up sent while the model
# is generating is different from a hard /stop: preserve the valid turn
# prefix, cancel only the in-flight model request, and rebuild its tail with
# the correction. The loop drains this slot at a role-safe boundary.
agent._pending_redirect: Optional[str] = None
agent._pending_redirect_lock = threading.Lock()
# Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent`
# runs each tool on its own ThreadPoolExecutor worker — those worker
# threads have tids distinct from `_execution_thread_id`, so
@ -897,6 +913,12 @@ def init_agent(
agent._stream_writer_tls = threading.local()
agent._stream_writer_dropped = 0
# Displayed reasoning text streamed during the current model response,
# captured only when a surface consumed it via a reasoning callback. Used
# by active-turn redirect to checkpoint what the user actually saw without
# ever persisting hidden provider reasoning.
agent._current_streamed_reasoning_text = ""
# Optional current-turn user-message override used when the API-facing
# user message intentionally differs from the persisted transcript
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
@ -1869,6 +1891,12 @@ def init_agent(
codex_app_server_auto_compaction,
)
codex_app_server_auto_compaction = "native"
# Opt-in idle compaction: compact a session up front when it resumes after
# this many seconds of inactivity (0 = disabled). Time-based, so it
# complements the size-based threshold above. Consumed by build_turn_context().
compression_idle_compact_after_seconds = max(
0, int(_compression_cfg.get("idle_compact_after_seconds", 0))
)
# Read optional explicit context_length override for the auxiliary
# compression model. Custom endpoints often cannot report this via
@ -2295,6 +2323,9 @@ def init_agent(
agent.compression_in_place = compression_in_place
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
agent.max_compression_attempts = compression_max_attempts
agent.compression_idle_compact_after_seconds = (
compression_idle_compact_after_seconds
)
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@ -2545,6 +2576,7 @@ def init_agent(
agent._primary_runtime = {
"model": agent.model,
"provider": agent.provider,
"requested_provider": agent.requested_provider,
"base_url": agent.base_url,
"api_mode": agent.api_mode,
"api_key": getattr(agent, "api_key", ""),

View file

@ -922,6 +922,18 @@ def recover_with_credential_pool(
)
return False, has_retried_429
# Capture the current API key before any rotation — needed to
# identify which credential actually failed when
# mark_exhausted_and_rotate is called. Without this hint the
# pool falls back to current() or _select_unlocked(), which may
# return the NEXT (healthy) entry after a prior rotation, marking
# the wrong credential as exhausted (#43747).
_api_key_hint = getattr(agent, "api_key", None) or None
if not _api_key_hint:
_cur = pool.current()
if _cur:
_api_key_hint = getattr(_cur, "runtime_api_key", None)
effective_reason = classified_reason
if effective_reason is None:
if status_code == 402:
@ -952,13 +964,13 @@ def recover_with_credential_pool(
if effective_reason == FailoverReason.billing:
rotate_status = status_code if status_code is not None else 402
# Runtime credentials can be resolved by a separate pool instance,
# leaving this recovery pool without ``current_id``. Match the key
# that actually failed instead of quarantining a different account.
next_entry = pool.mark_exhausted_and_rotate(
status_code=rotate_status,
error_context=error_context,
# Runtime credentials can be resolved by a separate pool instance,
# leaving this recovery pool without ``current_id``. Match the key
# that actually failed instead of quarantining a different account.
api_key_hint=getattr(agent, "api_key", None),
api_key_hint=_api_key_hint,
)
if next_entry is not None:
_ra().logger.info(
@ -983,7 +995,7 @@ def recover_with_credential_pool(
current_last_status,
)
rotate_status = status_code if status_code is not None else 429
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit, pre-exhausted) — rotated to pool entry %s",
@ -1007,7 +1019,7 @@ def recover_with_credential_pool(
if not has_retried_429 and not usage_limit_reached:
return False, True
rotate_status = status_code if status_code is not None else 429
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
if next_entry is not None:
_ra().logger.info(
"Credential %s (rate limit) — rotated to pool entry %s",
@ -1107,7 +1119,7 @@ def recover_with_credential_pool(
# Refresh failed — rotate to next credential instead of giving up.
# The failed entry is already marked exhausted by try_refresh_current().
rotate_status = status_code if status_code is not None else 401
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context)
next_entry = pool.mark_exhausted_and_rotate(status_code=rotate_status, error_context=error_context, api_key_hint=_api_key_hint)
if next_entry is not None:
_ra().logger.info(
"Credential %s (auth refresh failed) — rotated to pool entry %s",
@ -1166,6 +1178,7 @@ def try_recover_primary_transport(
agent._client_kwargs = dict(rt["client_kwargs"])
agent.model = rt["model"]
agent.provider = rt["provider"]
agent.requested_provider = rt.get("requested_provider", agent.provider)
agent.base_url = rt["base_url"]
agent.api_mode = rt["api_mode"]
if hasattr(agent, "_transport_cache"):
@ -1329,6 +1342,7 @@ def restore_primary_runtime(agent) -> bool:
# ── Core runtime state ──
agent.model = rt["model"]
agent.provider = rt["provider"]
agent.requested_provider = rt.get("requested_provider", agent.provider)
agent.base_url = rt["base_url"] # setter updates _base_url_lower
agent.api_mode = rt["api_mode"]
if hasattr(agent, "_transport_cache"):
@ -1993,6 +2007,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
for name in (
"model",
"provider",
"requested_provider",
"base_url",
"api_mode",
"api_key",
@ -2021,6 +2036,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
# ── Swap core runtime fields ──
agent.model = new_model
agent.provider = new_provider
agent.requested_provider = new_provider
# Use the new base_url when provided. When it's empty AND the
# provider is actually changing, do NOT fall back to the current
# (old provider's) URL — that silently pairs the new provider label
@ -2270,6 +2286,7 @@ def switch_model(agent, new_model, new_provider, api_key='', base_url='', api_mo
agent._primary_runtime = {
"model": agent.model,
"provider": agent.provider,
"requested_provider": agent.requested_provider,
"base_url": agent.base_url,
"api_mode": agent.api_mode,
"api_key": getattr(agent, "api_key", ""),

View file

@ -2537,6 +2537,7 @@ def set_runtime_main(
provider: str,
model: str,
*,
requested_provider: str = "",
base_url: str = "",
api_key: Any = "",
api_mode: str = "",
@ -2552,6 +2553,7 @@ def set_runtime_main(
global _RUNTIME_MAIN_AUTH_MODE, _RUNTIME_MAIN_COMPAT_SNAPSHOT
runtime = {
"provider": (provider or "").strip().lower(),
"requested_provider": (requested_provider or "").strip().lower(),
"model": (model or "").strip(),
"base_url": (base_url or "").strip(),
"api_key": (
@ -3024,6 +3026,7 @@ _AUTO_PROVIDER_LABELS = {
}
_MAIN_RUNTIME_FIELDS = ("provider", "model", "base_url", "api_key", "api_mode", "auth_mode")
_MAIN_RUNTIME_CONTEXT_FIELDS = _MAIN_RUNTIME_FIELDS + ("requested_provider",)
def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str, Any]:
@ -3046,7 +3049,7 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
if not isinstance(main_runtime, dict):
return {}
normalized: Dict[str, Any] = {}
for field in _MAIN_RUNTIME_FIELDS:
for field in _MAIN_RUNTIME_CONTEXT_FIELDS:
value = main_runtime.get(field)
# Preserve a callable api_key (Entra ID bearer provider) unchanged.
if field == "api_key" and callable(value) and not isinstance(value, str):
@ -3054,9 +3057,10 @@ def _normalize_main_runtime(main_runtime: Optional[Dict[str, Any]]) -> Dict[str,
continue
if isinstance(value, str) and value.strip():
normalized[field] = value.strip()
provider = normalized.get("provider")
if isinstance(provider, str):
normalized["provider"] = provider.lower()
for identity_field in ("provider", "requested_provider"):
identity = normalized.get(identity_field)
if isinstance(identity, str):
normalized[identity_field] = identity.lower()
return normalized

124
agent/billing_links.py Normal file
View file

@ -0,0 +1,124 @@
"""Provider-agnostic billing/credit recovery links.
Maps a billing-classified failure onto a recovery link + label. *Detection*
is not done here that is :mod:`agent.error_classifier`
(``FailoverReason.billing``), the single source of truth for "credit wall vs.
rate limit / auth / transport". The resulting :class:`BillingBlock` rides the
turn result and the gateway ``message.complete`` event so every surface (CLI,
TUI, desktop) renders one structured signal instead of re-parsing error text.
"""
from __future__ import annotations
from dataclasses import asdict, dataclass
from typing import Optional
from utils import base_url_host_matches
@dataclass
class BillingBlock:
"""Structured billing-wall descriptor shared across every surface.
``is_nous`` is the routing bit: Nous has a first-class in-app billing surface
(desktop Settings Billing, TUI/CLI ``/topup``), so surfaces prefer that over
``billing_url``; third-party providers have no in-app flow, so ``billing_url``
is the deep link the user actually needs.
"""
provider: str
provider_label: str
model: str
billing_url: Optional[str]
is_nous: bool
message: str
def to_dict(self) -> dict:
return asdict(self)
@dataclass(frozen=True)
class _Provider:
label: str
url: str
slugs: tuple[str, ...]
hosts: tuple[str, ...] = ()
# Single source of truth: internal slug(s) + base_url host(s) → billing page.
# Curated "add credits / manage billing" landing pages, not marketing homes.
# Hosts back the OpenAI-compatible fallback where the slug is a generic bucket
# (e.g. "openai_compatible") but base_url reveals the real upstream. An unknown
# provider degrades to a readable label with no invented URL.
_PROVIDERS: tuple[_Provider, ...] = (
_Provider("OpenAI", "https://platform.openai.com/settings/organization/billing", ("openai",), ("api.openai.com",)),
_Provider("Anthropic", "https://console.anthropic.com/settings/billing", ("anthropic",), ("api.anthropic.com",)),
_Provider("OpenRouter", "https://openrouter.ai/settings/credits", ("openrouter",), ("openrouter.ai",)),
_Provider("xAI", "https://console.x.ai/team/default/billing", ("xai", "xai-oauth"), ("api.x.ai",)),
_Provider("DeepSeek", "https://platform.deepseek.com/top_up", ("deepseek",), ("api.deepseek.com",)),
_Provider("Groq", "https://console.groq.com/settings/billing", ("groq",), ("api.groq.com",)),
_Provider("Mistral", "https://console.mistral.ai/billing", ("mistral",), ("api.mistral.ai",)),
_Provider("Together AI", "https://api.together.ai/settings/billing", ("together",), ("api.together.ai", "api.together.xyz")),
_Provider("Fireworks AI", "https://fireworks.ai/account/billing", ("fireworks",), ("fireworks.ai",)),
_Provider("Perplexity", "https://www.perplexity.ai/settings/api", ("perplexity",), ("perplexity.ai",)),
_Provider("Google AI", "https://aistudio.google.com/app/billing", ("google", "gemini"), ("generativelanguage.googleapis.com",)),
_Provider("Cohere", "https://dashboard.cohere.com/billing", ("cohere",)),
_Provider("Moonshot AI", "https://platform.moonshot.ai/console/pay", ("moonshot",)),
_Provider("NVIDIA", "https://build.nvidia.com/settings/billing", ("nvidia",)),
)
_BY_SLUG: dict[str, _Provider] = {slug: p for p in _PROVIDERS for slug in p.slugs}
def is_nous_inference_route(provider: str, base_url: str) -> bool:
"""True when the failing route is the Nous-managed inference gateway."""
if (provider or "").strip().lower() == "nous":
return True
return base_url_host_matches(str(base_url or ""), "inference-api.nousresearch.com")
def _nous_billing_url() -> Optional[str]:
"""Best-effort Nous portal billing URL (text-surface fallback; Nous prefers the in-app flow)."""
try:
from hermes_cli.nous_account import nous_portal_billing_url
return nous_portal_billing_url(None)
except Exception:
return "https://portal.nousresearch.com/billing"
def _resolve_provider_link(slug: str, base_url: str) -> tuple[str, Optional[str]]:
"""Resolve ``(label, url)``: exact slug → base_url host → readable-label fallback."""
hit = _BY_SLUG.get(slug)
if hit:
return hit.label, hit.url
base = str(base_url or "")
for p in _PROVIDERS:
if any(base_url_host_matches(base, host) for host in p.hosts):
return p.label, p.url
return slug.replace("_", " ").replace("-", " ").strip().title() or "your provider", None
def build_billing_block(
*,
provider: str,
base_url: str,
model: str,
message: str = "",
) -> BillingBlock:
"""Build the billing descriptor for a billing-classified failure.
``message`` is the guidance already assembled by the agent loop
(:func:`agent.conversation_loop._billing_or_entitlement_message`), carried
through unchanged so every surface shows identical copy.
"""
slug = (provider or "").strip().lower()
model = (model or "").strip()
if is_nous_inference_route(slug, base_url):
return BillingBlock(slug or "nous", "Nous Portal", model, _nous_billing_url(), True, message or "")
label, url = _resolve_provider_link(slug, base_url)
return BillingBlock(slug, label, model, url, False, message or "")

View file

@ -1719,6 +1719,7 @@ def try_activate_fallback(agent, reason: "FailoverReason | None" = None) -> bool
agent._config_context_length = None
agent.model = fb_model
agent.provider = fb_provider
agent.requested_provider = fb_provider
agent.base_url = fb_base_url
agent.api_mode = fb_api_mode
if hasattr(agent, "_transport_cache"):

View file

@ -702,6 +702,16 @@ def run_codex_app_server_turn(
except Exception:
pass
agent._codex_session = None
_user_interrupted = bool(
getattr(agent, "_interrupt_requested", False)
)
_interrupt_message = (
getattr(agent, "_interrupt_message", None)
if _user_interrupted
else None
)
if _user_interrupted:
agent.clear_interrupt()
return {
"final_response": (
f"Codex app-server turn failed: {exc}. "
@ -711,9 +721,27 @@ def run_codex_app_server_turn(
"api_calls": 0,
"completed": False,
"partial": True,
"interrupted": _user_interrupted,
**(
{"interrupt_message": _interrupt_message}
if _interrupt_message
else {}
),
"error": str(exc),
}
# This runtime bypasses the normal conversation-loop finalizer. Mirror its
# interrupt handoff/cleanup so a hard stop cannot poison the next turn and a
# message-bearing compatibility interrupt can still be replayed by callers.
_user_interrupted = bool(
turn.interrupted and getattr(agent, "_interrupt_requested", False)
)
_interrupt_message = (
getattr(agent, "_interrupt_message", None) if _user_interrupted else None
)
if _user_interrupted:
agent.clear_interrupt()
# If the turn signalled the underlying client is wedged (deadline
# blown, post-tool watchdog tripped, OAuth refresh died, subprocess
# exited), retire the session so the next turn respawns codex
@ -819,6 +847,12 @@ def run_codex_app_server_turn(
"api_calls": api_calls,
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"interrupted": _user_interrupted,
**(
{"interrupt_message": _interrupt_message}
if _interrupt_message
else {}
),
"error": turn.error,
# The codex app-server runtime IS an early-return path that bypasses
# conversation_loop, but we flush the projected assistant/tool messages

View file

@ -288,6 +288,12 @@ _HISTORICAL_SUMMARY_PREFIXES = (
"config, etc.) may reflect work described here — avoid repeating it:",
)
# Restart handoff detection should be early and bounded: it needs to catch the
# restored protected head plus a small cluster of already-stacked handoff/ack
# turns, but it must not treat arbitrary summary-looking live-tail messages as
# proof that this is a resumed compacted session.
_RESTART_HANDOFF_PROBE_EXTRA_MESSAGES = 4
# Minimum tokens for the summary output
_MIN_SUMMARY_TOKENS = 2000
# Proportion of compressed content to allocate for summary
@ -318,6 +324,7 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
# only meant to preserve continuity anchors from the dropped window, not to
# become another unbounded transcript copy after the LLM summarizer failed.
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
_FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS = 3_000
_FALLBACK_TURN_MAX_CHARS = 700
_AUTO_FOCUS_MAX_TURNS = 3
_AUTO_FOCUS_TURN_MAX_CHARS = 260
@ -2305,9 +2312,17 @@ class ContextCompressor(ContextEngine):
)
previous_summary_note = ""
if self._previous_summary:
previous_summary = redact_sensitive_text(self._previous_summary.strip())
if len(previous_summary) > _FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS:
previous_summary = (
previous_summary[: _FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS - 45].rstrip()
+ "\n...[previous summary snapshot truncated]"
)
previous_summary_note = (
"\n\nPrevious compaction summary was present and should still be treated as "
"background continuity context, but the latest LLM summary update failed."
"\n\n## Previous Summary Snapshot\n"
f"{previous_summary}\n\n"
"The previous compaction summary above remains background "
"continuity context because the latest LLM summary update failed."
)
reason_text = f" Summary failure reason: {reason}." if reason else ""
@ -2978,11 +2993,15 @@ This compaction should PRIORITISE preserving all information related to the focu
if text.startswith(prefix):
text = text[len(prefix):].lstrip()
break
# Strip the trailing end marker too — a rehydrated handoff body that
# keeps it would leak the boundary directive into the iterative-update
# Strip the end marker too — a rehydrated handoff body that keeps it
# would leak the boundary directive into the iterative-update
# summarizer prompt (and the marker is re-appended on insertion anyway).
if text.endswith(_SUMMARY_END_MARKER):
text = text[: -len(_SUMMARY_END_MARKER)].rstrip()
# Forced user-leading merged summaries keep the live tail request after
# this marker, so truncate at the marker even when it is not the final
# content.
marker_idx = text.find(_SUMMARY_END_MARKER)
if marker_idx >= 0:
text = text[:marker_idx].rstrip()
return text
@classmethod
@ -2992,7 +3011,29 @@ This compaction should PRIORITISE preserving all information related to the focu
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
@staticmethod
def _is_context_summary_content(content: Any) -> bool:
def _starts_with_summary_prefix(text: str) -> bool:
"""Return True if *text* begins with any known handoff prefix."""
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
@classmethod
def classify_summary_content(cls, content: Any) -> Optional[str]:
"""Classify how *content* relates to a compaction summary.
Returns:
``"standalone"``: the entire message IS a compaction handoff
(current, legacy, or historical prefix at the start). Frontends
may restyle/collapse the whole message as a summary.
``"merged"``: a merge-into-tail message real preserved turn
content wrapped under ``_MERGED_PRIOR_CONTEXT_HEADER``, followed by
``_MERGED_SUMMARY_DELIMITER`` and the summary body. The message
*contains* a summary but is not only a summary; collapsing the
whole message would hide the preserved content.
``None``: no compaction summary detected.
"""
text = _content_text_for_contains(content).lstrip()
# Merge-into-tail summaries wrap prior tail content before the summary,
# so the handoff prefix lands after _MERGED_SUMMARY_DELIMITER rather than
@ -3000,10 +3041,13 @@ This compaction should PRIORITISE preserving all information related to the focu
# (auto-focus skip, carry-forward summary find, last-real-user anchor)
# mistake a merged summary message for a real user turn.
if _MERGED_SUMMARY_DELIMITER in text:
text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip()
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
return True
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
after = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip()
return "merged" if cls._starts_with_summary_prefix(after) else None
return "standalone" if cls._starts_with_summary_prefix(text) else None
@classmethod
def _is_context_summary_content(cls, content: Any) -> bool:
return cls.classify_summary_content(content) is not None
@staticmethod
def _has_compressed_summary_metadata(message: Any) -> bool:
@ -3081,6 +3125,15 @@ This compaction should PRIORITISE preserving all information related to the focu
"session with no user-authored turns"
)
@classmethod
def _is_context_summary_message(cls, message: Any) -> bool:
"""Return True for summary handoff messages by metadata or content."""
if not isinstance(message, dict):
return False
return cls._has_compressed_summary_metadata(
message
) or cls._is_context_summary_content(message.get("content"))
@classmethod
def _is_blank_user_turn(cls, message: Any) -> bool:
"""Return whether *message* is an empty, non-summary user-role echo."""
@ -3239,6 +3292,24 @@ This compaction should PRIORITISE preserving all information related to the focu
return grounded.strip()
return f"{replacement}{body}".strip()
@classmethod
def _find_context_summaries(
cls,
messages: List[Dict[str, Any]],
start: int,
end: int,
) -> list[tuple[int, str]]:
"""Find handoff summaries inside a compression window."""
summaries: list[tuple[int, str]] = []
for idx in range(start, end):
content = messages[idx].get("content")
if cls._is_context_summary_message(messages[idx]):
summaries.append((
idx,
cls._strip_summary_prefix(_content_text_for_contains(content)),
))
return summaries
@classmethod
def _find_latest_context_summary(
cls,
@ -3247,10 +3318,9 @@ This compaction should PRIORITISE preserving all information related to the focu
end: int,
) -> tuple[Optional[int], str]:
"""Find the newest handoff summary inside a compression window."""
for idx in range(end - 1, start - 1, -1):
content = messages[idx].get("content")
if cls._is_context_summary_content(content):
return idx, cls._strip_summary_prefix(_content_text_for_contains(content))
summaries = cls._find_context_summaries(messages, start, end)
if summaries:
return summaries[-1]
return None, ""
@classmethod
@ -3471,7 +3541,25 @@ This compaction should PRIORITISE preserving all information related to the focu
idx += 1
return idx
def _effective_protect_first_n(self) -> int:
def _restart_handoff_probe_bounds(
self,
messages: List[Dict[str, Any]],
) -> tuple[int, int]:
"""Return the bounded transcript region that can indicate restart decay."""
if not messages or self.protect_first_n <= 0:
return 0, 0
first_non_system = 1 if messages[0].get("role") == "system" else 0
return first_non_system, min(
len(messages),
first_non_system
+ self.protect_first_n
+ _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES,
)
def _effective_protect_first_n(
self,
messages: Optional[List[Dict[str, Any]]] = None,
) -> int:
"""``protect_first_n`` decayed across compression cycles.
``protect_first_n`` keeps the first N non-system messages verbatim so
@ -3483,9 +3571,24 @@ This compaction should PRIORITISE preserving all information related to the focu
once, the early turns are already captured in the handoff summary, so
there's no need to keep re-protecting them: decay to 0 (the system
prompt is still always protected separately by _protect_head_size).
After a restart, infer that decayed state from handoff summaries in the
resumed-head region; disk-persisted restarts rely on the content prefix,
while the metadata branch covers in-process handoff messages.
"""
if self.compression_count >= 1 or self._previous_summary:
return 0
if messages and self.protect_first_n > 0:
# Probe only the early handoff shape created by a resumed compacted
# session. Summary-looking tail content should keep normal tail
# semantics and not decay the initial first-compaction protection.
first_non_system, restart_probe_end = self._restart_handoff_probe_bounds(
messages
)
if any(
self._is_context_summary_message(msg)
for msg in messages[first_non_system:restart_probe_end]
):
return 0
return self.protect_first_n
def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:
@ -3511,7 +3614,7 @@ This compaction should PRIORITISE preserving all information related to the focu
head = 0
if messages and messages[0].get("role") == "system":
head = 1
return head + self._effective_protect_first_n()
return head + self._effective_protect_first_n(messages)
def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int:
"""Pull a compress-end boundary backward to avoid splitting a
@ -3593,6 +3696,8 @@ This compaction should PRIORITISE preserving all information related to the focu
msg.get("content")
):
continue
if self._is_context_summary_message(msg):
continue
if last_any < 0:
last_any = i
content = msg.get("content")
@ -4059,23 +4164,45 @@ This compaction should PRIORITISE preserving all information related to the focu
return messages
turns_to_summarize = messages[compress_start:compress_end]
# Snapshot the rehydration state so an aborted attempt below can roll
# it back. The self-heal scan mutates ``_previous_summary`` (populating
# it from a fossil, or discarding a stale cross-session one); if
# summary generation then aborts and returns the transcript unchanged,
# leaving that mutation behind would make the retry — still
# ``compression_count == 0`` but now with a truthy ``_previous_summary``
# — take the narrow rescan, miss a beyond-window fossil, and discard the
# rehydrated state as cross-session leakage (#57835).
_previous_summary_before_scan = self._previous_summary
# A persisted handoff summary can sit in the protected head after a
# resume (commonly immediately after the system prompt). Search from
# the first non-system message through the compression window so we can
# rehydrate iterative-summary state without serializing that handoff as
# a new turn. Protected messages after the handoff remain live context,
# so only summarize messages that are both after the handoff and inside
# the current compression window.
# the first non-system message through the compression window. On the
# first compaction after a restart, extend through the full transcript
# so summaries that landed in the protected tail or drifted past the
# decay probe still rehydrate iterative-summary state instead of being
# copied forward as stacked fossils.
summary_search_start = 1 if messages and messages[0].get("role") == "system" else 0
summary_idx, summary_body = self._find_latest_context_summary(
summary_search_end = compress_end
if self.compression_count < 1 and not self._previous_summary:
summary_search_end = len(messages)
summary_search_end = min(len(messages), summary_search_end)
summary_indices: set[int] = set()
summary_idx = None
summary_body = None
tail_start = compress_end
summary_hits = self._find_context_summaries(
messages,
summary_search_start,
compress_end,
summary_search_end,
)
real_user_present = self._transcript_has_real_user_turn(messages)
if summary_idx is not None:
if summary_body and not self._previous_summary:
self._previous_summary = summary_body
if summary_hits:
summary_idx = summary_hits[-1][0]
summary_body = summary_hits[-1][1]
if not self._previous_summary:
summary_bodies = [body for _, body in summary_hits if body]
if summary_bodies:
self._previous_summary = "\n\n".join(summary_bodies)
# Zero-user provenance (#64650) rides on the newest handoff hit.
provenance = messages[summary_idx].get(
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY
)
@ -4090,7 +4217,42 @@ This compaction should PRIORITISE preserving all information related to the focu
self._summary_has_user_turn = not (
summary_body and _NO_USER_TASK_SENTINEL in summary_body
)
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
summary_indices = {idx for idx, _ in summary_hits}
# Summary rows are excluded from the summarizer input (their
# bodies already ride _previous_summary), BUT a merged handoff
# carries genuine prior-tail user content before the delimiter —
# unwrap it (via the strip helper) into the window instead of
# dropping it (#47274 interplay with the multi-fossil scan).
def _window_row(idx: int, msg: Dict[str, Any]):
if idx not in summary_indices:
return msg
stripped = self._strip_context_summary_handoff_message(
_fresh_compaction_message_copy(msg)
)
return stripped # None for standalone handoffs → dropped
pre_summary_turns = [
row for idx, msg in enumerate(
messages[compress_start:summary_idx],
start=compress_start,
)
if (row := _window_row(idx, msg)) is not None
]
turns_to_summarize = (
pre_summary_turns + messages[summary_idx + 1:compress_end]
)
# The newest hit itself may be a merged handoff too — recover its
# prior-tail content the same way.
_newest_stripped = self._strip_context_summary_handoff_message(
_fresh_compaction_message_copy(messages[summary_idx])
)
if _newest_stripped is not None:
turns_to_summarize = (
pre_summary_turns
+ [_newest_stripped]
+ messages[summary_idx + 1:compress_end]
)
if summary_idx >= compress_end:
tail_start = summary_idx + 1
elif self._previous_summary:
# No handoff summary found in the current messages, but
# _previous_summary is non-empty — it was set by a different
@ -4121,7 +4283,7 @@ This compaction should PRIORITISE preserving all information related to the focu
self.threshold_percent * 100,
self.threshold_tokens,
)
tail_msgs = n_messages - compress_end
tail_msgs = n_messages - tail_start
logger.info(
"Summarizing turns %d-%d (%d turns), protecting %d head + %d tail messages",
compress_start + 1,
@ -4174,6 +4336,11 @@ This compaction should PRIORITISE preserving all information related to the focu
telemetry["failure_class"] = "summary_network_failure"
else:
telemetry["failure_class"] = "summary_generation_aborted"
# Roll back the self-heal rehydration so this aborted attempt is a
# true no-op: the next attempt must re-run the full first-compaction
# scan instead of narrow-rescanning against a half-populated state
# and discarding a legitimately rehydrated fossil (#57835).
self._previous_summary = _previous_summary_before_scan
if not self.quiet_mode:
if self._last_summary_auth_failure:
logger.warning(
@ -4214,8 +4381,8 @@ This compaction should PRIORITISE preserving all information related to the focu
# _strip_context_summary_handoff_message() handles both shapes:
# standalone handoffs strip to None (dropped), merged handoffs
# unwrap to their genuine prior-tail content (preserved). Do NOT
# short-circuit on summary_idx here: a merged handoff carries real
# user content that a blanket skip would silently delete.
# short-circuit on summary_indices here: a merged handoff carries
# real user content that a blanket skip would silently delete.
msg = _fresh_compaction_message_copy(messages[i])
if i == 0 and msg.get("role") == "system":
existing = msg.get("content")
@ -4246,17 +4413,26 @@ This compaction should PRIORITISE preserving all information related to the focu
)
tail_messages: List[Dict[str, Any]] = []
for i in range(compress_end, n_messages):
# Start at tail_start (not compress_end): the restart-decay scan may
# have advanced it past a summary that sat beyond compress_end
# (#57835). summary_indices rows are already rehydrated; the strip
# helper handles any that remain (standalone → dropped, merged →
# unwrapped to genuine prior-tail content, #47274).
for i in range(max(compress_end, tail_start), n_messages):
if i in summary_indices and i >= tail_start:
# A summary at/after tail_start was already folded into
# _previous_summary; don't re-emit it verbatim.
continue
msg = _fresh_compaction_message_copy(messages[i])
stripped = self._strip_context_summary_handoff_message(msg)
if stripped is not None:
tail_messages.append(stripped)
_merge_summary_into_tail = False
last_head_role = compressed[-1].get("role", "user") if compressed else "user"
# NOTE: derive the tail's leading role from tail_messages (post
# handoff-strip), not messages[compress_end] — a stripped stale
# last_head_role reads the assembled (post-strip) head; first_tail_role
# reads the assembled (post-strip) tail_messages — a stripped stale
# handoff must not influence alternation-safe role selection.
last_head_role = compressed[-1].get("role", "user") if compressed else "user"
first_tail_role = tail_messages[0].get("role", "user") if tail_messages else None
# When the only protected head message is the system prompt, the
# summary becomes the first *visible* message in the API request
@ -4265,7 +4441,7 @@ This compaction should PRIORITISE preserving all information related to the focu
# Anthropic unconditionally rejects requests whose first message
# is not role=user, so we must pin the summary to "user" and
# prevent the flip logic below from reverting it (#52160).
_force_user_leading = last_head_role == "system"
_force_user_leading = compress_start == 0 or last_head_role == "system"
# Zero-user-turn guard (#58753). The #52160 guard above only fires
# when the system prompt sits *inside* ``messages`` (the gateway
# ``/compress`` path). The main auto-compression path passes the
@ -4299,7 +4475,7 @@ This compaction should PRIORITISE preserving all information related to the focu
summary_role = "assistant"
# If the chosen role collides with the tail AND flipping wouldn't
# collide with the head, flip it.
if first_tail_role and summary_role == first_tail_role:
if first_tail_role is not None and summary_role == first_tail_role:
flipped = "assistant" if summary_role == "user" else "user"
if flipped != last_head_role and not _force_user_leading:
summary_role = flipped
@ -4332,27 +4508,39 @@ This compaction should PRIORITISE preserving all information related to the focu
for tail_idx, msg in enumerate(tail_messages):
if _merge_summary_into_tail and tail_idx == 0:
# Merge the summary into the first tail message, but place
# the END MARKER at the very end so the model sees an
# unambiguous boundary. Old tail content is preserved as
# reference material BEFORE the summary, clearly delimited
# so it is not mistaken for a new message to respond to.
# Uses _append_text_to_content to safely handle both
# string and multimodal-list content types.
# Fixes ghost-message leakage across compaction boundaries
# where old head messages survived verbatim and appeared
# before the summary.
# Merge the summary into the first (post-strip) tail message.
old_content = msg.get("content", "")
suffix = (
"\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n"
+ summary + "\n\n"
+ _SUMMARY_END_MARKER
)
msg["content"] = _append_text_to_content(
_append_text_to_content(old_content, suffix, prepend=False),
_MERGED_PRIOR_CONTEXT_HEADER + "\n",
prepend=True,
)
if _force_user_leading and summary_role == "user":
# The summary must be part of the first user-visible
# message for Anthropic/Bedrock, but the real tail request
# still has to appear *after* the summary boundary.
prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
msg["content"] = _append_text_to_content(
old_content,
prefix,
prepend=True,
)
else:
# Merge the summary into the first tail message, but place
# the END MARKER at the very end so the model sees an
# unambiguous boundary. Old tail content is preserved as
# reference material BEFORE the summary, clearly delimited
# so it is not mistaken for a new message to respond to.
# Uses _append_text_to_content to safely handle both
# string and multimodal-list content types.
# Fixes ghost-message leakage across compaction boundaries
# where old head messages survived verbatim and appeared
# before the summary.
suffix = (
"\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n"
+ summary + "\n\n"
+ _SUMMARY_END_MARKER
)
msg["content"] = _append_text_to_content(
_append_text_to_content(old_content, suffix, prepend=False),
_MERGED_PRIOR_CONTEXT_HEADER + "\n",
prepend=True,
)
# Mark the merged message so frontends can identify it as
# containing a compression summary prefix.
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True

View file

@ -57,6 +57,71 @@ COMPACTION_STATUS = (
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
)
COMPACTION_DONE_STATUS = "✓ Context compaction complete — continuing turn..."
def _emit_compaction_done(agent: Any) -> None:
"""Emit the structured terminal edge for a started compaction."""
status_callback = getattr(agent, "status_callback", None)
if not status_callback:
return
try:
status_callback("compacted", COMPACTION_DONE_STATUS)
except Exception:
logger.debug("status_callback error in compaction completion", exc_info=True)
# ── Routine compression status templates ────────────────────────────────────
# Every ROUTINE (non-failure, non-manual-/compress) compression status line the
# agent emits lives here so the gateway noise filter and its tests can couple
# to the real emitted wording instead of hand-copied literals. These are
# suppressed on human-facing chat platforms by _TELEGRAM_NOISY_STATUS_RE
# (gateway/run.py) — when rewording ANY of them, update that regex and the
# pinned data in tests/gateway/test_telegram_noise_filter.py in the same PR.
# Failure notices (⚠ Compression aborted / empty transcript / codex compaction
# failed) and manual /compress feedback (manual_compression_feedback.py) are
# deliberate carve-outs from silence and must NOT be added here.
PRE_API_COMPRESSION_STATUS_TEMPLATE = (
"📦 Pre-API compression: ~{tokens:,} tokens "
"near the context/output limit. Compacting before the next model call."
)
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE = (
"📦 Preflight compression: ~{tokens:,} tokens "
">= {threshold:,} threshold. This may take a moment."
)
IDLE_COMPACTION_STATUS_TEMPLATE = (
"💤 Resumed after {idle_seconds}s idle — compacting "
"~{tokens:,} tokens before continuing."
)
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE = (
"🗜️ Context too large (~{tokens:,} tokens) — compressing ({attempt}/{cap})..."
)
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE = (
"🗜️ Compressed {before}{after} messages, retrying..."
)
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE = (
"🗜️ Compressed ~{before:,} → ~{after:,} tokens, retrying..."
)
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE = (
"🗜️ Context reduced to {new_ctx:,} tokens (was {old_ctx:,}), retrying..."
)
# Sample-formatted instances of every routine compression status line, for
# behavioral tests that iterate the ACTUAL emitted wording (formatted from the
# same constants the emission sites use) through the gateway noise filter.
ROUTINE_COMPRESSION_STATUS_SAMPLES = (
COMPACTION_STATUS,
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(tokens=123456),
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(tokens=120000, threshold=100000),
IDLE_COMPACTION_STATUS_TEMPLATE.format(idle_seconds=3600, tokens=120000),
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=250000, attempt=1, cap=3),
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=30, after=12),
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=250000, after=120000),
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format(
new_ctx=120000, old_ctx=250000
),
)
def _builtin_memory_prompt_snapshot(agent: Any) -> Optional[Tuple[str, str]]:
"""Return the built-in memory text that can affect a system prompt.
@ -1025,6 +1090,14 @@ def compress_context(
focus_topic,
)
agent._emit_status(COMPACTION_STATUS)
_compaction_done_emitted = False
def _complete_compaction_lifecycle() -> None:
nonlocal _compaction_done_emitted
if _compaction_done_emitted:
return
_compaction_done_emitted = True
_emit_compaction_done(agent)
# ── Compression lock ────────────────────────────────────────────────
# Atomic, state.db-backed lock per session_id. Without this, two
@ -1174,12 +1247,14 @@ def compress_context(
split_status="aborted",
failure_class="lock_contended",
)
_complete_compaction_lifecycle()
return messages, _existing_sp
_lock_released = False
def _release_lock() -> None:
"""Release the lock keyed on the OLD session_id (before rotation)."""
nonlocal _lock_released
_complete_compaction_lifecycle()
if _lock_released:
return
_lock_released = True
@ -1876,6 +1951,15 @@ def _compress_context_via_codex_app_server(
except Exception:
pass
_compaction_done_emitted = False
def _complete_compaction_lifecycle() -> None:
nonlocal _compaction_done_emitted
if _compaction_done_emitted:
return
_compaction_done_emitted = True
_emit_compaction_done(agent)
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
try:
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
@ -1883,6 +1967,7 @@ def _compress_context_via_codex_app_server(
except BaseException:
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression failed")
_complete_compaction_lifecycle()
raise
if getattr(result, "interrupted", False) or getattr(result, "error", None):
@ -1907,6 +1992,7 @@ def _compress_context_via_codex_app_server(
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
_complete_compaction_lifecycle()
return messages, existing_prompt
try:
@ -1946,6 +2032,7 @@ def _compress_context_via_codex_app_server(
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
_complete_compaction_lifecycle()
return messages, existing_prompt
@ -2206,6 +2293,7 @@ def try_shrink_image_parts_in_messages(
__all__ = [
"COMPACTION_STATUS",
"COMPACTION_DONE_STATUS",
"COMPACTION_STATUS_MARKER",
"check_compression_model_feasibility",
"replay_compression_warning",

View file

@ -28,7 +28,14 @@ import uuid
from typing import Any, Dict, List, Optional
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.conversation_compression import conversation_history_after_compression
from agent.conversation_compression import (
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE,
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE,
PRE_API_COMPRESSION_STATUS_TEMPLATE,
conversation_history_after_compression,
)
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
@ -103,6 +110,53 @@ _API_CALL_MODULES = frozenset({
})
def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None:
"""Append a provider-safe checkpoint and correction to the live turn.
Incomplete provider reasoning blocks are not valid replay items (Anthropic
signs them; Responses reasoning items require their following output).
Preserve only what Hermes actually displayed, demoted to ordinary text,
then add the correction as a real user message. This keeps role alternation
valid and leaves every previously cached message byte-for-byte unchanged.
"""
reasoning = str(
getattr(agent, "_current_streamed_reasoning_text", "") or ""
).strip()
visible = agent._strip_think_blocks(
getattr(agent, "_current_streamed_assistant_text", "") or ""
).strip()
checkpoint_parts = ["[This response was interrupted by a user correction.]"]
if reasoning:
checkpoint_parts.extend(
["Reasoning shown before the interruption:", reasoning]
)
if visible:
checkpoint_parts.extend(
["Visible response before the interruption:", visible]
)
checkpoint = "\n\n".join(checkpoint_parts)
# The normal live tail is user or tool, so an assistant checkpoint followed
# by the correction preserves strict alternation. If a transport already
# committed an assistant item, attribute the checkpoint inside the user
# correction instead of creating assistant→assistant.
if messages and messages[-1].get("role") == "assistant":
correction = (
"[Context from the interrupted assistant response]\n"
f"{checkpoint}\n\n"
f"{text}"
)
messages.append({"role": "user", "content": correction})
else:
messages.append({"role": "assistant", "content": checkpoint})
messages.append({"role": "user", "content": text})
agent._current_streamed_assistant_text = ""
agent._current_streamed_reasoning_text = ""
agent._stream_needs_break = True
def _image_error_max_dimension(error: Exception) -> Optional[int]:
"""Extract a provider-reported image dimension ceiling, if present."""
parts = []
@ -253,6 +307,19 @@ def _billing_or_entitlement_message(
]
return "\n".join(lines)
# Provider-agnostic billing URL derivation (OpenAI, DeepSeek, xAI, Groq,
# OpenRouter, …) so every text surface — CLI, gateway messaging, TUI
# transcript — shows the same actionable link, not just OpenRouter.
try:
from agent.billing_links import build_billing_block
_link = build_billing_block(provider=provider, base_url=base_url, model=model)
if _link.provider_label:
provider_label = _link.provider_label
billing_url = _link.billing_url
except Exception:
billing_url = None
lines = [
(
f"{provider_label} reported that billing, credits, or account "
@ -260,12 +327,24 @@ def _billing_or_entitlement_message(
),
"Add credits or update billing with that provider, then retry.",
]
if base_url_host_matches(str(base_url or ""), "openrouter.ai"):
lines.append("OpenRouter credits: https://openrouter.ai/settings/credits")
if billing_url:
lines.append(f"{provider_label} billing: {billing_url}")
lines.append("You can switch providers temporarily with /model <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,
*,
@ -733,6 +812,16 @@ def run_conversation(
)
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
_redirect_text = agent._drain_pending_redirect()
if _redirect_text:
_apply_active_turn_redirect(agent, messages, _redirect_text)
if isinstance(original_user_message, str):
original_user_message = (
f"{original_user_message}\n\n"
f"User correction during the turn: {_redirect_text}"
)
agent._persist_session(messages, conversation_history)
# Reset per-turn checkpoint dedup so each iteration can take one snapshot
agent._checkpoint_mgr.new_turn()
@ -1222,8 +1311,9 @@ def run_conversation(
max_compression_attempts,
)
agent._emit_status(
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
f"near the context/output limit. Compacting before the next model call."
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(
tokens=request_pressure_tokens
)
)
_last_preflight_pressure = request_pressure_tokens
messages, active_system_prompt = agent._compress_context(
@ -1554,22 +1644,59 @@ def run_conversation(
from hermes_cli.middleware import run_llm_execution_middleware
response = run_llm_execution_middleware(
api_kwargs,
_perform_api_call,
original_request=_original_api_kwargs,
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
middleware_trace=list(_llm_middleware_trace),
)
_model_request_active = getattr(agent, "_model_request_active", None)
_redirect_lock = getattr(agent, "_pending_redirect_lock", None)
if _redirect_lock is not None:
with _redirect_lock:
if _model_request_active is not None:
_model_request_active.set()
elif _model_request_active is not None:
_model_request_active.set()
_redirect_crossed_response = False
try:
response = run_llm_execution_middleware(
api_kwargs,
_perform_api_call,
original_request=_original_api_kwargs,
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
middleware_trace=list(_llm_middleware_trace),
)
finally:
if _redirect_lock is not None:
with _redirect_lock:
if _model_request_active is not None:
_model_request_active.clear()
_redirect_crossed_response = bool(
agent._pending_redirect
)
else:
if _model_request_active is not None:
_model_request_active.clear()
_redirect_crossed_response = agent._has_pending_redirect()
if _redirect_crossed_response:
# The response and redirect can cross on different threads:
# redirect() observed the request as active just before this
# call returned. Discard that now-stale response and rebuild
# from the correction rather than silently losing it.
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
else:
interrupted = True
break
api_duration = time.time() - api_start_time
@ -2238,13 +2365,17 @@ def run_conversation(
force=True,
)
agent._cleanup_task_resources(effective_task_id)
agent._persist_session(messages, conversation_history)
_final_response = (
"Stream repeatedly dropped mid tool-call (network); "
"the tool was not executed"
if _is_stub_stall
else "Response truncated due to output length limit"
)
# Prior successful tool batches (or injected tool
# errors) can leave a tool-result tail; this path
# never reaches finalize_turn (#48879 class).
close_interrupted_tool_sequence(messages, _final_response)
agent._persist_session(messages, conversation_history)
return {
"final_response": _final_response,
"messages": messages,
@ -2537,6 +2668,15 @@ def run_conversation(
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
if agent._has_pending_redirect():
# redirect() deliberately used the interrupt machinery to
# cancel only this provider request. Keep its correction
# queued, clear the cancellation bit, and let the outer
# loop rebuild a clean request tail. Never materialize
# incomplete signed/encrypted reasoning items.
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
api_elapsed = time.time() - api_start_time
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
interrupted = True
@ -3419,8 +3559,9 @@ def run_conversation(
)
if len(messages) < original_len or old_ctx > _reduced_ctx:
agent._buffer_status(
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
f"(was {old_ctx:,}), retrying..."
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format(
new_ctx=_reduced_ctx, old_ctx=old_ctx
)
)
time.sleep(2)
_retry.restart_with_compressed_messages = True
@ -3681,9 +3822,9 @@ def run_conversation(
if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95):
if len(messages) < original_len:
agent._buffer_status(f"🗜️ Compressed {original_len}{len(messages)} messages, retrying...")
agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
else:
agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
time.sleep(2) # Brief pause between compression retries
_retry.restart_with_compressed_messages = True
break
@ -3901,7 +4042,7 @@ def run_conversation(
"failed": True,
"compression_exhausted": True,
}
agent._buffer_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...")
agent._buffer_status(COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=approx_tokens, attempt=compression_attempts, cap=max_compression_attempts))
original_len = len(messages)
original_tokens = estimate_messages_tokens_rough(messages)
@ -3922,9 +4063,9 @@ def run_conversation(
if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95) or (new_ctx and new_ctx < old_ctx):
if len(messages) < original_len:
agent._buffer_status(f"🗜️ Compressed {original_len}{len(messages)} messages, retrying...")
agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
elif new_tokens > 0 and new_tokens < original_tokens * 0.95:
agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
time.sleep(2) # Brief pause between compression retries
_retry.restart_with_compressed_messages = True
break
@ -4200,6 +4341,31 @@ def run_conversation(
final_response=_policy_response,
error_detail=_nonretryable_summary,
)
# Billing walls are the common non-retryable abort: enrich
# the result with the same structured recovery descriptor as
# the max-retries path so every surface (CLI, TUI, desktop)
# renders one consistent billing signal.
if classified.reason == FailoverReason.billing:
_ce_guidance = _billing_or_entitlement_message(
capability="model access",
provider=_provider,
base_url=str(_base),
model=_model,
)
_ce_final = f"Billing or credits exhausted: {_nonretryable_summary}"
if _ce_guidance:
_ce_final += f"\n\n{_ce_guidance}"
_ce_block = _billing_block_dict(_provider, _base, _model, _ce_guidance)
return {
"final_response": _ce_final,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"failed": True,
"error": _nonretryable_summary,
"failure_reason": classified.reason.value,
"billing_block": _ce_block,
}
return {
"final_response": _nonretryable_summary,
"messages": messages,
@ -4360,10 +4526,14 @@ def run_conversation(
api_kwargs, reason="max_retries_exhausted", error=api_error,
)
agent._persist_session(messages, conversation_history)
_billing_block = None
if classified.reason == FailoverReason.billing:
_final_response = f"Billing or credits exhausted: {_final_summary}"
if _billing_guidance:
_final_response += f"\n\n{_billing_guidance}"
# Structured recovery descriptor so every surface renders
# the same link + label from one signal (see helper).
_billing_block = _billing_block_dict(_provider, _base, _model, _billing_guidance)
else:
_final_response = f"API call failed after {max_retries} retries: {_final_summary}"
if _is_thinking_timeout:
@ -4403,6 +4573,9 @@ def run_conversation(
# different exit code. ``rate_limit`` / ``billing`` here
# mean "quota wall, not a task error".
"failure_reason": classified.reason.value,
# Present only for billing walls: structured recovery
# descriptor (provider, billing_url, is_nous, message).
"billing_block": _billing_block,
}
# For rate limits, respect the Retry-After header if present
@ -4485,6 +4658,15 @@ def run_conversation(
f"{int(sleep_end - time.time())}s remaining"
)
if _retry.restart_with_redirected_messages:
# The cancelled request produced no valid assistant item. Reuse the
# same logical iteration after the outer loop appends the displayed
# partial context and correction to ``messages``.
api_call_count -= 1
agent.iteration_budget.refund()
_retry.restart_with_redirected_messages = False
continue
# If the API call was interrupted, skip response processing
if interrupted:
_turn_exit_reason = "interrupted_during_api_call"
@ -4872,8 +5054,13 @@ def run_conversation(
agent._flush_status_buffer()
agent._vprint(f"{agent.log_prefix}❌ Max retries (3) for invalid tool calls exceeded. Stopping as partial.", force=True)
agent._invalid_tool_retries = 0
agent._persist_session(messages, conversation_history)
_final_response = f"Model generated invalid tool call: {invalid_preview}"
# Prior <3 retries (or an earlier successful tool batch)
# leave a tool-result tail. Closing it here matches
# interrupt aborts (#48879 / #52592) so the next user
# turn is not tool→user for strict providers.
close_interrupted_tool_sequence(messages, _final_response)
agent._persist_session(messages, conversation_history)
return {
"final_response": _final_response,
"messages": messages,
@ -4953,14 +5140,18 @@ def run_conversation(
)
agent._invalid_json_retries = 0
agent._cleanup_task_resources(effective_task_id)
_final_response = "Response truncated due to output length limit"
# Same tool-tail close as interrupt / invalid-tool
# exhaustion — this path never reaches finalize_turn.
close_interrupted_tool_sequence(messages, _final_response)
agent._persist_session(messages, conversation_history)
return {
"final_response": "Response truncated due to output length limit",
"final_response": _final_response,
"messages": messages,
"api_calls": api_call_count,
"completed": False,
"partial": True,
"error": "Response truncated due to output length limit",
"error": _final_response,
}
# Track retries for invalid JSON arguments

View file

@ -1484,6 +1484,43 @@ class CredentialPool:
self._sync_device_code_entry_to_auth_store(updated)
return updated
def _codex_quota_restored_upstream(self, entry: PooledCredential) -> bool:
"""Live-check whether an exhausted Codex entry's quota reset early.
A Codex 429 persists a ``last_error_reset_at`` that can be days in
the future (weekly windows), but the upstream window can reopen
before then the user redeems a banked rate-limit reset via the
Codex CLI / ChatGPT UI, upgrades their plan, or OpenAI resets the
window. Without this check the pool keeps the credential frozen
until the stale timestamp elapses even though the account is
usable (issue #43747).
Only fires for openai-codex entries frozen by a 429/quota-shaped
error. The underlying probe is throttled per token (5 min) so this
is safe on the hot selection path.
"""
if self.provider != "openai-codex" or entry.last_status != STATUS_EXHAUSTED:
return False
if not auth_mod._is_codex_rate_limit_shaped(
entry.last_error_code,
entry.last_error_reason,
entry.last_error_message,
):
return False
token = entry.access_token or ""
if not token:
return False
try:
return bool(
auth_mod._probe_codex_quota_restored(
token,
base_url=entry.base_url,
)
)
except Exception:
logger.debug("Codex quota-restored probe failed", exc_info=True)
return False
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if entry.auth_type != AUTH_TYPE_OAUTH:
return False
@ -1605,7 +1642,18 @@ class CredentialPool:
if entry.last_status == STATUS_EXHAUSTED:
exhausted_until = _exhausted_until(entry)
if exhausted_until is not None and now < exhausted_until:
continue
# Codex quota windows can reopen EARLY: the user redeems a
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
# their plan, or OpenAI resets the window. The persisted
# ``last_error_reset_at`` can then be days in the future
# while the account is already usable again — a throttled
# live probe of the Codex usage endpoint detects that and
# lifts the stale cooldown (issue #43747).
if not (
clear_expired
and self._codex_quota_restored_upstream(entry)
):
continue
if clear_expired:
cleared = replace(
entry,

View file

@ -316,12 +316,21 @@ def evaluate_credits_notices(
active.discard(CREDITS_USAGE_KEY)
if target_band is not None:
# Belt-and-suspenders: a producer could set subscription_limit_micros
# without subscription_limit_usd. Render "$? cap" rather than "$None cap".
# without subscription_limit_usd. Render "$?" rather than "$None".
_cap_usd = state.subscription_limit_usd or "?"
_level = current_band[1] # type: ignore[index] (current_band set when target_band set)
# Report absolute dollars used, not a bare "N% used": the percentage is
# only meaningful against a Nous subscription cap (no cap → never fires),
# so dollars are clearer and don't imply a universal %. Used = cap
# remaining (micros, money-safe), clamped to [0, cap]. Re-emits on band
# change (50 → 75 → 90), not every turn — a snapshot, not a live ticker.
_lim = state.subscription_limit_micros or 0
_used_micros = max(0, min(_lim, _lim - state.subscription_micros))
_used_usd = f"{_used_micros / 1_000_000:.2f}" if _lim else "?"
_glyph = "" if _level == "warn" else ""
to_show.append(
AgentNotice(
text=f"{'' if _level == 'warn' else ''} Credits {target_band}% used · ${_cap_usd} cap",
text=f"{_glyph} You've used ${_used_usd} of your ${_cap_usd} cap",
level=_level,
kind=CREDITS_NOTICE_KIND,
key=CREDITS_USAGE_KEY,

View file

@ -32,7 +32,6 @@ from __future__ import annotations
import logging
import os
import sysconfig
import threading
from functools import lru_cache
from pathlib import Path
@ -92,12 +91,8 @@ def _locales_dir() -> Path:
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
sealed-packaging system) to point at the installed catalog directory.
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
2. ``<repo-root>/locales`` -- source checkouts and editable installs,
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
@ -116,25 +111,6 @@ def _locales_dir() -> Path:
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
source_dir = Path(__file__).resolve().parent.parent / "locales"
if source_dir.is_dir():
return source_dir
# pip wheel install: data-files lands under the interpreter data scheme.
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
# both are out of scope; see the plan header.
for scheme in ("data", "purelib", "platlib"):
raw = sysconfig.get_path(scheme)
if not raw:
continue
candidate = Path(raw) / "locales"
if candidate.is_dir():
return candidate
# Last resort: return the source-style path so _load_catalog's catalog-missing
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
return source_dir

View file

@ -181,6 +181,8 @@ def _supports_vision_override(
cfg: Optional[Dict[str, Any]],
provider: str,
model: str,
*,
requested_provider: str = "",
) -> Optional[bool]:
"""Resolve user-declared vision capability from config.yaml.
@ -188,9 +190,10 @@ def _supports_vision_override(
1. ``model.supports_vision`` (top-level shortcut for the active model)
2. ``providers.<provider>.models.<model>.supports_vision``
(named custom providers ``provider`` may be the runtime-resolved
value ``"custom"`` and/or the user-declared name under
``model.provider``; both are tried. For ``custom:<name>`` syntax,
the stripped ``<name>`` is also tried as a provider key.)
value ``"custom"``, the runtime's originally requested provider,
and/or the user-declared name under ``model.provider``; all are
tried. For ``custom:<name>`` syntax, the stripped ``<name>`` is also
tried as a provider key.)
Returns None when no override is set, so the caller falls through to
models.dev. Returns False explicitly only when the user wrote a
@ -210,16 +213,21 @@ def _supports_vision_override(
# get rewritten to provider="custom" at runtime
# (hermes_cli/runtime_provider.py:_resolve_named_custom_runtime), so the
# config still holds the user-declared name under model.provider. Try
# both as candidate provider keys, plus the stripped suffix from
# "custom:<name>" (where <name> is the key under providers:).
# both as candidate provider keys. Either identity may use the
# "custom:<name>" form while providers: is keyed by bare <name>.
config_provider = str(model_cfg.get("provider") or "").strip()
# Extract the stripped name from "custom:<name>" if present
stripped_suffix = ""
if config_provider.startswith("custom:"):
stripped_suffix = config_provider[len("custom:"):]
provider_candidates: List[str] = []
for candidate in (requested_provider, provider, config_provider):
if not candidate:
continue
provider_candidates.append(candidate)
if candidate.startswith("custom:"):
stripped_candidate = candidate[len("custom:"):]
if stripped_candidate:
provider_candidates.append(stripped_candidate)
providers_raw = cfg.get("providers")
providers_cfg: Dict[str, Any] = providers_raw if isinstance(providers_raw, dict) else {}
for p in dict.fromkeys(filter(None, (provider, config_provider, stripped_suffix))):
for p in dict.fromkeys(provider_candidates):
entry_raw = providers_cfg.get(p)
entry: Dict[str, Any] = entry_raw if isinstance(entry_raw, dict) else {}
models_raw = entry.get("models")
@ -235,28 +243,24 @@ def _supports_vision_override(
# may appear as the raw name or "custom:<name>" at runtime).
custom_providers = cfg.get("custom_providers")
if isinstance(custom_providers, list):
# Build candidate names: the provider value and the config provider
# value, both raw and with "custom:" prefix stripped/added.
candidate_names: set = set()
for p in filter(None, (provider, config_provider)):
candidate_names.add(p)
if p.startswith("custom:"):
candidate_names.add(p[len("custom:"):])
else:
candidate_names.add(f"custom:{p}")
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip()
if entry_name not in candidate_names:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
# Candidate priority matters when the CLI-selected provider differs
# from model.provider. Walk identities first, then config entries, so
# list order cannot let the persisted default shadow the live route.
for candidate in dict.fromkeys(provider_candidates):
candidate_name = candidate.strip().lower()
for entry_raw in custom_providers:
if not isinstance(entry_raw, dict):
continue
entry_name = str(entry_raw.get("name") or "").strip().lower()
if entry_name != candidate_name:
continue
models_raw = entry_raw.get("models")
models_cfg = models_raw if isinstance(models_raw, dict) else {}
per_model_raw = models_cfg.get(model)
per_model = per_model_raw if isinstance(per_model_raw, dict) else {}
coerced = _coerce_capability_bool(per_model.get("supports_vision"))
if coerced is not None:
return coerced
return None
@ -376,6 +380,8 @@ def _lookup_supports_vision(
provider: str,
model: str,
cfg: Optional[Dict[str, Any]] = None,
*,
requested_provider: str = "",
) -> Optional[bool]:
"""Return True/False if we can resolve caps, None if unknown.
@ -383,7 +389,34 @@ def _lookup_supports_vision(
(so custom/local models declared as vision-capable don't fall through to
text routing in ``auto`` mode), then falls back to models.dev.
"""
override = _supports_vision_override(cfg, provider, model)
# Named custom providers are canonicalized to ``provider="custom"`` by
# runtime resolution. The original CLI/config name is carried in the
# context-local main runtime so capability lookup can still select the
# exact custom_providers entry. Require an exact provider+model match:
# background/auxiliary lookups must never borrow another turn's identity.
if not requested_provider:
try:
from agent.auxiliary_client import _runtime_main_value
runtime_provider = str(
_runtime_main_value("provider") or ""
).strip().lower()
runtime_model = str(_runtime_main_value("model") or "").strip()
lookup_provider = str(provider or "").strip().lower()
lookup_model = str(model or "").strip()
if runtime_provider == lookup_provider and runtime_model == lookup_model:
requested_provider = str(
_runtime_main_value("requested_provider") or ""
).strip()
except Exception:
pass
override = _supports_vision_override(
cfg,
provider,
model,
requested_provider=requested_provider,
)
if override is not None:
return override
if not provider or not model:
@ -421,6 +454,8 @@ def decide_image_input_mode(
provider: str,
model: str,
cfg: Optional[Dict[str, Any]],
*,
requested_provider: str = "",
) -> str:
"""Return ``"native"`` or ``"text"`` for the given turn.
@ -428,6 +463,7 @@ def decide_image_input_mode(
provider: active inference provider ID (e.g. ``"anthropic"``, ``"openrouter"``).
model: active model slug as it would be sent to the provider.
cfg: loaded config.yaml dict, or None. When None, behaves as auto.
requested_provider: provider identity before runtime canonicalization.
"""
mode_cfg = "auto"
if isinstance(cfg, dict):
@ -444,7 +480,17 @@ def decide_image_input_mode(
# explicit auxiliary.vision config acts as a *fallback* for text-only
# main models — it should not preempt native vision on a model that
# can natively inspect the pixels (issue #29135).
supports = _lookup_supports_vision(provider, model, cfg)
if requested_provider:
supports = _lookup_supports_vision(
provider,
model,
cfg,
requested_provider=requested_provider,
)
else:
# Keep the long-standing three-argument call contract for callers and
# tests that replace the capability lookup hook.
supports = _lookup_supports_vision(provider, model, cfg)
if supports is True:
return "native"
if _explicit_aux_vision_override(cfg):

View file

@ -52,6 +52,13 @@ def busy_input_hint_gateway(mode: str) -> str:
"Send `/busy interrupt` or `/busy queue` to change this, or "
"`/busy status` to check. This notice won't appear again."
)
if mode == "redirect":
return (
"💡 First-time tip — I redirected the current run using your message. "
"Completed work stays in context, and `/stop` still cancels the task. "
"Send `/busy queue` to wait for a separate turn, or `/busy status` "
"to check. This notice won't appear again."
)
return (
"💡 First-time tip — I just interrupted my current task to answer you. "
"Send `/busy queue` to queue follow-ups for after the current task instead, "
@ -74,6 +81,12 @@ def busy_input_hint_cli(mode: str) -> str:
"after the next tool call. Use /busy interrupt or /busy queue to "
"change this. This tip only shows once."
)
if mode == "redirect":
return (
"(tip) Your correction redirected the current run without discarding "
"completed work. Use /stop to cancel or /busy queue to wait for a "
"separate turn. This tip only shows once."
)
return (
"(tip) Your message interrupted the current run. "
"Use /busy queue to queue messages for the next turn instead, "

View file

@ -300,6 +300,8 @@ class CodexAppServerSession:
self._client: Optional[CodexAppServerClient] = None
self._thread_id: Optional[str] = None
self._interrupt_event = threading.Event()
self._active_turn_id: Optional[str] = None
self._active_turn_lock = threading.Lock()
# Pending file-change items, keyed by item id. Populated on
# item/started for fileChange items; consumed by the approval
# bridge when codex sends item/fileChange/requestApproval. The
@ -374,6 +376,8 @@ class CodexAppServerSession:
if self._closed:
return
self._closed = True
with self._active_turn_lock:
self._active_turn_id = None
if self._client is not None:
try:
self._client.close()
@ -395,6 +399,33 @@ class CodexAppServerSession:
and unwind. Called by AIAgent's _interrupt_requested path."""
self._interrupt_event.set()
def request_steer(self, text: str) -> bool:
"""Append user guidance to the active Codex turn via ``turn/steer``."""
cleaned = str(text or "").strip()
if not cleaned:
return False
with self._active_turn_lock:
turn_id = self._active_turn_id
thread_id = self._thread_id
client = self._client
if not turn_id or not thread_id or client is None:
return False
try:
response = client.request(
"turn/steer",
{
"threadId": thread_id,
"input": [{"type": "text", "text": cleaned}],
"expectedTurnId": turn_id,
},
timeout=10,
)
except (CodexAppServerError, TimeoutError):
logger.debug("turn/steer rejected for active Codex turn", exc_info=True)
return False
accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None
return accepted_turn_id in {None, turn_id}
# ---------- diagnostics ----------
def _format_error_with_stderr(
@ -469,11 +500,18 @@ class CodexAppServerSession:
# Subprocess almost certainly unhealthy — retire so the next
# turn re-spawns cleanly.
result.should_retire = True
self._interrupt_event.clear()
return result
assert self._client is not None and self._thread_id is not None
result.thread_id = self._thread_id
self._interrupt_event.clear()
# Do not clear here: a hard stop can arrive while ensure_started() is
# spawning/initializing the subprocess. Honor it before launching a
# Codex turn instead of erasing the signal.
if self._interrupt_event.is_set():
result.interrupted = True
self._interrupt_event.clear()
return result
projector = CodexEventProjector()
user_input_text = _coerce_turn_input_text(user_input)
@ -505,6 +543,7 @@ class CodexAppServerSession:
result.error = self._format_error_with_stderr(
"turn/start failed", exc
)
self._interrupt_event.clear()
return result
except TimeoutError as exc:
# turn/start hanging is a strong signal the subprocess is wedged.
@ -514,9 +553,12 @@ class CodexAppServerSession:
"turn/start timed out", exc
)
result.should_retire = True
self._interrupt_event.clear()
return result
result.turn_id = (ts.get("turn") or {}).get("id")
with self._active_turn_lock:
self._active_turn_id = result.turn_id
deadline = time.monotonic() + turn_timeout
turn_complete = False
# Post-tool watchdog state. last_tool_completion_at is set whenever
@ -741,6 +783,9 @@ class CodexAppServerSession:
)
result.should_retire = True
with self._active_turn_lock:
self._active_turn_id = None
self._interrupt_event.clear()
return result
def compact_thread(

View file

@ -26,11 +26,16 @@ from __future__ import annotations
import logging
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional
from agent.conversation_compression import conversation_history_after_compression
from agent.conversation_compression import (
IDLE_COMPACTION_STATUS_TEMPLATE,
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
conversation_history_after_compression,
)
from agent.iteration_budget import IterationBudget
from agent.memory_manager import build_memory_context_block
from agent.model_metadata import (
@ -255,6 +260,40 @@ def _should_run_preflight_estimate(
return estimate_messages_tokens_rough(messages) >= threshold_tokens
def _should_idle_compact(
*,
enabled: bool,
idle_after_seconds: int,
idle_gap_seconds: float,
tokens: int,
floor_tokens: int,
cooldown_active: bool,
) -> bool:
"""Decide whether an idle-triggered compaction should run this turn.
Idle compaction is opt-in (``idle_after_seconds <= 0`` disables it). It
fires when a session resumes after a wall-clock gap of at least
``idle_after_seconds`` since its last activity, so a long-lived thread
that is paused and later resumed compacts its accumulated history up
front instead of re-reading it on every subsequent turn.
It is orthogonal to the token-threshold trigger: it does NOT require the
context to exceed ``threshold_tokens``. It still skips work when the
context is at or below ``floor_tokens`` (the size compaction would reduce
*to*), so a small idle thread never pays for a summarisation that saves
nothing, and it defers to an active compression-failure cooldown.
Pure predicate so the policy is unit-testable without a live agent.
"""
if not enabled or idle_after_seconds <= 0:
return False
if idle_gap_seconds < idle_after_seconds:
return False
if cooldown_active:
return False
return tokens > floor_tokens
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
@ -336,6 +375,7 @@ def build_turn_context(
set_runtime_main(
getattr(agent, "provider", "") or "",
getattr(agent, "model", "") or "",
requested_provider=getattr(agent, "requested_provider", "") or "",
base_url=getattr(agent, "base_url", "") or "",
api_key=getattr(agent, "api_key", "") or "",
api_mode=getattr(agent, "api_mode", "") or "",
@ -584,6 +624,78 @@ def build_turn_context(
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
# ── Idle-triggered compaction (opt-in; ``idle_compact_after_seconds``) ──
# When a session resumes after a long idle gap, compact the accumulated
# history up front so the rest of the conversation does not keep re-reading
# a large stale context on every turn. This fires on elapsed wall-clock time
# rather than size, so it complements (does not replace) the token-threshold
# preflight below. ``_last_activity_ts`` is the last time this turn loop did
# work; nothing has touched it yet this turn, so it measures the gap since
# the previous turn finished. The cheap gap pre-check gates the (more
# expensive) token estimate, mirroring ``_should_run_preflight_estimate``.
_idle_after = getattr(agent, "compression_idle_compact_after_seconds", 0)
if agent.compression_enabled and _idle_after > 0 and messages:
_idle_gap = time.time() - getattr(agent, "_last_activity_ts", time.time())
if _idle_gap >= _idle_after:
_compressor = agent.context_compressor
_idle_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
# Post-compression target size: don't summarise a thread already
# below what compaction would reduce it to.
_idle_floor = int(
_compressor.threshold_tokens * _compressor.summary_target_ratio
)
_idle_cooldown = getattr(
_compressor, "get_active_compression_failure_cooldown", lambda: None
)()
if _should_idle_compact(
enabled=agent.compression_enabled,
idle_after_seconds=_idle_after,
idle_gap_seconds=_idle_gap,
tokens=_idle_tokens,
floor_tokens=_idle_floor,
cooldown_active=bool(_idle_cooldown),
):
logger.info(
"Idle compaction: %ss idle >= %ss, ~%s tokens > %s floor "
"(session %s)",
int(_idle_gap),
_idle_after,
f"{_idle_tokens:,}",
f"{_idle_floor:,}",
agent.session_id or "none",
)
agent._emit_status(
IDLE_COMPACTION_STATUS_TEMPLATE.format(
idle_seconds=int(_idle_gap), tokens=_idle_tokens
)
)
_idle_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_idle_tokens,
task_id=effective_task_id,
)
# ``_compress_context`` returns the INPUT list object when it
# skips (per-session lock held by another path, failure
# cooldown, anti-thrash breaker, codex-native routing). Only
# re-baseline + re-anchor after a real compaction — a skip
# must leave the turn's flush baseline and user-message index
# untouched.
if messages is not _idle_input:
conversation_history = conversation_history_after_compression(
agent, messages
)
# Compaction rebuilt the list, so the index of this turn's
# just-appended user message is stale — re-anchor it the
# same way the preflight path does below.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
@ -666,9 +778,10 @@ def build_turn_context(
f"{_compressor.context_length:,}",
)
agent._emit_status(
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
tokens=_preflight_tokens,
threshold=_compressor.threshold_tokens,
)
)
# Preflight passes honor the same configured per-turn cap
# (compression.max_attempts) as the loop's compression sites;

View file

@ -73,6 +73,10 @@ class TurnRetryState:
# was rolled back off ``messages`` and the loop should re-issue the API
# call against the newly-activated provider (#32421).
restart_with_rebuilt_messages: bool = False
# A user correction cancelled the in-flight provider request. The outer
# loop must append a role-safe checkpoint + user message, rebuild the API
# payload, and retry the same logical iteration.
restart_with_redirected_messages: bool = False
def __iter__(self):
# Convenience for debugging / tests: iterate (name, value) pairs.

View file

@ -60,10 +60,12 @@ def _db_path() -> Path:
def _connect() -> sqlite3.Connection:
from hermes_state import apply_wal_with_fallback
path = _db_path()
path.parent.mkdir(parents=True, exist_ok=True)
conn = sqlite3.connect(path)
conn.execute("PRAGMA journal_mode=WAL")
apply_wal_with_fallback(conn, db_label="verification_evidence.db")
conn.execute("PRAGMA busy_timeout=5000")
conn.row_factory = sqlite3.Row
_ensure_schema(conn)

View file

@ -8,13 +8,10 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { test } from './test'
import { expect, test } from './test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
@ -61,7 +58,7 @@ test.describe('chat interaction with mock backend', () => {
return (body.textContent ?? '').includes('Hello, can you hear me?')
},
undefined,
{ timeout: 15_000 },
{ timeout: 15_000 }
)
// Wait for the mock response to appear. The canned reply is:
@ -81,11 +78,61 @@ test.describe('chat interaction with mock backend', () => {
return text.includes('mock inference server') || text.includes('boot chain is working')
},
undefined,
{ timeout: 60_000 },
{ timeout: 60_000 }
)
})
test('screenshot of chat with messages', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
})
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
const page = fixture!.page
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]')
const speakReplies = page.locator(
'[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]'
)
await composer.click()
await composer.type(BLOCKING_CLARIFY_TRIGGER)
await page.keyboard.press('Enter')
await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 })
await expect(primary).toHaveAttribute('aria-label', 'Stop')
await expect(primary.locator('span')).toHaveClass(/bg-current/)
await composer.click()
await composer.type('please answer tersely')
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await expect(dictation).toBeVisible()
await expect(speakReplies).toBeVisible()
await expect(queue).toBeVisible()
await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible()
const controlLabels = await page
.locator('[data-slot="composer-root"] button')
.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))
const speakRepliesIndex = controlLabels.findIndex(
label => label === 'Read replies aloud' || label === 'Stop reading replies aloud'
)
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
expect(controlLabels.indexOf('Queue message')).toBeLessThan(
controlLabels.findIndex(label => label?.startsWith('Steer'))
)
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible()
await queue.click()
await expect(primary).toHaveAttribute('aria-label', 'Stop')
await expect(queue).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') })
await expect(page.getByText('1 Queued')).toBeVisible()
await primary.click()
await expect(page.getByText('1 Queued — paused')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') })
})
})

View file

@ -0,0 +1,210 @@
/**
* Regression coverage for a correction sent during a live response, then a
* warm session switch away and back. The correction is an accepted user turn,
* not an optimistic duplicate of the original prompt, and its relative place
* in the transcript must survive the resume reconciliation.
*/
import { type TestInfo } from '@playwright/test'
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from './mock-server'
const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.'
const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.`
const CORRECTION = 'E2E correction must stay after the original prompt.'
const TOOL_STARTED = 'Checking the long-running task before I continue.'
const CORRECTED_REPLY = 'The corrected task finished.'
const INFERENCE_SWITCH_TRIGGER = 'E2E_INFERENCE_SWITCH_TRIGGER'
const INFERENCE_PROMPT = `${INFERENCE_SWITCH_TRIGGER}: original inference prompt must remain singular.`
const INFERENCE_CORRECTION = `${INFERENCE_SWITCH_TRIGGER}: correction sent while inference is live.`
async function send(page: Page, text: string): Promise<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)
})
})

View file

@ -27,7 +27,7 @@ import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer } from './mock-server'
import { startMockServer, type MockServerOptions } from './mock-server'
import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
@ -140,15 +140,30 @@ export function createSandbox(prefix: string): Sandbox {
* Write a config.yaml that pre-configures a mock provider pointing at the
* mock inference server. The provider is set as the active model provider so
* the desktop app skips onboarding and boots straight to the chat UI.
*
* @param extraDisplayConfig optional YAML lines appended to the `display:`
* section, used by the interim-message e2e test.
* @param extraConfig optional top-level YAML sections for a test scenario.
* @param modelContextLength optional primary-model context limit.
*/
export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
export function writeMockProviderConfig(
hermesHome: string,
mockUrl: string,
extraDisplayConfig?: string,
extraConfig?: string,
modelContextLength?: number,
): void {
const configPath = path.join(hermesHome, 'config.yaml')
const displaySection = extraDisplayConfig
? `\ndisplay:\n${extraDisplayConfig}\n`
: ''
const config = `# Auto-generated by E2E test fixtures
model:
default: mock-model
provider: mock
providers:
${modelContextLength ? ` context_length: ${modelContextLength}\n` : ''}providers:
mock:
api: ${mockUrl}/v1
name: Mock
@ -157,7 +172,7 @@ providers:
models:
mock-model: {}
context_length: 4096
`
${displaySection}${extraConfig ? `\n${extraConfig.trim()}\n` : ''}`
fs.writeFileSync(configPath, config, 'utf8')
}
@ -318,11 +333,25 @@ export async function launchDesktop(
export interface MockBackendFixture {
app: ElectronApplication
page: Page
mock: Awaited<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
/** Additional top-level config.yaml sections for an E2E scenario. */
extraConfig?: string
/** Override the mock model's context window for compression scenarios. */
modelContextLength?: number
}
/**
* Set up a full mock-backend E2E environment:
* 1. Start the mock inference server
@ -330,13 +359,23 @@ export interface MockBackendFixture {
* 3. Launch the desktop app
* 4. Return handles for test interaction
*/
export async function setupMockBackend(): Promise<MockBackendFixture> {
export interface MockBackendOptions {
mockServer?: MockServerOptions
}
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer()
const mock = await startMockServer(options.mockServer)
// 2. Create sandbox + write config
const sandbox = createSandbox('mock')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeMockProviderConfig(
sandbox.hermesHome,
mock.url,
options.extraDisplayConfig,
options.extraConfig,
options.modelContextLength,
)
writeEnvFile(sandbox.hermesHome)
// 3. Build env + launch
@ -346,6 +385,7 @@ export async function setupMockBackend(): Promise<MockBackendFixture> {
return {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {

View file

@ -0,0 +1,215 @@
/**
* E2E test for the interim-assistant-message preservation fix (#65919).
*
* Reproduces the bug across all three layers (agent core tui_gateway
* desktop renderer): when the agent emits assistant text alongside a tool
* call, then completes the turn with a *different* final answer, the
* interim text must survive in the transcript not be wiped when
* message.complete replaces the streaming bubble.
*
* The mock server walks through a multi-turn script when it sees the
* trigger keyword:
*
* Turn 1: "Let me start by planning the approach." + todo tool_call
* Turn 2: "Now checking the details before answering." + todo tool_call
* Turn 3: (no text) + todo tool_call NO interim (no visible text)
* Turn 4: "Found something interesting worth noting." + todo tool_call
* Turn 5: "All done! Here is the complete summary..." (final, stop)
*
* Two describe blocks exercise the config flag both ways:
*
* display.interim_assistant_messages: true (default)
* ALL interim texts AND the final text must be visible in the
* transcript.
*
* display.interim_assistant_messages: false
* only the final text is visible (no message.interim events emitted,
* so all streamed interim text is replaced at message.complete).
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { INTERIM_TEXTS, restartMockServer } from './mock-server'
// ─── Helpers ──────────────────────────────────────────────────────────
/** Unique trigger keyword the mock server detects to switch to the script. */
const TRIGGER = 'E2E_INTERIM_TRIGGER'
/**
* Send a message and wait for BOTH the user's message and the agent's
* final response to appear in the transcript. Returns when the final text
* is visible, which means message.complete has fired and the transcript
* has settled.
*/
async function sendInterimMessage(page: Page): Promise<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)
}
})
})

View file

@ -0,0 +1,235 @@
import { spawnSync } from 'node:child_process'
import * as path from 'node:path'
import { type TestInfo } from '@playwright/test'
import { expect, test, type ElectronApplication, type Page } from './test'
import {
buildAppEnv,
createSandbox,
launchDesktop,
type Sandbox,
waitForAppReady,
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py')
const SESSION_TITLE = 'E2E large persisted session'
const EXPECTED_TEXT = 'E2E persisted user message 52'
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
interface SeededFixture {
app: ElectronApplication
mock: MockServer
mockUrl: string
page: Page
sandbox: Sandbox
cleanup: () => Promise<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)
})
}
})

View file

@ -15,17 +15,250 @@
*/
import http from 'node:http'
import type { ServerResponse } from 'node:http'
/** A canned assistant reply used for every chat completion request. */
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
export interface MockServerOptions {
/** Pause the matching stream after its first token for session-switch E2E coverage. */
holdFirstStreamForPrompt?: string
/** Pause the first completion whose request JSON contains this text. */
holdFirstCompletionContaining?: string
}
export interface MockServer {
port: number
url: string
receivedPrompts: string[]
waitForHeldStream: () => Promise<void>
waitForHeldCompletion: () => Promise<void>
releaseHeldStream: () => void
heldCompletionCount: () => number
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
}
/**
* Start the mock server on an ephemeral port.
*
* @returns a handle with `port`, `url`, and `close()`.
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
*/
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
export function startMockServer(options: MockServerOptions = {}): Promise<MockServer> {
return new Promise((resolve, reject) => {
const receivedPrompts: string[] = []
let resolveHeldStreamStarted: (() => void) | null = null
let releaseHeldStream: (() => void) | null = null
let heldCompletionCount = 0
const heldStreamStarted = new Promise<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.
@ -75,88 +308,128 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
// malformed JSON — treat as non-streaming with defaults
}
const lastUserMessage = [...(parsed.messages ?? [])]
.reverse()
.find((message: { role?: unknown }) => message?.role === 'user')
if (typeof lastUserMessage?.content === 'string') {
receivedPrompts.push(lastUserMessage.content)
}
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
const holdThisCompletion = Boolean(
options.holdFirstCompletionContaining &&
heldCompletionCount === 0 &&
JSON.stringify(parsed).includes(options.holdFirstCompletionContaining),
)
// Detect the interim-message test trigger: the user's message
// contains a specific keyword. The mock walks through the
// INTERIM_SCRIPT turns in sequence.
//
// The trigger keyword is chosen so normal chat tests (which send
// "Hello, can you hear me?" etc.) never hit this path.
const messages: any[] = Array.isArray(parsed.messages) ? parsed.messages : []
const lastUserMsg = [...messages].reverse().find(m => m?.role === 'user')
const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : ''
if (userText) {
_receivedUserTexts.push(userText)
}
const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER')
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
const isCorrectionSwitchTrigger = messages.some(
message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER),
)
if (includesBlockingClarifyTrigger(parsed.messages)) {
if (stream) {
streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
} else {
nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
}
return
}
if (isQueueStopTrigger) {
const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1]
_queueStopIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isCorrectionSwitchTrigger) {
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
_correctionSwitchIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isSidebarCrossTrigger) {
const turn = SIDEBAR_CROSS_SCRIPT[_sidebarCrossIndex] ?? SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1]
_sidebarCrossIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isSidebarTrigger) {
const turn = SIDEBAR_SCRIPT[_sidebarScriptIndex] ?? SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1]
_sidebarScriptIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isInterimTrigger) {
const turn = INTERIM_SCRIPT[_scriptIndex] ?? INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1]
_scriptIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (stream) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
// Send the content in a few chunks to simulate streaming.
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
// Final chunk with finish_reason
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: 'stop',
},
],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: { content: word },
finish_reason: null,
},
],
})}\n\n`,
)
i++
// Small delay between chunks to simulate real streaming.
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
// Non-streaming response
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [
{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
const holdThisStream = Boolean(
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
)
streamTextResponse(res, model, MOCK_REPLY, holdThisStream || holdThisCompletion ? () => {
if (holdThisCompletion) {
heldCompletionCount++
}
resolveHeldStreamStarted?.()
return heldStreamReleased
} : undefined)
} else {
if (holdThisCompletion) {
heldCompletionCount++
resolveHeldStreamStarted?.()
void heldStreamReleased.then(() => nonStreamingTextResponse(res, model, MOCK_REPLY))
} else {
nonStreamingTextResponse(res, model, MOCK_REPLY)
}
}
})
@ -187,6 +460,11 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
resolve({
port,
url,
receivedPrompts,
waitForHeldStream: () => heldStreamStarted,
waitForHeldCompletion: () => heldStreamStarted,
releaseHeldStream: () => releaseHeldStream?.(),
heldCompletionCount: () => heldCompletionCount,
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
@ -201,3 +479,238 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
})
})
}
// ─── Response helpers ──────────────────────────────────────────────────
/** SSE chunk shape for a streaming chat completion. */
function sseChunk(model: string, delta: Record<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

View file

@ -0,0 +1,119 @@
/**
* A queued prompt must remain local until the current inference turn settles.
*
* Hold the first streamed reply open after its first token. This gives the
* composer a live, busy turn while the user queues a follow-up, then lets us
* assert against the mock provider's real request log before and after the
* held turn completes.
*/
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { MOCK_REPLY } from './mock-server'
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
async function send(page: Page, text: string): Promise<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()
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Enter')
}
async function queue(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Control+Enter')
}
async function transcriptMessageOrder(page: Page): Promise<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!
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await queue(page, QUEUED_PROMPT)
await expect(page.getByText('1 Queued')).toBeVisible()
// The mock keeps the active SSE stream open, so a queued prompt has no
// completed-turn boundary that could legitimately drain it. Wait past the
// queue retry interval and assert the provider saw only the active turn.
await page.waitForTimeout(1_000)
expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0)
await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT)
mock.releaseHeldStream()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 }
)
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
})
test('places a steer prompt before the reply it redirects', async () => {
const { mock, page } = fixture!
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await steer(page, STEER_PROMPT)
mock.releaseHeldStream()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 }
)
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
})
})

View file

@ -0,0 +1,52 @@
#!/usr/bin/env python3
"""Seed a deterministic, tool-free large session into an isolated state.db."""
import sys
from pathlib import Path
repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(repo_root))
from hermes_state import SessionDB # noqa: E402
SESSION_ID = "e2e-large-session"
SESSION_TITLE = "E2E large persisted session"
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit(f"usage: {sys.argv[0]} <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()

View file

@ -0,0 +1,146 @@
/**
* E2E coverage for session compression, which rotates a live backend session.
*/
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { MOCK_REPLY, receivedUserTexts, restartMockServer } from './mock-server'
async function send(page: Page, text: string, delay = 15): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay })
await page.keyboard.press('Enter')
}
async function pasteAndSend(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await page.keyboard.insertText(text)
await page.keyboard.press('Enter')
}
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<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' })
})
})
test.describe('session compression in progress', () => {
let fixture: MockBackendFixture
test.beforeAll(async () => {
fixture = await setupMockBackend({
modelContextLength: 64_000,
extraConfig: `compression:
threshold_tokens: 22000
protect_first_n: 0
protect_last_n: 1
auxiliary:
compression:
provider: custom
model: mock-model`,
mockServer: {
holdFirstCompletionContaining: 'You are a summarization agent creating a context checkpoint.',
}
})
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('queues an Enter-submitted draft instead of steering while compaction is active', async ({}, testInfo) => {
const { page } = fixture
const queued = 'E2E_QUEUED_DURING_COMPACTION'
// A normal message crosses the tiny configured context budget. The mock
// blocks only the resulting summary request, so these assertions run
// during automatic compaction rather than a slash-command path.
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_ONE '.repeat(5))
await waitForTranscript(page, MOCK_REPLY)
await pasteAndSend(page, 'E2E_COMPACTION_HISTORY_TWO '.repeat(5))
await waitForTranscript(page, MOCK_REPLY)
await pasteAndSend(page, 'E2E_TRIGGER_AUTOMATIC_COMPACTION '.repeat(500))
await fixture.mock.waitForHeldCompletion()
await expect(page.getByRole('status', { name: 'Summarizing thread' }).last()).toBeVisible()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await expect(primary).toHaveAttribute('aria-label', 'Queue message')
await send(page, queued)
await expect(page.getByText('1 Queued')).toBeVisible()
expect(fixture.mock.heldCompletionCount()).toBe(1)
expect(receivedUserTexts()).not.toContain(queued)
await page.screenshot({ path: testInfo.outputPath('queued-during-compaction.png') })
fixture.mock.releaseHeldStream()
await expect.poll(() => receivedUserTexts().filter(text => text === queued).length).toBe(1)
expect(fixture.mock.heldCompletionCount()).toBe(1)
})
})

View file

@ -0,0 +1,252 @@
/**
* E2E tests for desktop sidebar states background processes, subagents,
* and session dot transitions.
*
* The mock server returns scripted tool_calls that the agent executes for
* real (trivial commands + real subagent delegations). The tests assert the
* sidebar states driven by real gateway events.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { SIDEBAR_CROSS_TEXTS, SIDEBAR_TEXTS, restartMockServer } from './mock-server'
/** Background-running dot aria-label (from i18n en.ts). */
const BG_DOT_LABEL = 'Background task running'
/** Finished-unread dot aria-label. */
const UNREAD_DOT_LABEL = 'Finished — unread'
/** Send a message and wait for the final response to appear. */
async function sendMessageAndWait(
page: Page,
trigger: string,
finalText: string,
timeout = 90_000,
): Promise<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' })
})
})

View file

@ -0,0 +1,75 @@
/**
* Regression coverage for #69578: harmless route-token churn during a send
* must not make the desktop silently drop the prompt before prompt.submit.
*/
import { test, expect } from './test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
const PROMPT = 'E2E route token drift must still submit this prompt.'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => {
const { page, mock } = fixture!
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(PROMPT, { delay: 10 })
// The submit pipeline snapshots the route synchronously, then awaits session
// creation. Keep changing only the query string of whichever chat route is
// current. Before #69578, comparing the raw route token treated this as a
// user chat switch and aborted before prompt.submit.
await page.evaluate(() => {
let revision = 0
const interval = window.setInterval(() => {
const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new'
window.location.hash = `${pathname}?e2e-route-churn=${revision++}`
}, 1)
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => {
window.clearInterval(interval)
}
})
try {
await page.keyboard.press('Enter')
await expect
.poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 })
.toBe(true)
await page.waitForFunction(
prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false,
PROMPT,
{ timeout: 15_000 },
)
await page.waitForFunction(
() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false,
undefined,
{ timeout: 60_000 },
)
} finally {
await page.evaluate(() => {
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.()
})
}
await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') })
})

View file

@ -0,0 +1,210 @@
/**
* E2E tests for the tile-unread bug two scenarios:
*
* 1. TAB (stacked, not visible) a session opened as a tab via -click is
* NOT visible on screen. When it finishes, the green "unread" dot IS
* correct the user isn't looking at it. This test PASSES.
*
* 2. SPLIT (side-by-side, visible) a session dragged to the edge of the
* workspace zone opens as a split tile, visible on screen at the same time
* as the main session. When it finishes, it should NOT get the green
* "unread" dot the user is looking right at it. This test FAILS until
* the fix in session-states.ts:174 lands (the unread check only compares
* against $selectedStoredSessionId and ignores $sessionTiles).
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { SIDEBAR_CROSS_TEXTS, restartMockServer } from './mock-server'
/** Finished-unread dot aria-label. */
const UNREAD_DOT_LABEL = 'Finished — unread'
/** Background-running dot aria-label. */
const BG_DOT_LABEL = 'Background task running'
/** Locate a session's sidebar row by its preview text. */
function sessionRow(page: import('@playwright/test').Page, text: string) {
return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first()
}
/** Common setup: start a turn with a sleep 5 bg process + subagent, wait for
* the turn to complete, then switch to a new session so the first session is
* no longer $selectedStoredSessionId (required before opening a tile). */
async function startTurnAndSwitchAway(page: import('@playwright/test').Page) {
// Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the user's message to appear.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
undefined,
{ timeout: 15_000 },
)
// Wait for the background dot — confirms the turn is running.
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should appear' },
)
.toBeGreaterThan(0)
// Wait for the turn to complete (final answer visible).
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
SIDEBAR_CROSS_TEXTS.finalText,
{ timeout: 90_000 },
)
// The background dot should still be visible (sleep 5 hasn't finished).
const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0)
// Switch to a new session — session A is no longer $selectedStoredSessionId.
// This is required: openSessionTile bails if the session is already selected.
await page.locator('button:has-text("New session")').first().click()
await page.waitForTimeout(2000)
}
/** Wait for the background process to finish (sleep 5 + auto-dismiss). */
async function waitForBgProcessToFinish(page: import('@playwright/test').Page) {
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
)
.toBe(0)
}
// ────────────────────────────────────────────────────────────────────────
// Test 1: TAB (not visible) — unread dot IS correct (PASSES)
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — tab (hidden) unread is correct', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('session opened as a tab (not visible) correctly gets unread dot', async () => {
const page = fixture.page
await startTurnAndSwitchAway(page)
// Evidence: session A is in the background (bg dot in sidebar).
await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' })
// ⌃-click opens the session as a TAB (center dock = stacked, not visible
// unless it's the active tab). The session is NOT on screen.
const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText)
await row.click({ modifiers: ['Control'] })
await page.waitForTimeout(2000)
// Evidence: the tab is open but the session is not visible on screen.
await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' })
await waitForBgProcessToFinish(page)
// A tab that's not the active tab IS hidden — the unread dot is correct.
// The user is NOT looking at it, so marking it "unread" is right.
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
expect(unreadCount, 'hidden tab should be marked unread').toBeGreaterThan(0)
await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' })
})
})
// ────────────────────────────────────────────────────────────────────────
// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix)
// ────────────────────────────────────────────────────────────────────────
test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('session visible in a split tile does NOT get unread dot when it finishes', async () => {
const page = fixture.page
await startTurnAndSwitchAway(page)
// Evidence: session A is in the background (bg dot in sidebar).
await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' })
// Drag the session row from the sidebar to the right edge of the workspace
// zone to create a SPLIT (side-by-side) tile. This triggers the real
// startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path.
const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText)
const rowBox = await row.boundingBox()
expect(rowBox, 'session row must be visible').not.toBeNull()
// Find the workspace zone — the main chat area. We drop on its right edge.
const workspace = page.locator('[data-session-anchor="workspace"]')
const wsBox = await workspace.boundingBox()
expect(wsBox, 'workspace zone must be visible').not.toBeNull()
// Drag from the session row to the right edge of the workspace.
// The drag-session's subZonePosition resolves a right-edge drop as 'right'
// (a split dock), not 'center' (which would be a composer link).
await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2)
await page.mouse.down()
// Move in steps so the drag-session's pointermove handler tracks the
// position and resolves the drop zone (a single jump can miss the
// threshold/engage logic).
const targetX = wsBox!.x + wsBox!.width - 20
const targetY = wsBox!.y + wsBox!.height / 2
const steps = 10
for (let i = 1; i <= steps; i++) {
const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps)
const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps)
await page.mouse.move(x, y)
await page.waitForTimeout(30)
}
await page.mouse.up()
await page.waitForTimeout(2000)
// Evidence: the split tile is now open side-by-side — both sessions visible.
await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' })
await waitForBgProcessToFinish(page)
// THE BUG: the session visible in the split tile should NOT have the green
// "finished unread" dot — the user is looking right at it. This assertion
// FAILS until the fix in session-states.ts:174 lands.
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0)
// Evidence: the green dot should NOT be here — this screenshot shows the bug.
await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' })
})
})

View file

@ -0,0 +1,484 @@
/**
* E2E regression: warm-route resume must not re-render the transcript more
* than once.
*
* When a session is already in the runtime-id cache (the "warm" path in
* `resumeSession()`), clicking its sidebar row should paint the transcript
* exactly once. Before the fix, the warm cache painted via
* `syncSessionStateToView`, then the `session.activate` RPC returned a
* reconciled message list with different message object references, causing
* `syncSessionStateToView` to fire a second `setMessages` a visual
* flicker as the transcript DOM was updated.
*
* This test pre-seeds a 32-message session into state.db, boots the app,
* clicks the session (cold resume populates the warm cache), navigates
* away to a new chat, then clicks back (warm resume). Two detectors run:
*
* 1. A MutationObserver counts additive DOM mutation bursts (childList
* additions). More than 1 burst = the transcript was repainted.
*
* 2. A 2ms innerHTML-length poll counts "reconciles" DOM content changes
* that happen AFTER the initial paint, while messages are already on
* screen. This catches the case where React reconciles by key without
* adding/removing nodes (same keys in-place prop update no
* MutationObserver burst), but `$messages` was still set twice.
*
* The test passes when bursts === 1 AND reconciles === 0.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { expect, test } from './test'
import {
type MockBackendFixture,
waitForAppReady,
createSandbox,
writeMockProviderConfig,
writeEnvFile,
buildAppEnv,
launchDesktop,
} from './fixtures'
import { startMockServer } from './mock-server'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const SEED_SCRIPT = path.join(DESKTOP_ROOT, 'e2e', 'scripts', 'seed_session_db.py')
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
const SESSION_ID = 'e2e-warm-resume-session'
/** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */
const MESSAGE_COUNT = 32
/** Seeded PRNG so the generated content is deterministic across runs. */
const RNG_SEED = 42
/** Mulberry32 — tiny deterministic PRNG. */
function mulberry32(seed: number): () => number {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** Generate ~40 chars of gibberish from a seeded PRNG. */
function gibberish(rng: () => number): string {
const len = 30 + Math.floor(rng() * 20)
let s = ''
for (let i = 0; i < len; i++) {
s += String.fromCharCode(97 + Math.floor(rng() * 26))
}
return s
}
/** First user message — used as a wait target in the test. */
const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
/**
* Generate a session fixture with MESSAGE_COUNT messages (user/assistant
* pairs) of seeded gibberish just role + content, enough for SessionDB
* to import and the transcript to render. Written to a temp file for the
* seed script.
*/
function generateSessionFixture(fixturePath: string): void {
const rng = mulberry32(RNG_SEED)
const messages: Array<{ role: string; content: string }> = []
for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
messages.push({ role: 'user', content: gibberish(rng) })
messages.push({ role: 'assistant', content: gibberish(rng) })
}
const session = {
id: SESSION_ID,
source: 'cli',
model: 'mock-model',
system_prompt: '',
started_at: 1721692800.0,
message_count: MESSAGE_COUNT,
title: SESSION_TITLE,
cwd: '/tmp',
archived: 0,
rewind_count: 0,
compression_fallback_streak: 0,
messages,
}
fs.writeFileSync(fixturePath, JSON.stringify(session), 'utf8')
}
/** Resolve the python binary from the nix devshell (falls back to python3). */
function findPython(): string {
const result = spawnSync('which', ['python'], { encoding: 'utf8' })
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim()
}
return 'python3'
}
/**
* Set up a mock-backend sandbox with a pre-seeded session in state.db.
*
* Unlike the shared `setupMockBackend()`, this variant seeds the DB
* BEFORE launching the app so the session appears in the sidebar on first
* load exercising the real `resumeSession()` cold path without needing
* to send a message first.
*/
async function setupSeededMockBackend(): Promise<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)
})

View file

@ -0,0 +1,94 @@
import { execFileSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { test, expect } from './test'
import {
buildAppEnv,
createSandbox,
launchDesktop,
writeEnvFile,
writeMockProviderConfig,
type MockBackendFixture,
waitForAppReady,
} from './fixtures'
import { startMockServer } from './mock-server'
const BRANCH_NAME = 'e2e-composer-branch'
function createGitRepo(root: string): string {
const repo = path.join(root, 'repo')
fs.mkdirSync(repo, { recursive: true })
execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo })
execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo })
execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo })
fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8')
execFileSync('git', ['add', 'README.md'], { cwd: repo })
execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo })
return repo
}
function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void {
writeMockProviderConfig(hermesHome, mockUrl)
fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8')
writeEnvFile(hermesHome)
}
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
const sandbox = createSandbox('worktree-branch-status')
const repo = createGitRepo(sandbox.root)
const mock = await startMockServer()
configureRepoCwd(sandbox.hermesHome, mock.url, repo)
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
fixture = {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('creating a branch with ctrl-shift-b updates the composer git-status branch', async ({}, testInfo) => {
const page = fixture!.page
const codingRow = page.locator('.coding-status-bar')
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('create a repo-backed e2e session', { delay: 2 })
await page.keyboard.press('Enter')
await page.waitForFunction(
prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt),
'create a repo-backed e2e session',
{ timeout: 15_000 },
)
await expect(codingRow).toContainText('main')
await page.keyboard.press('Control+Shift+B')
const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first()
await expect(branchInput).toBeVisible()
await branchInput.fill(BRANCH_NAME)
await page.getByRole('button', { name: 'New worktree' }).click()
await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 })
await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') })
})

View file

@ -12,7 +12,12 @@ import path from 'node:path'
import { test } from 'vitest'
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
import {
canImportHermesCli,
hermesRuntimeImportProbe,
shouldTrustHermesOverride,
verifyHermesCli
} from './backend-probes'
// Resolve the host's own Node binary -- guaranteed to be on disk and
// runnable. We use it as both a stand-in for "a python that doesn't
@ -51,6 +56,15 @@ test('hermes runtime import probe checks config dependencies', () => {
assert.match(probe, /\bimport hermes_cli\.config\b/)
})
test('explicit Hermes override is authoritative', () => {
assert.equal(shouldTrustHermesOverride('/nix/store/abc/bin/hermes'), true)
})
test('empty Hermes override is not authoritative', () => {
assert.equal(shouldTrustHermesOverride(''), false)
assert.equal(shouldTrustHermesOverride(undefined), false)
})
test('verifyHermesCli returns false when command is falsy', () => {
assert.equal(verifyHermesCli(''), false)
assert.equal(verifyHermesCli(null), false)

View file

@ -104,6 +104,16 @@ 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
@ -123,4 +133,4 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
}
}
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verifyHermesCli }

View file

@ -34,7 +34,7 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child'
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
import { createBackendConnectionState } from './backend-connection-state'
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
import { canImportHermesCli, verifyHermesCli } from './backend-probes'
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
import { waitForDashboardPortAnnouncement } from './backend-ready'
import { shouldLatchBackendStartFailure } from './backend-start-failure'
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
@ -80,19 +80,6 @@ import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { runNativeLogin } from './native-oauth-login'
import {
nativeRefreshUrl,
parseTokenResponse,
resolveLoginStrategy,
tokenNeedsRefresh,
type NativeTokenSet
} from './native-oauth'
import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth
} from './native-auth-decisions'
import { scanGitRepos } from './git-repo-scan'
import {
fileDiffVsHead,
@ -129,6 +116,15 @@ import {
} from './hardening'
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
import { ensureMainWindow } from './main-window-lifecycle'
import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions'
import {
nativeRefreshUrl,
type NativeTokenSet,
parseTokenResponse,
resolveLoginStrategy,
tokenNeedsRefresh
} from './native-oauth'
import { runNativeLogin } from './native-oauth-login'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { createKeepAwake } from './power-save'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
@ -3568,7 +3564,11 @@ function resolveHermesBackend(backendArgs) {
// and lets the resolver fall through to step 6 / bootstrap.
const shellForProbe = isCommandScript(hermesCommand)
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
// HERMES_DESKTOP_HERMES is an explicit deployment override (used by
// the Nix wrapper), not a discovered PATH candidate. It must not fall
// through to the install-script bootstrap if the optional probe times
// out under load; the pinned backend is the only valid runtime there.
if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
return (
unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || {
label: `existing Hermes CLI at ${hermesCommand}`,
@ -5851,6 +5851,7 @@ async function ensureNativeAccessToken(baseUrl: string): Promise<string | null>
{ refresh_token: tokens.refreshToken, provider: tokens.provider },
{ timeoutMs: 10_000 }
)
const rotated = parseTokenResponse(body)
_storeNativeTokens(baseUrl, rotated)
@ -5883,6 +5884,7 @@ async function mintGatewayWsTicket(baseUrl) {
timeoutMs: 8_000,
bearer: nativeAt
})) as any
const ticket = body?.ticket
if (!ticket || typeof ticket !== 'string') {
@ -6459,10 +6461,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
// RFC 8252 flow) counts as connected too — otherwise a completed native
// sign-in shows "not connected" in Settings. The authoritative liveness
// check is the ws-ticket mint in resolveRemoteBackend at actual connect time.
remoteOauthConnected = oauthSessionIsLive(
hasNativeSession(remoteUrl),
await hasLiveOauthSession(remoteUrl)
)
remoteOauthConnected = oauthSessionIsLive(hasNativeSession(remoteUrl), await hasLiveOauthSession(remoteUrl))
} catch {
remoteOauthConnected = false
}
@ -8955,6 +8954,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
postJson: (url, body, opts) => postJsonNoAuth(url, body, opts),
rememberLog
})
_storeNativeTokens(baseUrl, tokens)
return { ok: true, baseUrl, connected: true }
@ -8977,6 +8977,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
await clearOauthSession(baseUrl || undefined)
// Also drop any native (RFC 8252) bearer tokens for this gateway so a
// logout clears BOTH auth shapes.
if (baseUrl) {
@ -8986,9 +8987,7 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) =
// Report against the SAME liveness notion the Settings indicator uses
// (AT-or-RT cookie, or a native token) so a logout that left any session
// behind is reflected as still-connected rather than silently signed-out.
const connected = baseUrl
? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl)
: false
const connected = baseUrl ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) : false
return { ok: true, connected }
})

View file

@ -10,11 +10,7 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth
} from './native-auth-decisions'
import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions'
// --- 1. body encoding (guards the double-JSON.stringify 422) ---

View file

@ -44,9 +44,7 @@ export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: bo
return hasNativeToken || hasCookieSession
}
export type OauthRestAuth =
| { kind: 'bearer'; token: string }
| { kind: 'cookie' }
export type OauthRestAuth = { kind: 'bearer'; token: string } | { kind: 'cookie' }
/**
* Decide how an oauth-mode REST request authenticates: prefer the native

View file

@ -22,14 +22,18 @@ function makeFakeServerFactory(port = 51234) {
const createServer: any = (handler: any) => {
state.handler = handler
const server: any = new EventEmitter()
server.listen = (_port: number, _host: string, cb: () => void) => {
state.listening = true
cb()
}
server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port })
server.close = () => {
state.closed = true
}
state.server = server
return server

View file

@ -32,10 +32,10 @@ import {
buildNativeAuthorizeUrl,
generatePkcePair,
generateState,
type NativeTokenSet,
nativeTokenUrl,
parseLoopbackCallback,
parseTokenResponse,
type NativeTokenSet
parseTokenResponse
} from './native-oauth'
// Loopback login must complete inside this window (user opens browser,
@ -90,6 +90,7 @@ export async function runNativeLogin(
return new Promise<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.
@ -179,6 +180,7 @@ export async function runNativeLogin(
}
const redirectUri = `http://127.0.0.1:${addr.port}/callback`
const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, {
challenge,
redirectUri,

View file

@ -34,6 +34,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => {
assert.equal(pair.method, 'S256')
// Verifier length within RFC 7636 range (43128).
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
// Challenge must be the base64url SHA-256 of the verifier.
const expected = createHash('sha256')
.update(pair.verifier, 'ascii')
@ -41,6 +42,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => {
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
assert.equal(pair.challenge, expected)
// No padding / URL-unsafe chars.
assert.doesNotMatch(pair.verifier, /[+/=]/)
@ -91,6 +93,7 @@ test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
state: 'STATE',
provider: 'nous'
})
const parsed = new URL(url)
assert.equal(parsed.origin, 'https://gw.example.com')
@ -108,6 +111,7 @@ test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix
redirectUri: 'http://127.0.0.1:1/cb',
state: 'S'
})
const parsed = new URL(url)
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
@ -128,10 +132,7 @@ test('parseLoopbackCallback returns the code on a state match', () => {
})
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
assert.throws(
() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'),
/state mismatch/i
)
assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i)
})
test('parseLoopbackCallback surfaces a gateway error param', () => {

View file

@ -86,10 +86,7 @@ export function statusSupportsNativeFlow(statusBody: any): boolean {
* (e.g. a corporate proxy that blocks loopback). Precedence written down here,
* in one place, as a pure function per the desktop "observable ladder" rule.
*/
export function resolveLoginStrategy(
statusBody: any,
opts: { forceEmbedded?: boolean } = {}
): 'native' | 'embedded' {
export function resolveLoginStrategy(statusBody: any, opts: { forceEmbedded?: boolean } = {}): 'native' | 'embedded' {
if (opts.forceEmbedded) {
return 'embedded'
}
@ -109,6 +106,7 @@ export function buildNativeAuthorizeUrl(
): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
const q = new URLSearchParams({
code_challenge: params.challenge,
code_challenge_method: 'S256',
@ -145,10 +143,7 @@ export function nativeRefreshUrl(baseUrl: string): string {
* `expectedState` MUST match (CSRF defense RFC 6749 §10.12); a mismatch
* throws rather than proceeding.
*/
export function parseLoopbackCallback(
requestUrl: string,
expectedState: string
): { code: string } {
export function parseLoopbackCallback(requestUrl: string, expectedState: string): { code: string } {
// requestUrl is the path+query the loopback server received, e.g.
// "/callback?code=...&state=...". Resolve against a dummy origin to parse.
const parsed = new URL(requestUrl, 'http://127.0.0.1')
@ -203,7 +198,11 @@ export function parseTokenResponse(body: any): NativeTokenSet {
* before use. `skewSeconds` refreshes slightly early to avoid a race where
* the token expires in flight (mirrors the server's 60s cookie floor).
*/
export function tokenNeedsRefresh(tokens: Pick<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

View file

@ -11,15 +11,19 @@
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",
"clean:e2e":"tsc --build tsconfig.e2e.json --clean",
"clean:renderer":"tsc --build tsconfig.json --clean ",
"clean:electron":"tsc --build tsconfig.electron.json --clean",
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:mock": "node scripts/dev-mock.mjs",
"dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174",
"dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174",
"dev:electron": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"prebuild": "tsc -b . --clean",
"prebuild": "npm run clean",
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
"postbuild": "node scripts/assert-dist-built.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
@ -51,9 +55,9 @@
"test": "vitest run",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
"test:e2e": "playwright test e2e/",
"test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
"test:e2e": "npm run build && playwright test e2e/",
"test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
},
"dependencies": {
"@assistant-ui/react": "^0.14.23",

View file

@ -2,17 +2,25 @@ import { closeActiveTerminal } from '@/app/right-sidebar/terminal/terminals'
import { closeWorkspaceTab } from '@/components/pane-shell/tree/store'
import { isFocusWithin } from '@/lib/keybinds/combo'
import { $filePreviewTabs, $previewTarget, closeActiveRightRailTab } from '@/store/preview'
import { closeSessionTile, nextSessionTileForWorkspace } from '@/store/session-states'
/**
* W close the tab of the context you're in, by precedence:
* 1. a focused terminal its active terminal tab,
* 2. right-rail tabs (live preview and/or file peeks),
* 3. the MAIN zone its active tab (a session tile stacked into the workspace).
* 4. the MAIN (workspace) tab itself, when session tabs are stacked with it:
* the workspace can't close, so W shifts the NEXT session tab into main
* (loads it as the primary + drops its now-redundant tile).
* Returns false when nothing closes, so W is a no-op it never closes the
* window (a bare workspace stays put). Shared by the keyboard path (Win/Linux)
* and the macOS menu-accelerator IPC.
*
* `loadSessionIntoWorkspace` carries the app's route-based "load this session
* into main" (the two call sites have router access); omitting it disables the
* step-4 promotion (W stays the pre-existing no-op on the main tab).
*/
export function closeActiveTab(): boolean {
export function closeActiveTab(loadSessionIntoWorkspace?: (storedSessionId: string) => void): boolean {
if (isFocusWithin('[data-terminal]')) {
closeActiveTerminal()
@ -28,5 +36,27 @@ export function closeActiveTab(): boolean {
return closeActiveRightRailTab()
}
return closeWorkspaceTab()
// A closeable main-zone tab (a session tile that's the active tab) closes
// outright; the uncloseable workspace tab returns false and falls through.
if (closeWorkspaceTab()) {
return true
}
// The main (workspace) tab is active and can't be closed — but if session
// tabs are stacked with it, ⌘W shifts the next one into the main tab: drop
// its tile (the session stays alive, no busy-close prompt) and load it into
// main. Order matters — close the tile FIRST so the selection homes to the
// workspace instead of re-fronting the tile.
if (loadSessionIntoWorkspace) {
const next = nextSessionTileForWorkspace()
if (next) {
closeSessionTile(next)
loadSessionIntoWorkspace(next)
return true
}
}
return false
}

View file

@ -0,0 +1,79 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { I18nProvider } from '@/i18n'
import { ComposerControls } from './controls'
vi.mock('./model-pill', () => ({ ModelPill: () => null }))
const state: ChatBarState = {
model: { canSwitch: false, model: '', provider: '' },
tools: { enabled: false, label: '' },
voice: { active: false, enabled: false }
}
function renderControls(overrides: Partial<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 Enter for Steer', async () => {
renderControls({ busy: true, busyAction: 'steer' })
await expectShortcutTooltip('Steer the current run', '↵')
})
it('shows Ctrl+Enter for Queue', async () => {
renderControls({ busy: true, busyAction: 'queue' })
await expectShortcutTooltip('Queue message', 'Ctrl+↵')
})
})

View file

@ -1,11 +1,9 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { KbdCombo } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@ -42,7 +40,6 @@ export function ComposerControls({
autoSpeak,
busy,
busyAction,
canSteer,
canSubmit,
compactModelPill = false,
conversation,
@ -51,13 +48,12 @@ export function ComposerControls({
state,
voiceStatus,
onDictate,
onSteer,
onQueue,
onToggleAutoSpeak
}: {
autoSpeak: boolean
busy: boolean
busyAction: 'queue' | 'stop'
canSteer: boolean
busyAction: 'steer' | 'queue' | 'stop'
canSubmit: boolean
compactModelPill?: boolean
conversation: ConversationProps
@ -66,50 +62,39 @@ export function ComposerControls({
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => void
onSteer: () => void
onQueue: () => void
onToggleAutoSpeak: () => void
}) {
const { t } = useI18n()
const c = t.composer
const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = (
<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} />
{/* 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}>
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
{busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.queue" text={c.queueMessage} />}>
<Button
aria-label={steerLabel}
aria-label={c.queueMessage}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onSteer}
onClick={onQueue}
size="icon"
type="button"
variant="ghost"
>
<SteeringWheel className={iconSize.sm} />
<Layers3 className={iconSize.sm} />
</Button>
</Tip>
) : (
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
)}
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
) : null}
{showVoicePrimary ? (
<Tip label={c.startVoice}>
<Button
@ -127,9 +112,26 @@ export function ComposerControls({
</Button>
</Tip>
) : (
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
<Tip
label={
busy ? (
<TipKeybindLabel
actionId={
busyAction === 'steer'
? 'composer.steer'
: busyAction === 'queue'
? 'composer.queue'
: 'composer.send'
}
text={busyLabel}
/>
) : (
<TipKeybindLabel actionId="composer.send" text={c.send} />
)
}
>
<Button
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
aria-label={busy ? busyLabel : c.send}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
@ -137,6 +139,8 @@ 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" />
)

View file

@ -0,0 +1,167 @@
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
compacting?: boolean
text?: string
}
function renderSubmitHook({
attachments = [],
busy = false,
compacting = 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,
compacting,
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('queues a plain-text follow-up while the active turn is compacting', () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
compacting: true,
text: 'wait for the summary'
})
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onCancel).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()
})
})

View file

@ -17,7 +17,7 @@ interface UseComposerSubmitArgs {
activeQueueSessionKeyRef: RefObject<string | null>
attachments: ComposerAttachment[]
busy: boolean
canSteer: boolean
compacting: boolean
clearDraft: () => void
disabled: boolean
draftRef: RefObject<string>
@ -43,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` nudges the live turn. Reads
* rejects, so nothing is ever lost); `steerDraft` redirects the live turn. Reads
* the draft + queue APIs; owns no state of its own beyond the stable
* external-submit listener ref.
*/
@ -52,7 +52,7 @@ export function useComposerSubmit({
activeQueueSessionKeyRef,
attachments,
busy,
canSteer,
compacting,
clearDraft,
disabled,
draftRef,
@ -151,7 +151,14 @@ export function useComposerSubmit({
triggerHaptic('submit')
clearDraft()
dispatchSubmit(text)
} else if (!compacting && !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
@ -173,16 +180,18 @@ export function useComposerSubmit({
focusInput()
}
// 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.
// 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.
const steerDraft = () => {
if (!onSteer || !canSteer) {
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)) {
return
}
const text = draftRef.current.trim()
triggerHaptic('submit')
clearDraft()
@ -193,5 +202,14 @@ export function useComposerSubmit({
})
}
return { dispatchSubmit, steerDraft, submitDraft }
const queueDraft = () => {
if (disabled || !busy) {
return
}
queueCurrentDraft()
focusInput()
}
return { dispatchSubmit, queueDraft, steerDraft, submitDraft }
}

View file

@ -1,7 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { chatMessageText } from '@/lib/chat-messages'
import { chatMessageText, collectUnspokenTurnSpeech } from '@/lib/chat-messages'
import { triggerHaptic } from '@/lib/haptics'
import { resetBrowseState } from '@/store/composer-input-history'
import { notifyError } from '@/store/notifications'
@ -60,6 +60,7 @@ export function useComposerVoice({
onTranscribeAudio
})
/** Auto-speak selector: the latest unspoken reply only — a backlog collapses to the newest. */
const pendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
@ -81,6 +82,13 @@ export function useComposerVoice({
}
}
/**
* Voice-conversation selector: every unspoken assistant bubble of the turn,
* in order narration interims AND the final answer, not just whichever
* bubble happens to be last. See `collectUnspokenTurnSpeech`.
*/
const pendingTurnResponse = () => collectUnspokenTurnSpeech($messages.get(), lastSpokenIdRef.current)
const consumePendingResponse = () => {
const messages = $messages.get()
const last = messages.findLast(m => m.role === 'assistant' && !m.hidden)
@ -108,7 +116,7 @@ export function useComposerVoice({
onFatalError: () => setVoiceConversationActive(false),
onSubmit: submitVoiceTurn,
onTranscribeAudio,
pendingResponse
pendingResponse: pendingTurnResponse
})
// The `composer.voice` hotkey (Ctrl+B) toggles the conversation. Starting

View file

@ -1,7 +1,14 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
import {
markVoicePlaybackInterrupted,
playSpeechText,
type SpeechStreamSession,
startSpeechStream,
stopVoicePlayback
} from '@/lib/voice-playback'
import { notify, notifyError } from '@/store/notifications'
import { useMicRecorder } from './use-mic-recorder'
@ -44,7 +51,9 @@ export function useVoiceConversation({
const awaitingSpokenResponseRef = useRef(false)
const responseIdRef = useRef<string | null>(null)
const spokenSourceLengthRef = useRef(0)
const speechBufferRef = useRef('')
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
const bargeCapturePendingRef = useRef(false)
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
const busyRef = useRef(busy)
@ -74,60 +83,13 @@ export function useVoiceConversation({
}
}
const resetSpeechBuffer = () => {
const dropSpeechSession = () => {
stopBargeMonitorRef.current?.()
stopBargeMonitorRef.current = null
bargeCapturePendingRef.current = false
speechSessionRef.current = null
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(
@ -167,7 +129,7 @@ export function useVoiceConversation({
}
awaitingSpokenResponseRef.current = true
resetSpeechBuffer()
dropSpeechSession()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
@ -193,6 +155,10 @@ export function useVoiceConversation({
return
}
if (bargeCapturePendingRef.current) {
return // the barge monitor is mid-capture and owns the mic
}
if (statusRef.current !== 'idle') {
return
}
@ -220,24 +186,238 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const speak = useCallback(
async (text: string) => {
setStatus('speaking')
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 {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
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()
}
},
[voiceCopy.playbackFailed]
[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
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
}
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]
)
const start = useCallback(async () => {
@ -254,7 +434,7 @@ export function useVoiceConversation({
setMuted(false)
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
dropSpeechSession()
consumePendingResponse()
pendingStartRef.current = true
await startListening()
@ -274,7 +454,7 @@ export function useVoiceConversation({
handle.cancel()
turnClosingRef.current = false
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
dropSpeechSession()
consumePendingResponse()
setMuted(false)
setStatus('idle')
@ -325,8 +505,9 @@ export function useVoiceConversation({
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, stopTurn])
// Drive the loop: after a voice-submitted turn, speak stable chunks as the
// assistant stream grows. Otherwise start listening when idle between turns.
// 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.
useEffect(() => {
if (!enabled || muted) {
return
@ -336,38 +517,15 @@ export function useVoiceConversation({
const response = pendingResponse()
if (response) {
if (response.id !== responseIdRef.current) {
resetSpeechBuffer()
responseIdRef.current = response.id
}
openLiveSpeech(response.id)
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
}
return
}
if (!busy && status === 'thinking') {
// Turn finished without any speakable reply (tool-only, error).
awaitingSpokenResponseRef.current = false
resetSpeechBuffer()
dropSpeechSession()
pendingStartRef.current = true
setStatus('idle')
@ -382,7 +540,7 @@ export function useVoiceConversation({
if (pendingStartRef.current) {
void startListening()
}
}, [busy, consumePendingResponse, enabled, muted, pendingResponse, speak, startListening, status])
}, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status])
useEffect(() => {
if (enabled && !wasEnabledRef.current) {

View file

@ -11,6 +11,7 @@ import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { DATA_IMAGE_URL_RE } from '@/lib/embedded-images'
import { triggerHaptic } from '@/lib/haptics'
import { cn } from '@/lib/utils'
import { $compactionActive } from '@/store/compaction'
import { browseBackward, browseForward, deriveUserHistory, isBrowsingHistory } from '@/store/composer-input-history'
import { POPOUT_WIDTH_REM } from '@/store/composer-popout'
import { parkQueuedPrompts, removeQueuedPrompt, unparkQueuedPrompts } from '@/store/composer-queue'
@ -104,6 +105,7 @@ export function ChatBar({
// focus-bus key, and awaiting-input edge. Main scope = the legacy globals.
const scope = useComposerScope()
const attachments = useStore(scope.attachments.$attachments)
const compacting = useStore($compactionActive)
const scrolledUp = useStore($threadScrolledUp)
const autoSpeak = useStore($autoSpeakReplies)
// The turn is parked on the user (clarify / approval / sudo / secret). Esc must
@ -227,20 +229,27 @@ 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
const canSteer = busy && !compacting && !!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'
: compacting || 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 { steerDraft, submitDraft } = useComposerSubmit({
const { queueDraft, steerDraft, submitDraft } = useComposerSubmit({
activeQueueSessionKey,
activeQueueSessionKeyRef,
attachments,
busy,
canSteer,
compacting,
clearDraft,
disabled,
draftRef,
@ -578,14 +587,22 @@ export function ChatBar({
return
}
// Cmd/Ctrl+Enter is reserved for steering the live run — never a send.
// Steer when there's a steerable draft, otherwise swallow it so it can't
// surprise-send. (Plain Enter still queues while busy / sends when idle.)
// Cmd/Ctrl+Enter queues a follow-up while a turn runs. Plain Enter steers
// a text-only draft, so both live-turn actions stay reachable by keyboard.
if (event.key === 'Enter' && (event.metaKey || event.ctrlKey) && !event.shiftKey) {
event.preventDefault()
if (canSteer) {
steerDraft()
if (busy && !disabled) {
// As with plain Enter, source the just-typed content from the DOM so a
// fast keypress cannot queue a stale draft.
const editorText = editorRef.current ? composerPlainText(editorRef.current) : draftRef.current
if (editorText !== draftRef.current) {
draftRef.current = editorText
setComposerText(editorText)
}
queueDraft()
}
return
@ -721,7 +738,6 @@ export function ChatBar({
autoSpeak={autoSpeak}
busy={busy}
busyAction={busyAction}
canSteer={canSteer}
canSubmit={canSubmit}
compactModelPill={poppedOut || compactPill}
conversation={{
@ -737,7 +753,7 @@ export function ChatBar({
disabled={disabled}
hasComposerPayload={hasComposerPayload}
onDictate={dictate}
onSteer={steerDraft}
onQueue={queueDraft}
onToggleAutoSpeak={handleToggleAutoSpeak}
state={state}
voiceStatus={voiceStatus}

View file

@ -4,6 +4,7 @@ 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'
@ -11,6 +12,7 @@ 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,
@ -70,6 +72,7 @@ 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] ?? []) : []),
@ -123,6 +126,13 @@ 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,

View file

@ -31,9 +31,10 @@ export interface PaneMirror<T> {
before?: (tile: T) => null | string | undefined
minWidth: string
title: (key: string) => string
/** Lead-dot color for the tile's tab (e.g. a session's project color). Re-read
* on every `also` change, so pass the color source in `also` to keep it live. */
accent?: (key: string) => string | undefined
/** Custom lead NODE for the tile's tab (rendered before the label). A live,
* self-subscribing component (e.g. a session's status dot) so the strip needn't
* re-sync on status/color change only `title` drives re-registration. */
tabLead?: (key: string) => ReactNode
render: (key: string) => ReactNode
/** Wrap the tile's TAB (domain context menu — session verbs). */
tabWrap?: (key: string, tab: ReactElement) => ReactNode
@ -52,7 +53,7 @@ export interface PaneMirror<T> {
/** Build a `watch*` fn: syncs once, then re-syncs on every source/also change.
* Module-level state lives in the returned closure, so call it once per app. */
export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
const registered = new Map<string, { dispose: () => void; title: string; accent?: string }>()
const registered = new Map<string, { dispose: () => void; title: string }>()
const paneId = (key: string) => `${cfg.prefix}:${key}`
const sync = () => {
@ -62,11 +63,10 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
for (const tile of tiles) {
const key = cfg.key(tile)
const title = cfg.title(key)
const accent = cfg.accent?.(key)
const current = registered.get(key)
// register() replaces same-id in place — safe for live title/accent refreshes.
if (current && current.title === title && current.accent === accent) {
// register() replaces same-id in place — safe for live title refreshes.
if (current && current.title === title) {
continue
}
@ -75,7 +75,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
area: 'panes',
title,
data: {
accent,
tabLead: cfg.tabLead ? () => cfg.tabLead!(key) : undefined,
dock: {
before: cfg.before?.(tile),
pane: cfg.anchor?.(tile) ?? 'workspace',
@ -92,7 +92,7 @@ export function paneMirror<T>(cfg: PaneMirror<T>): () => void {
render: () => cfg.render(key)
})
registered.set(key, { dispose, title, accent })
registered.set(key, { dispose, title })
if (!current) {
registerPaneCloser(paneId(key), () => cfg.close(key))

View file

@ -0,0 +1,141 @@
import { useStore } from '@nanostores/react'
import { type Translations, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import { $attentionSessionIds, $stalledSessionIds, $workingSessionIds } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { type SessionDotState, sessionDotState } from './sidebar/session-row-state'
// A pure lookup table: each state maps to its className, aria-label, and title.
// No priority resolution here — sessionDotState already picked one. Label/title
// resolve from sidebar.row translations, keyed by name.
type DotVariant = {
ariaLabel?: (r: Translations['sidebar']['row']) => string
className: string
role?: 'status'
title?: (r: Translations['sidebar']['row']) => string
}
// Shared base for every active dot; idle is smaller and uses its own class.
const DOT_BASE = 'relative size-1.5 rounded-full'
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
// the two pulsing dots. The `before:bg-*` color is written inline per variant
// (NOT interpolated here): Tailwind only generates utilities it can see as
// complete static strings, so a `before:bg-${color}` template never emits.
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
// Amber steady — a clarify/approval is blocking the turn. Steady (not
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
'needs-input': {
ariaLabel: r => r.needsInput,
className: `${DOT_BASE} quest-glow bg-amber-500`,
role: 'status',
title: r => r.waitingForAnswer
},
// Accent pulse — the LLM turn is actively running.
working: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
role: 'status'
},
// Quiet accent pulse — the turn is still authoritative-running, but no
// stream activity has arrived for the watchdog window.
stalled: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
role: 'status',
title: r => r.sessionRunning
},
// Pulsing gray — a terminal(background=true) process is alive while the LLM
// is idle. Gray (not accent) reads as "something chugging along". Brighter
// than muted-foreground so it's visible against the surface.
background: {
ariaLabel: r => r.backgroundRunning,
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
role: 'status',
title: r => r.backgroundRunning
},
// Steady green — a background session's turn completed and the user hasn't
// opened it since. "Something new here, go look."
unread: {
ariaLabel: r => r.finishedUnread,
className: `${DOT_BASE} bg-emerald-500`,
role: 'status',
title: r => r.finishedUnread
},
idle: {
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
}
}
export interface SessionStatusDotProps {
/** The STORED session id — the key every live-state atom (working /
* attention / stalled / unread / background) is keyed by, on BOTH surfaces:
* the sidebar row's `session.id` and a pane tile's `storedSessionId` are the
* same stored id (`$workingSessionIds` et al. map `storedSessionId`). */
storedSessionId: string
/** The session row for color resolution recents OR the project tree. Both
* call sites already hold it; passing it lets the idle dot inherit the
* project color even for a session older than the paginated recents page
* (which has no `$sessionColorById` entry). */
session?: null | SessionInfo
/** TUI-style tree stem for a branched session (`└─ ` / `├─ `). */
branchStem?: string
/** Applied to the OUTER wrapper (stem + dot) e.g. hover-fade on the
* reorder handle. */
className?: string
}
/**
* SESSION STATUS DOT the ONE primitive both the sidebar row and the pane tab
* render, so a session's status/color can never disagree between the two
* surfaces. It reads every signal itself from the shared stores keyed by the
* stored session id: live state (working / needs-input / stalled / unread /
* background, mutually exclusive via `sessionDotState`) and the resolved color
* (override project color, via `sessionColorFor`). An idle session shows its
* project color; the active states own the dot with their semantic color so an
* attention cue is never masked by the inherited tint.
*/
export function SessionStatusDot({ storedSessionId, session, branchStem, className }: SessionStatusDotProps) {
const { t } = useI18n()
const r = t.sidebar.row
// Subscribe to the shared color map for reactivity; sessionColorFor falls
// back to the resolver for a session outside the recents page.
useStore($sessionColorById)
const color = sessionColorFor(session) ?? null
const needsInput = useStore($attentionSessionIds).includes(storedSessionId)
const isWorking = useStore($workingSessionIds).includes(storedSessionId)
const isStalled = useStore($stalledSessionIds).includes(storedSessionId)
const isUnread = useStore($unreadFinishedSessionIds).includes(storedSessionId)
const hasBackground = useStore($backgroundRunningSessionIds).includes(storedSessionId)
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
return (
<span className={cn('flex items-center gap-0.5', className)}>
{branchStem ? (
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{branchStem}
</span>
) : null}
{dotState === 'idle' && color ? (
<span aria-hidden="true" className="size-1 rounded-full" style={{ backgroundColor: color }} />
) : (
<span
aria-label={DOT_VARIANTS[dotState].ariaLabel?.(r)}
className={DOT_VARIANTS[dotState].className}
role={DOT_VARIANTS[dotState].role}
title={DOT_VARIANTS[dotState].title?.(r)}
/>
)}
</span>
)
}

View file

@ -22,6 +22,7 @@ import { useEffect, useMemo, useRef } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
import { useModelControls } from '@/app/session/hooks/use-model-controls'
import { blobToDataUrl } from '@/app/session/hooks/use-prompt-actions/utils'
import { resolveStoredSession } from '@/app/session/hooks/use-session-actions/utils'
import { ModelMenuPanel } from '@/app/shell/model-menu-panel'
import { formatRefValue } from '@/components/assistant-ui/directive-text'
import { CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
@ -36,6 +37,7 @@ import { sessionTitle } from '@/lib/chat-runtime'
import { createComposerAttachmentScope } from '@/store/composer'
import { $pinnedSessionIds, pinSession, unpinSession } from '@/store/layout'
import { $activeGatewayProfile } from '@/store/profile'
import { $projectTree } from '@/store/projects'
import { sessionAwaitingInput } from '@/store/prompts'
import {
$gatewayState,
@ -44,7 +46,6 @@ import {
sessionMatchesStoredId,
sessionPinId
} from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import {
$sessionStates,
$sessionTiles,
@ -54,12 +55,14 @@ import {
type SessionTile,
sessionTileDelegate
} from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import type { SessionDragPayload } from './composer/inline-refs'
import { type ComposerScope, ComposerScopeProvider } from './composer/scope'
import { useComposerActions } from './hooks/use-composer-actions'
import { paneMirror } from './pane-mirror'
import { startSessionDrag } from './session-drag'
import { SessionStatusDot } from './session-status-dot'
import { useSessionTileActions } from './session-tile-actions'
import { type SessionView, SessionViewProvider } from './session-view'
import { SessionContextMenu } from './sidebar/session-actions-menu'
@ -199,6 +202,53 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
const resumingRef = useRef(false)
const view = useMemo(() => buildTileView(storedSessionId), [storedSessionId])
// A tab-strip "+"/⌘T tab is created UNLISTED — its session stays out of
// $sessions (no sidebar clutter) until it's actually used, so the tab shows
// "New session". The moment this tile has a message, pull its row into
// $sessions via the lightweight by-id lookup so the tab (and a sidebar row)
// resolve the real title. `resolveStoredSession` no-ops when it's already
// listed, and 404s harmlessly for an in-memory draft that hasn't persisted a
// turn yet — so we retry across that brief persist lag and stop as soon as it
// lands (a global turn-complete refresh may beat us to it).
const hasMessages = useStore(view.$messagesEmpty) === false
useEffect(() => {
const alreadyListed = () => $sessions.get().some(s => sessionMatchesStoredId(s, storedSessionId))
if (!runtimeId || !hasMessages || alreadyListed()) {
return
}
let cancelled = false
let timer: number | undefined
const attempt = (remaining: number) => {
if (cancelled || alreadyListed()) {
return
}
void resolveStoredSession(storedSessionId)
.then(resolved => {
if (cancelled || resolved || remaining <= 0) {
return
}
timer = window.setTimeout(() => attempt(remaining - 1), 500)
})
.catch(() => undefined)
}
attempt(6)
return () => {
cancelled = true
if (timer !== undefined) {
window.clearTimeout(timer)
}
}
}, [hasMessages, runtimeId, storedSessionId])
// Same gating as the primary's route resume (use-route-resume): never fire
// session.resume before the gateway is OPEN. Persisted tiles mount at boot
// while it's still connecting — an ungated resume rejected there and
@ -278,21 +328,34 @@ export function SessionTilePane({ storedSessionId }: { storedSessionId: string }
// Tile -> pane contribution sync (call once from the app root).
// ---------------------------------------------------------------------------
function tileTitle(storedSessionId: string): string {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
/** Resolve a tile's stored row: the recents list first, then the project
* tree. A session opened as a tab from a project group is often older than
* the paginated recents page, so it has no `$sessions` row at all until new
* activity lands it there resolving through the tree keeps its tab titled
* and tinted instead of a grey "Session" placeholder. */
export function tileStoredRow(storedSessionId: string): SessionInfo | undefined {
const match = (s: SessionInfo) => sessionMatchesStoredId(s, storedSessionId)
return stored ? sessionTitle(stored) : 'Session'
return (
$sessions.get().find(match) ??
$projectTree
.get()
.flatMap(p => [...p.repos.flatMap(r => r.groups.flatMap(g => g.sessions)), ...(p.previewSessions ?? [])])
.find(match)
)
}
/** The tab's lead-dot color the tile's session resolved through the SAME
* shared map the sidebar reads, so a row and its tab always agree. */
function tileAccent(storedSessionId: string): string | undefined {
return sessionColorFor($sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId)))
function tileTitle(storedSessionId: string): string {
const stored = tileStoredRow(storedSessionId)
// A tab-strip "+" tab is unlisted until its first turn persists, so it isn't
// in $sessions yet — label it "New session" rather than a bare "Session".
return stored ? sessionTitle(stored) : 'New session'
}
/** The `@session` link payload for a tile tab drag — id + owning profile + title. */
function tileDragPayload(storedSessionId: string): SessionDragPayload {
const stored = $sessions.get().find(s => sessionMatchesStoredId(s, storedSessionId))
const stored = tileStoredRow(storedSessionId)
return { id: storedSessionId, profile: stored?.profile ?? '', title: tileTitle(storedSessionId) }
}
@ -381,9 +444,12 @@ export function SessionTabMenu({
/** Layout-tree pane id — powers the Close-others/right/all verbs. */
tabPaneId: string
}) {
const sessions = useStore($sessions)
// Subscribe for reactivity; the row is read imperatively via tileStoredRow
// (which spans both sources), so the values themselves are unused here.
useStore($sessions)
useStore($projectTree)
const pinnedSessionIds = useStore($pinnedSessionIds)
const stored = sessions.find(s => sessionMatchesStoredId(s, storedSessionId))
const stored = tileStoredRow(storedSessionId)
const pinId = stored ? sessionPinId(stored) : storedSessionId
const pinned = pinnedSessionIds.includes(pinId)
@ -440,7 +506,10 @@ export function WorkspaceTabMenu({ children }: { children: React.ReactElement })
* `$sessions`). Tiles dock against main on the chosen edge, flex width. */
export const watchSessionTiles = paneMirror<SessionTile>({
source: $sessionTiles,
also: [$sessions, $sessionColorById],
// $projectTree: a tile whose session is older than the recents page resolves
// its title through the tree, which loads after the tiles register. (The tab's
// status dot subscribes to color/state itself, so it needs no `also` entry.)
also: [$sessions, $projectTree],
key: t => t.storedSessionId,
prefix: 'session-tile',
dir: t => t.dir,
@ -448,7 +517,13 @@ export const watchSessionTiles = paneMirror<SessionTile>({
before: t => t.before,
minWidth: '20rem',
title: tileTitle,
accent: tileAccent,
// The tab's status dot — the SAME primitive the sidebar row renders, keyed by
// the stored id, so a session's status/color can never disagree between the
// two surfaces. Self-subscribing (live state + resolved color), so the strip
// needn't re-sync when it changes.
tabLead: storedSessionId => (
<SessionStatusDot session={tileStoredRow(storedSessionId)} storedSessionId={storedSessionId} />
),
render: storedSessionId => <SessionTilePane storedSessionId={storedSessionId} />,
tabWrap: (storedSessionId, tab) => (
<SessionTabMenu

View file

@ -15,7 +15,7 @@ import {
import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { $dismissedWorktreeIds, dismissWorktree } from '@/store/layout'
import { $dismissedWorktreeIds, dismissWorktree, setWorkspaceNodeOpen } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { removeWorktreePath } from '@/store/projects'
@ -257,7 +257,15 @@ function RepoFlatSection({
<WorkspaceHeader
action={
onNewSession && (
<WorkspaceAddButton label={s.newSessionIn(repo.label)} onClick={() => onNewSession(repo.path)} />
<WorkspaceAddButton
label={s.newSessionIn(repo.label)}
onClick={() => {
// Reveal the repo the new session targets if the user had it
// collapsed — the session lands in one of its lanes.
setWorkspaceNodeOpen(repo.id, true)
onNewSession(repo.path)
}}
/>
)
}
count={repoCount}

View file

@ -5,7 +5,7 @@ import type { HermesGitWorktree } from '@/global'
import type { SessionInfo } from '@/hermes'
import { desktopGit } from '@/lib/desktop-git'
import { mapPool } from '@/lib/pool'
import { $sidebarWorkspaceCollapsedIds, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $sidebarWorkspaceNodeOpen, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $worktreeRefreshToken } from '@/store/projects'
import { sessionRecency, type SidebarProjectTree } from './workspace-groups'
@ -123,14 +123,13 @@ export function useRepoWorktreeMap(
// Persisted open/collapse for a repo/worktree node. Lets a project's folder
// layout auto-restore when you enter it, and survive reloads.
//
// The persisted set is an OVERRIDE of `defaultOpen`, not an absolute "collapsed"
// list: XOR lets one store serve both polarities. A default-open node (repo,
// populated lane) lists collapses; a default-collapsed node (an EMPTY lane — no
// sessions yet) instead records an explicit expand. So empty worktree/branch
// lanes start collapsed and only open when the user clicks in.
// State is stored as the RESOLVED boolean per node (see `$sidebarWorkspaceNodeOpen`),
// so a node whose `defaultOpen` flips — an empty worktree/branch lane defaults
// collapsed, then defaults open once it holds a session — keeps whatever the
// user explicitly chose instead of having it silently reinterpreted. An absent
// id follows `defaultOpen`, so empty lanes still start collapsed until opened.
export function useWorkspaceNodeOpen(id: string, defaultOpen = true): [boolean, () => void] {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const overridden = collapsed.includes(id)
const state = useStore($sidebarWorkspaceNodeOpen)
return [defaultOpen ? !overridden : overridden, () => toggleWorkspaceNodeCollapsed(id)]
return [state[id] ?? defaultOpen, () => toggleWorkspaceNodeCollapsed(id, defaultOpen)]
}

View file

@ -4,6 +4,7 @@ import { useState } from 'react'
import { Codicon } from '@/components/ui/codicon'
import type { SessionInfo } from '@/hermes'
import { useI18n } from '@/i18n'
import { setWorkspaceNodeOpen } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { newSessionInProfile } from '@/store/profile'
import { switchBranchInRepo } from '@/store/projects'
@ -68,6 +69,11 @@ export function SidebarWorkspaceGroup({ group, renderRows, onNewSession, onRemov
}
const handleNewSession = async () => {
// Reveal the lane the new session targets — an empty worktree/branch lane
// starts collapsed, so without this the session lands in a folder the user
// can't see. Stable across the lane's default flipping open once populated.
setWorkspaceNodeOpen(group.id, true)
if (isProfileGroup) {
newSessionInProfile(group.id)

View file

@ -505,6 +505,24 @@ describe('liveSessionProjectId', () => {
expect(id).toBe('p_app')
})
it('places a cwd-outside-root session under an explicit project matching either path', () => {
// A mid-session relocation (or a sibling worktree) leaves cwd outside the
// recorded repo root. An explicit folder match is still authoritative —
// only the auto-project (repo root) fallback needs cwd-under-root
// confidence. Match via the repo root...
expect(
liveSessionProjectId(makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' }), [
makeProject('p_proj', ['/home/u/proj'])
])
).toBe('p_proj')
// ...and via the cwd.
expect(
liveSessionProjectId(makeSession('/www/elsewhere/sub', { git_repo_root: '/home/u/proj' }), [
makeProject('p_www', ['/www/elsewhere'])
])
).toBe('p_www')
})
it('matches a mixed-case/separator Windows cwd to its explicit project in the live overlay', () => {
// The bug: a fresh Windows session drops into the overlay before the next
// backend refresh; case-sensitive matching missed its project until then.
@ -553,6 +571,14 @@ describe('sessionProjectColor', () => {
expect(sessionProjectColor(session, [colored('p_app', ['/www/app'], '#4a9eff')])).toBe('#4a9eff')
})
it('colors a cwd-outside-root session when an explicit project folder matches', () => {
// The backend tree groups such a row under the project; the client color
// derivation must agree instead of leaving the row (and its tab) grey.
const session = makeSession('/www/elsewhere', { git_repo_root: '/home/u/proj' })
expect(sessionProjectColor(session, [colored('p_proj', ['/home/u/proj'], '#4a9eff')])).toBe('#4a9eff')
})
it('returns null for a session that only maps to an auto repo root (no explicit project)', () => {
// liveSessionProjectId falls back to the repo root id, which is not a
// project row and therefore carries no color.

View file

@ -357,7 +357,11 @@ function isPathUnder(folder: string, target: string): boolean {
* the overview at once instead of waiting for the next backend refresh. Returns
* null only for sessions we genuinely can't place from the row alone: cwd-less,
* kanban-task worktrees (they fold into the kanban bucket), or a worktree that
* lives OUTSIDE the repo root (a sibling dir whose project can't be derived).
* lives OUTSIDE the repo root (a sibling dir) AND under no explicit project
* folder. An explicit-project folder match always places the row even when
* the row's cwd sits outside its recorded repo root (a mid-session relocation,
* or a sibling worktree of a project repo), the folder match is authoritative;
* only the repo-root AUTO-project fallback needs cwd-under-root confidence.
*/
export function liveSessionProjectId(session: SessionInfo, explicitProjects: ProjectInfo[]): null | string {
const cwd = (session.cwd || '').trim()
@ -372,13 +376,6 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
return null
}
// With a cwd present it must sit under the repo root (a sibling worktree
// outside the root can't be placed from the row alone); a root-only session
// skips this — the root IS the anchor.
if (cwd && !isPathUnder(repoRoot, cwd)) {
return null
}
let projectId = ''
let bestLen = -1
@ -399,7 +396,19 @@ export function liveSessionProjectId(session: SessionInfo, explicitProjects: Pro
}
}
return projectId || repoRoot
if (projectId) {
return projectId
}
// AUTO-project fallback (the repo root itself): with a cwd present it must
// sit under the repo root (a sibling worktree outside the root can't be
// placed from the row alone); a root-only session skips this — the root IS
// the anchor.
if (cwd && !isPathUnder(repoRoot, cwd)) {
return null
}
return repoRoot
}
/**

View file

@ -273,7 +273,10 @@ function useSessionActions({
const workItems: ItemSpec[] = [
spec({
disabled: !onBranch,
icon: 'git-branch',
// Fork glyph to match the inline message action's GitFork icon
// (assistant-message.tsx). NB: this codicon font has no `git-fork`
// glyph (only `git-fork-private`); `repo-forked` is the fork icon.
icon: 'repo-forked',
label: r.branchFrom,
onSelect: () => {
triggerHaptic('selection')

View file

@ -14,15 +14,14 @@ import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { coarseElapsed } from '@/lib/time'
import { cn } from '@/lib/utils'
import { $backgroundRunningSessionIds } from '@/store/composer-status'
import { $unreadFinishedSessionIds } from '@/store/session'
import { $sessionColorById } from '@/store/session-color'
import { $attentionSessionIds, $stalledSessionIds, openSessionTile } from '@/store/session-states'
import { $attentionSessionIds, openSessionTile } from '@/store/session-states'
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
import { SessionStatusDot } from '../session-status-dot'
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
import { type SessionDotState, sessionDotState, sessionShowsRunningArc } from './session-row-state'
import { sessionShowsRunningArc } from './session-row-state'
import { useProfilePrewarm } from './use-profile-prewarm'
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
@ -88,22 +87,6 @@ export function SidebarSessionRow({
const handoffLabel = handoffSource ? (sessionSourceLabel(handoffSource) ?? handoffSource) : null
// True when a clarify prompt in this session is waiting on the user.
const needsInput = useStore($attentionSessionIds).includes(session.id)
// True when the session's most recent turn finished in the background (while
// the user was viewing a different session) and hasn't been opened since.
const isUnread = useStore($unreadFinishedSessionIds).includes(session.id)
// True when the turn is still running but the stream has been quiet long
// enough to soften the animation. This must never look like an idle row.
const isStalled = useStore($stalledSessionIds).includes(session.id)
// True when a terminal(background=true) process is alive in this session.
const hasBackground = useStore($backgroundRunningSessionIds).includes(session.id)
// The session's resolved color (idle dot tint), read from the ONE shared map
// the pane tabs also read — an O(1) lookup, never re-derived per render.
const projectColor = useStore($sessionColorById)[session.id] ?? null
// Resolve the dot's display state once — the four signals are mutually
// exclusive by priority, so threading them as booleans through wrappers just
// to collapse them at the leaf is backwards.
const dotState = sessionDotState({ hasBackground, isStalled, isUnread, isWorking, needsInput })
return (
<SessionContextMenu
@ -237,16 +220,16 @@ export function SidebarSessionRow({
dragHandleProps={dragHandleProps}
leadClassName={needsInput ? 'overflow-visible' : undefined}
>
<SessionRowLeadDot
<SessionStatusDot
branchStem={branchStem}
className="transition-opacity group-hover/handle:opacity-0 group-focus-within/handle:opacity-0"
dotState={dotState}
projectColor={projectColor}
session={session}
storedSessionId={session.id}
/>
</SidebarRowGrab>
) : (
<SidebarRowLead className={needsInput ? 'overflow-visible' : 'overflow-hidden'}>
<SessionRowLeadDot branchStem={branchStem} dotState={dotState} projectColor={projectColor} />
<SessionStatusDot branchStem={branchStem} session={session} storedSessionId={session.id} />
</SidebarRowLead>
)}
{handoffSource && handoffLabel ? (
@ -267,128 +250,3 @@ export function SidebarSessionRow({
</SessionContextMenu>
)
}
function SessionRowLeadDot({
branchStem,
dotState = 'idle',
className,
projectColor
}: {
branchStem?: string
dotState?: SessionDotState
className?: string
projectColor?: null | string
}) {
return (
<span className={cn('flex items-center gap-0.5', className)}>
{branchStem ? (
<span aria-hidden className="shrink-0 font-mono text-[0.625rem] leading-none text-(--ui-text-quaternary)">
{branchStem}
</span>
) : null}
<SidebarRowDot dotState={dotState} projectColor={projectColor} />
</span>
)
}
// A pure lookup table: each state maps to its className, aria-label, and
// title. No priority resolution here — the call site already picked one.
// Label/title are resolved from sidebar.row translations, keyed by name.
type DotVariant = {
ariaLabel?: (r: Translations['sidebar']['row']) => string
className: string
role?: 'status'
title?: (r: Translations['sidebar']['row']) => string
}
// Shared base for every active dot; idle is smaller and uses its own class.
const DOT_BASE = 'relative size-1.5 rounded-full'
// Pseudo-element ping ring that scales outward and fades — shared scaffold for
// the two pulsing dots. The `before:bg-*` color is written inline per variant
// (NOT interpolated here): Tailwind only generates utilities it can see as
// complete static strings, so a `before:bg-${color}` template never emits.
const PING = "before:absolute before:inset-0 before:animate-ping before:rounded-full before:content-['']"
const DOT_VARIANTS: Record<SessionDotState, DotVariant> = {
// Amber steady — a clarify/approval is blocking the turn. Steady (not
// pulsing) reads as "your turn", distinct from the accent pulse of a turn.
'needs-input': {
ariaLabel: r => r.needsInput,
className: `${DOT_BASE} quest-glow bg-amber-500`,
role: 'status',
title: r => r.waitingForAnswer
},
// Accent pulse — the LLM turn is actively running.
working: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) shadow-[0_0_0.625rem_color-mix(in_srgb,var(--ui-accent)_55%,transparent)] ${PING} before:bg-(--ui-accent) before:opacity-70`,
role: 'status'
},
// Quiet accent pulse — the turn is still authoritative-running, but no
// stream activity has arrived for the watchdog window.
stalled: {
ariaLabel: r => r.sessionRunning,
className: `${DOT_BASE} bg-(--ui-accent) opacity-70 ${PING} before:bg-(--ui-accent) before:opacity-40`,
role: 'status',
title: r => r.sessionRunning
},
// Pulsing gray — a terminal(background=true) process is alive while the LLM
// is idle. Gray (not accent) reads as "something chugging along". Brighter
// than muted-foreground so it's visible against the sidebar surface.
background: {
ariaLabel: r => r.backgroundRunning,
className: `${DOT_BASE} bg-muted-foreground/80 ${PING} before:bg-muted-foreground/80 before:opacity-60`,
role: 'status',
title: r => r.backgroundRunning
},
// Steady green — a background session's turn completed and the user hasn't
// opened it since. "Something new here, go look."
unread: {
ariaLabel: r => r.finishedUnread,
className: `${DOT_BASE} bg-emerald-500`,
role: 'status',
title: r => r.finishedUnread
},
idle: {
className: 'size-1 rounded-full bg-(--ui-text-quaternary) opacity-80'
}
}
function SidebarRowDot({
dotState,
className,
projectColor
}: {
dotState: SessionDotState
className?: string
projectColor?: null | string
}) {
const { t } = useI18n()
const r = t.sidebar.row
// An idle session inherits its project's color (a quiet marker matching the
// project row's own color dot). The active states (working / needs-input /
// background / unread) own the dot and keep their semantic color, so the
// inherited tint never competes with an attention cue.
if (dotState === 'idle' && projectColor) {
return (
<span
aria-hidden="true"
className={cn('size-1 rounded-full', className)}
style={{ backgroundColor: projectColor }}
/>
)
}
const variant = DOT_VARIANTS[dotState]
return (
<span
aria-label={variant.ariaLabel?.(r)}
className={cn(variant.className, className)}
role={variant.role}
title={variant.title?.(r)}
/>
)
}

View file

@ -3,6 +3,7 @@ import { computed } from 'nanostores'
import type { CSSProperties, ReactElement, PointerEvent as ReactPointerEvent } from 'react'
import { PREVIEW_RAIL_MAX_WIDTH, PREVIEW_RAIL_MIN_WIDTH } from '@/app/chat/right-rail'
import { SessionStatusDot } from '@/app/chat/session-status-dot'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { type StatusbarItem } from '@/app/shell/statusbar-controls'
import { IdleMount } from '@/components/idle-mount'
@ -53,7 +54,6 @@ import {
import { $filePreviewTarget, $previewTarget, closeRightRail } from '@/store/preview'
import { $reviewOpen, closeReview, REVIEW_PANE_ID } from '@/store/review'
import { $currentCwd, $selectedStoredSessionId, $sessions, sessionMatchesStoredId } from '@/store/session'
import { $sessionColorById, sessionColorFor } from '@/store/session-color'
import type { SessionDragPayload } from '../chat/composer/inline-refs'
import { watchRouteTiles } from '../chat/route-tile'
@ -410,9 +410,10 @@ const syncWorkspaceTitle = () => {
area: 'panes',
title: stored ? storedSessionTitle(stored) : 'New session',
data: {
// The tab's lead dot — same shared map the sidebar row reads, so the
// main tab and its sidebar row always show the same color.
accent: sessionColorFor(stored),
// The tab's status dot — the SAME primitive the sidebar row and session
// tiles render, so the main tab never disagrees with its sidebar row. No
// dot on a fresh draft (no session yet).
tabLead: selected ? () => <SessionStatusDot session={stored} storedSessionId={selected} /> : undefined,
// Pages aren't tab-able: the main zone's bar stands down while one shows.
headerVeto: $workspaceIsPage.get(),
placement: 'main',
@ -427,7 +428,6 @@ const syncWorkspaceTitle = () => {
$selectedStoredSessionId.listen(syncWorkspaceTitle)
$sessions.listen(syncWorkspaceTitle)
$sessionColorById.listen(syncWorkspaceTitle)
$workspaceIsPage.listen(syncWorkspaceTitle)
// Layout reset collapses every session tile into main as a tab (after the

View file

@ -0,0 +1,128 @@
// Dev-only: drive the credit-notice UX without a backend that can hit real
// usage bands. Each trigger emits ONE synthetic `notification.show` /
// `notification.clear` gateway event through the real fan-out
// (`emitLocalGatewayEvent`), so it exercises the actual dispatcher branch in
// `use-message-stream/gateway-event.ts` — toast render, key-replacement
// escalation, TTL self-dismiss, native OS notification, and billing re-poll.
//
// Installed only under `import.meta.env.DEV` (see contrib/wiring.tsx), so none
// of this ships in a production build.
import type { GatewayEvent } from '@hermes/shared'
import { PALETTE_AREA, type PaletteContribution } from '@/app/command-palette/contrib'
import { registry } from '@/contrib/registry'
import { CreditCard } from '@/lib/icons'
import { emitLocalGatewayEvent } from '@/store/gateway'
import { $activeSessionId } from '@/store/session'
interface NoticeStep {
key: string
level: string
kind: 'sticky' | 'ttl'
text: string
ttl_ms?: number
}
// Walks the same lifecycle the Nous credits tracker drives: usage escalates in
// place (50→75→90, one key), then grant-spent, then the depleted/restored pair.
// Wraps around. These are all separate SHOW steps; the stepper auto-clears the
// previous notice when the key changes, so the demo shows one toast at a time
// (real usage CAN stack these, but that's noise when you're eyeballing a single
// transition). The same-key usage steps still demonstrate in-place escalation.
const STEPS: readonly NoticeStep[] = [
{ key: 'credits.usage', kind: 'sticky', level: 'info', text: "• You've used $110.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $165.00 of your $220.00 cap" },
{ key: 'credits.usage', kind: 'sticky', level: 'warn', text: "⚠ You've used $198.00 of your $220.00 cap" },
{ key: 'credits.grant_spent', kind: 'sticky', level: 'info', text: '• Grant spent · $12.00 top-up left' },
{ key: 'credits.depleted', kind: 'sticky', level: 'error', text: '✕ Credit access paused · run /topup to top up' },
{ key: 'credits.restored', kind: 'ttl', level: 'success', text: '✓ Credit access restored', ttl_ms: 8000 }
]
let cursor = 0
let lastShownKey: null | string = null
function clearNotice(key: string): void {
emitLocalGatewayEvent({
payload: { key },
session_id: $activeSessionId.get() ?? '',
type: 'notification.clear'
} as GatewayEvent)
}
function showNotice(step: NoticeStep): void {
emitLocalGatewayEvent({
payload: {
id: `${step.key}:${Date.now()}`,
key: step.key,
kind: step.kind,
level: step.level,
text: step.text,
ttl_ms: step.ttl_ms ?? null
},
session_id: $activeSessionId.get() ?? '',
type: 'notification.show'
} as GatewayEvent)
}
/** Fire the next notice in the scripted sequence, wrapping at the end. */
export function stepCreditsNoticeDemo(): void {
const step = STEPS[cursor % STEPS.length]
// One toast at a time: retire the previous notice when we move to a new key.
// (Same-key steps skip this, so the usage line still escalates in place.)
if (lastShownKey && lastShownKey !== step.key) {
clearNotice(lastShownKey)
}
showNotice(step)
lastShownKey = step.key
cursor += 1
}
// The hotkey: Ctrl+Shift+C on every platform (Ctrl, not Cmd, so it can't clash
// with a system Cmd chord and works the same on Windows/Linux). Matched on
// `code` so it's keyboard-layout independent, and captured before the composer
// so a focused input can't swallow it.
function isTriggerChord(e: KeyboardEvent): boolean {
return e.ctrlKey && e.shiftKey && !e.metaKey && !e.altKey && e.code === 'KeyC'
}
/**
* Install the dev trigger: a capture-phase hotkey (Ctrl+Shift+C), a K palette
* entry, and a `window.__creditsDemo()` console hook. Returns a disposer.
*/
export function installCreditsNoticeDemo(): () => void {
const onKeyDown = (e: KeyboardEvent) => {
if (!isTriggerChord(e)) {
return
}
e.preventDefault()
e.stopPropagation()
stepCreditsNoticeDemo()
}
window.addEventListener('keydown', onKeyDown, { capture: true })
;(window as unknown as { __creditsDemo?: () => void }).__creditsDemo = stepCreditsNoticeDemo
const disposePalette = registry.register({
id: 'dev.creditsNotice',
area: PALETTE_AREA,
data: {
id: 'dev.creditsNotice',
icon: CreditCard,
keywords: ['credits', 'notice', 'toast', 'billing', 'dev', 'demo'],
label: 'Dev: cycle credit notices',
run: stepCreditsNoticeDemo
} satisfies PaletteContribution
})
console.info('[dev] credit-notice demo ready — press Ctrl+Shift+C, or run window.__creditsDemo()')
return () => {
window.removeEventListener('keydown', onKeyDown, { capture: true })
disposePalette()
delete (window as unknown as { __creditsDemo?: () => void }).__creditsDemo
}
}

View file

@ -3,7 +3,15 @@ import { useEffect, useRef } from 'react'
import { closeActiveTab } from '@/app/chat/close-tab'
import { storedSessionIdForNotification } from '@/lib/session-ids'
import { respondToApprovalAction } from '@/store/native-notifications'
import { getRememberedRoute, getRememberedSessionId, setRememberedRoute, setRememberedSessionId } from '@/store/session'
import { $activeGatewayProfile } from '@/store/profile'
import {
$sessions,
getRememberedRoute,
getRememberedSessionId,
rememberedSessionProfile,
setRememberedRoute,
setRememberedSessionId
} from '@/store/session'
import { onSessionsChanged } from '@/store/session-sync'
import { openUpdatesWindow, startUpdatePoller, stopUpdatePoller } from '@/store/updates'
import { isSecondaryWindow } from '@/store/windows'
@ -63,7 +71,10 @@ export function useDesktopIntegrations({
// you don't want to boot into a modal.
useEffect(() => {
if (routedSessionId) {
setRememberedSessionId(routedSessionId)
setRememberedSessionId(
routedSessionId,
rememberedSessionProfile($sessions.get(), routedSessionId, $activeGatewayProfile.get())
)
}
if (!isOverlayView(appViewForPath(locationPathname))) {
@ -92,7 +103,7 @@ export function useDesktopIntegrations({
return
}
const last = getRememberedSessionId()
const last = getRememberedSessionId($activeGatewayProfile.get())
if (last) {
navigate(sessionRoute(last), { replace: true })
@ -100,8 +111,14 @@ export function useDesktopIntegrations({
}, [locationPathname, navigate])
useEffect(() => {
if (resumeExhaustedSessionId && getRememberedSessionId() === resumeExhaustedSessionId) {
setRememberedSessionId(null)
if (!resumeExhaustedSessionId) {
return
}
const owner = rememberedSessionProfile($sessions.get(), resumeExhaustedSessionId, $activeGatewayProfile.get())
if (getRememberedSessionId(owner) === resumeExhaustedSessionId) {
setRememberedSessionId(null, owner)
}
}, [resumeExhaustedSessionId])
@ -155,10 +172,12 @@ export function useDesktopIntegrations({
// OS-standard window close, esp. secondary windows). The Win/Linux keyboard
// path is the `view.closeTab` keybind (use-keybinds), sharing closeActiveTab.
useEffect(() => {
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(() => void closeActiveTab())
const unsubscribe = window.hermesDesktop?.onClosePreviewRequested?.(
() => void closeActiveTab(id => navigate(sessionRoute(id)))
)
return () => unsubscribe?.()
}, [])
}, [navigate])
// Another window mutated the shared session list -> re-pull the sidebar.
useEffect(() => {

View file

@ -0,0 +1,100 @@
import { renderHook } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type * as HermesModule from '@/hermes'
import { setSessions } from '@/store/session'
import { sessionTileDelegate } from '@/store/session-states'
import type { SessionInfo } from '@/types/hermes'
import { useSessionTileDelegate } from './use-session-tile-delegate'
vi.mock('@/hermes', async importActual => ({
...(await importActual<typeof HermesModule>()),
getSessionMessages: vi.fn(async () => ({ messages: [], session_id: '' }))
}))
const { getSessionMessages } = await import('@/hermes')
const row = (over: Partial<SessionInfo>): SessionInfo =>
({
ended_at: null,
id: 'live',
input_tokens: 0,
is_active: false,
last_active: 0,
message_count: 1,
model: null,
output_tokens: 0,
preview: null,
profile: 'default',
source: null,
started_at: 0,
title: null,
...over
}) as SessionInfo
function renderTile(requestGateway: ReturnType<typeof vi.fn>) {
renderHook(() =>
useSessionTileDelegate({
archiveSession: vi.fn(async () => undefined),
branchStoredSession: vi.fn(async () => undefined),
executeSlashCommand: vi.fn(async () => undefined) as never,
removeSession: vi.fn(async () => undefined),
requestGateway: requestGateway as never,
runtimeIdByStoredSessionIdRef: { current: new Map() },
sessionStateByRuntimeIdRef: { current: new Map() },
updateSessionState: vi.fn()
})
)
}
describe('useSessionTileDelegate resumeTile', () => {
beforeEach(() => {
setSessions([])
vi.mocked(getSessionMessages).mockClear()
})
afterEach(() => {
setSessions([])
})
it('carries the owning profile into a cold tile resume so it cannot fork profiles', async () => {
// A tile opens a session owned by another profile. Resuming without the
// profile lets the gateway fall back to the launch-profile DB and clone the
// conversation into the wrong profile (#67603). The owning profile must ride
// both the transcript prefetch and the resume RPC.
setSessions([row({ id: 'stored-x', profile: 'ai-engineer' })])
const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-1' } as never) : ({} as never)
)
renderTile(requestGateway)
const runtimeId = await sessionTileDelegate()!.resumeTile('stored-x')
expect(runtimeId).toBe('runtime-1')
expect(getSessionMessages).toHaveBeenCalledWith('stored-x', 'ai-engineer')
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-x',
cols: 96,
profile: 'ai-engineer'
})
})
it('resolves and carries a default-profile session explicitly', async () => {
setSessions([row({ id: 'stored-y', profile: 'default' })])
const requestGateway = vi.fn(async (method: string) =>
method === 'session.resume' ? ({ session_id: 'runtime-2' } as never) : ({} as never)
)
renderTile(requestGateway)
await sessionTileDelegate()!.resumeTile('stored-y')
expect(requestGateway).toHaveBeenCalledWith('session.resume', {
session_id: 'stored-y',
cols: 96,
profile: 'default'
})
})
})

View file

@ -6,6 +6,7 @@ import { publishSessionState, setSessionTileDelegate } from '@/store/session-sta
import type { SessionResumeResponse } from '@/types/hermes'
import type { usePromptActions } from '../../session/hooks/use-prompt-actions'
import { resolveSessionProfile } from '../../session/hooks/use-session-actions/utils'
import type { useSessionStateCache } from '../../session/hooks/use-session-state-cache'
import type { GatewayRequester } from '../types'
@ -66,9 +67,20 @@ export function useSessionTileDelegate({
return existing
}
// Resolve the owning profile before binding a runtime. A tile can open a
// session from any profile, not just the active one; resuming (or
// reading messages) without a profile lets the gateway fall back to the
// launch-profile DB and fork the conversation into the wrong profile —
// the same cross-profile bleed the recovery resumes had (#67603).
const profile = await resolveSessionProfile(storedSessionId)
const [prefetch, resumed] = await Promise.all([
getSessionMessages(storedSessionId).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', { session_id: storedSessionId, cols: 96 })
getSessionMessages(storedSessionId, profile).catch(() => null),
requestGateway<SessionResumeResponse>('session.resume', {
session_id: storedSessionId,
cols: 96,
...(profile ? { profile } : {})
})
])
const runtimeId = resumed?.session_id

View file

@ -19,6 +19,7 @@ import { DesktopInstallOverlay } from '@/components/desktop-install-overlay'
import { GatewayConnectingOverlay } from '@/components/gateway-connecting-overlay'
import { NotificationStack } from '@/components/notifications'
import { DesktopOnboardingOverlay } from '@/components/onboarding'
import { $newSessionTabAction } from '@/components/pane-shell/tree/store'
import { FloatingPet } from '@/components/pet/floating-pet'
import { RemoteDisplayBanner } from '@/components/remote-display-banner'
import { emitGatewayEvent } from '@/contrib/events'
@ -27,11 +28,12 @@ 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, resolveNewSessionCwd } from '@/store/projects'
import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects'
import {
$activeSessionId,
$connection,
@ -48,8 +50,6 @@ import {
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentModelSource,
setCurrentProvider,
@ -89,6 +89,7 @@ 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,6 +127,10 @@ 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.
@ -133,7 +138,20 @@ 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)
@ -236,10 +254,7 @@ export function ContribWiring({ children }: { children: ReactNode }) {
requestGateway
})
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({
activeSessionIdRef,
refreshProjectBranch
})
const { refreshHermesConfig, sttEnabled, voiceMaxRecordingSeconds } = useHermesConfig({ activeSessionIdRef })
const { refreshCurrentModel, selectModel, updateModelOptionsCache } = useModelControls({
queryClient,
@ -257,6 +272,23 @@ export function ContribWiring({ children }: { children: ReactNode }) {
return () => window.removeEventListener('hermes:open-keybinds', onOpenKeybinds)
}, [navigate])
// Dev-only: install the credit-notice demo trigger (Ctrl+Shift+C / ⌘K palette
// / window.__creditsDemo). Dynamic import inside the DEV guard so the module
// is dropped from production builds.
useEffect(() => {
if (!import.meta.env.DEV) {
return
}
let dispose: (() => void) | undefined
void import('./dev/credits-notice-demo').then(m => {
dispose = m.installCreditsNoticeDemo()
})
return () => dispose?.()
}, [])
// Post-turn rehydrate from stored history (same behavior as DesktopController,
// including finished-todos restoration).
const hydrateFromStoredSession = useCallback(
@ -450,33 +482,16 @@ 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) => {
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)
startWorkspaceSession({
activeSessionIdRef,
followActiveSessionCwd,
onExplicitWorkspace: restoreWorktree,
path,
requestGateway,
startFreshSessionDraft
})
},
[requestGateway, startFreshSessionDraft]
[activeSessionIdRef, requestGateway, startFreshSessionDraft]
)
// Composer "branch off into a new worktree": open a fresh session anchored
@ -712,15 +727,33 @@ export function ContribWiring({ children }: { children: ReactNode }) {
}
}, [])
// The tab-strip "+" and ⌘T share one action: open a new session as its own
// tab (stacked into the workspace zone) WITHOUT polluting the session list.
// Created `listed: false`, so each new tab's in-memory session stays out of
// the sidebar until its first message persists a turn and a refresh surfaces
// it — Cursor-style. Every click opens a fresh "New session" tab (multiple
// empty tabs are fine since none touch the session list).
const openNewSessionTab = useCallback(() => {
void openNewSessionTile('center', { listed: false })
}, [openNewSessionTile])
// Single global listener for every rebindable hotkey plus the on-screen
// keybind editor's capture mode (same as DesktopController).
useKeybinds({
openNewSessionTab: () => void openNewSessionTile('center'),
openNewSessionTab,
startFreshSession: startFreshSessionDraft,
toggleCommandCenter,
toggleSelectedPin
})
// Register the tab-strip "+" action (the generic renderer stays
// session-agnostic; null until wired hides the glyph).
useEffect(() => {
$newSessionTabAction.set(openNewSessionTab)
return () => $newSessionTabAction.set(null)
}, [openNewSessionTab])
// The controller's entire callback surface, gathered into the stable
// `actions` bag. `nextActions` is TS-checked against WiringActions each
// render; its fields are copied into the ref object so `actions` keeps one

View file

@ -0,0 +1,71 @@
import { afterEach, describe, expect, it } from 'vitest'
import {
type GatewaySurvivor,
stashGatewaySurvivor,
survivorIsStale,
takeGatewaySurvivor
} from './gateway-hmr-survivor'
// A minimal stand-in for HermesGateway: the survivor cache only reads
// `connectionState`. Cast through unknown so we don't drag the whole class in.
function fakeGateway(state: 'idle' | 'connecting' | 'open' | 'closed' | 'error') {
return { connectionState: state } as unknown as GatewaySurvivor['gateway']
}
function makeSurvivor(state: Parameters<typeof fakeGateway>[0]): GatewaySurvivor {
return { gateway: fakeGateway(state), profile: 'default', connection: null }
}
afterEach(() => {
// Drain any survivor a test parked but didn't take, so cases don't leak across
// the shared globalThis slot.
takeGatewaySurvivor()
})
describe('gateway HMR survivor cache', () => {
it('returns null when nothing is parked', () => {
expect(takeGatewaySurvivor()).toBeNull()
})
it('round-trips a parked survivor', () => {
const survivor = makeSurvivor('open')
stashGatewaySurvivor(survivor)
expect(takeGatewaySurvivor()).toBe(survivor)
})
it('is single-shot — a second take returns null', () => {
stashGatewaySurvivor(makeSurvivor('open'))
expect(takeGatewaySurvivor()).not.toBeNull()
expect(takeGatewaySurvivor()).toBeNull()
})
it('persists across re-imports via the globalThis slot', async () => {
const survivor = makeSurvivor('open')
stashGatewaySurvivor(survivor)
// A fresh import of the module (simulating an HMR module swap) must resolve
// the same underlying store, not a reset one.
const reimported = await import('./gateway-hmr-survivor')
expect(reimported.takeGatewaySurvivor()).toBe(survivor)
})
it('treats open / connecting sockets as adoptable', () => {
expect(survivorIsStale(makeSurvivor('open'))).toBe(false)
expect(survivorIsStale(makeSurvivor('connecting'))).toBe(false)
})
it('treats closed / error / idle sockets as stale', () => {
expect(survivorIsStale(makeSurvivor('closed'))).toBe(true)
expect(survivorIsStale(makeSurvivor('error'))).toBe(true)
expect(survivorIsStale(makeSurvivor('idle'))).toBe(true)
})
it('returns a stale survivor from take() so the caller can close it', () => {
const dead = makeSurvivor('closed')
stashGatewaySurvivor(dead)
const taken = takeGatewaySurvivor()
expect(taken).toBe(dead)
expect(taken && survivorIsStale(taken)).toBe(true)
})
})

View file

@ -0,0 +1,56 @@
// Keep the live primary gateway socket alive across Vite HMR so editing UI never
// drops the agent session. Stash on dispose, re-adopt on remount. globalThis +
// self-accept so this module's own reload doesn't reset the cache. Prod strips
// import.meta.hot → byte-for-byte unchanged live unmount.
import type { HermesConnection } from '@/global'
import type { HermesGateway } from '@/hermes'
export interface GatewaySurvivor {
gateway: HermesGateway
profile: string
connection: HermesConnection | null
}
// One slot on globalThis, keyed by a process-stable Symbol so repeated imports
// (across hot reloads) resolve the exact same store.
const SURVIVOR_KEY = Symbol.for('hermes.desktop.gatewaySurvivor')
interface SurvivorGlobal {
[SURVIVOR_KEY]?: GatewaySurvivor | null
}
function slot(): SurvivorGlobal {
return globalThis as unknown as SurvivorGlobal
}
/** Park the live gateway so the next module instance can re-adopt it. */
export function stashGatewaySurvivor(survivor: GatewaySurvivor): void {
slot()[SURVIVOR_KEY] = survivor
}
/**
* Take the parked gateway, if any. Single-shot: the slot is cleared on read.
* The caller decides whether to adopt or discard it via `survivorIsStale` a
* socket that died while parked (e.g. backend restart between edits) is still
* returned so the caller can close it and boot fresh.
*/
export function takeGatewaySurvivor(): GatewaySurvivor | null {
const store = slot()
const survivor = store[SURVIVOR_KEY] ?? null
store[SURVIVOR_KEY] = null
return survivor
}
/** A parked survivor whose socket is no longer open — caller should discard it. */
export function survivorIsStale(survivor: GatewaySurvivor): boolean {
const state = survivor.gateway.connectionState
return state !== 'open' && state !== 'connecting'
}
// Self-accept so editing THIS module doesn't blow away the cache it manages.
if (import.meta.hot) {
import.meta.hot.accept()
}

View file

@ -4,6 +4,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $desktopBoot } from '@/store/boot'
import { $gatewayState } from '@/store/session'
import { takeGatewaySurvivor } from './gateway-hmr-survivor'
import { useGatewayBoot } from './use-gateway-boot'
// End-to-end-ish repro of the "remote VPS → stuck on CONNECTING, no Settings"
@ -131,6 +132,15 @@ function Harness({
const originalWebSocket = globalThis.WebSocket
beforeEach(() => {
// Drop any parked gateway left by a prior file/case (globalThis slot).
const leftover = takeGatewaySurvivor()
if (leftover) {
try {
leftover.gateway.close()
} catch {
// ignore
}
}
vi.useFakeTimers()
FakeWebSocket.mode = 'open'
FakeWebSocket.instances = []
@ -152,6 +162,17 @@ beforeEach(() => {
afterEach(() => {
cleanup()
// Vitest keeps import.meta.hot truthy, so the boot effect's cleanup parks an
// open gateway instead of tearing it down (the real HMR path). Drain + close
// that survivor so the next test boots a fresh socket instead of adoptBoot().
const survivor = takeGatewaySurvivor()
if (survivor) {
try {
survivor.gateway.close()
} catch {
// ignore
}
}
vi.useRealTimers()
;(globalThis as { WebSocket: unknown }).WebSocket = originalWebSocket
delete (window as { hermesDesktop?: unknown }).hermesDesktop

View file

@ -40,6 +40,8 @@ import {
import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states'
import type { RpcEvent } from '@/types/hermes'
import { stashGatewaySurvivor, survivorIsStale, takeGatewaySurvivor } from './gateway-hmr-survivor'
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
// raise a recoverable boot error. Otherwise a dropped remote gateway loops the
// backoff forever behind the fullscreen CONNECTING overlay with no way to reach
@ -337,9 +339,28 @@ export function useGatewayBoot({
progress: 6
})
const gateway = new HermesGateway()
// HMR adoption: in a dev hot update, the previous effect instance parked its
// still-open socket instead of closing it (see the cleanup below). Re-adopt
// it so an edit doesn't drop the live agent session. A stale (closed) parked
// socket is discarded and we boot fresh. No-op in production: import.meta.hot
// is undefined there, so this folds to `null` and the whole survivor module
// dead-code-eliminates out of the bundle.
const survivor = import.meta.hot ? takeGatewaySurvivor() : null
const adoptedFromHmr = Boolean(survivor && !survivorIsStale(survivor))
if (survivor && !adoptedFromHmr) {
// Parked socket died between edits (e.g. backend restart) — release it.
try {
survivor.gateway.close()
} catch {
// ignore
}
}
const gateway = adoptedFromHmr ? survivor!.gateway : new HermesGateway()
callbacksRef.current.onGatewayReady(gateway)
setPrimaryGateway(gateway, normalizeProfileKey($activeGatewayProfile.get()))
setPrimaryGateway(gateway, survivor?.profile ?? normalizeProfileKey($activeGatewayProfile.get()))
// Secondary (background-profile) sockets funnel into the same handler.
configureGatewayRegistry({ onEvent: event => callbacksRef.current.handleGatewayEvent(event) })
@ -513,7 +534,42 @@ export function useGatewayBoot({
}
}
void boot()
// Adopt the parked socket without re-running the full boot handshake: the
// socket is already open, the backend session is untouched, and we already
// know the profile. We only re-publish the connection, re-sync config +
// sessions (cheap, and the backend may have moved on between edits), and
// dismiss any boot overlay. This is what keeps a live, mid-stream session
// intact across an HMR update.
async function adoptBoot() {
bootCompleted = true
completeDesktopBoot()
if (survivor?.connection) {
publish(survivor.connection)
}
const profile = survivor?.profile ?? $activeGatewayProfile.get()
$activeGatewayProfile.set(profile)
void ensureGatewayForProfile(profile)
// Mirror the current (already-open) socket state into the composer so the
// input doesn't sit disabled after the swap.
reportPrimaryGatewayState(gateway.connectionState)
await callbacksRef.current.refreshHermesConfig().catch(() => undefined)
if (cancelled) {
return
}
await callbacksRef.current.refreshSessions().catch(() => undefined)
}
if (adoptedFromHmr) {
void adoptBoot()
} else {
void boot()
}
return () => {
cancelled = true
@ -532,6 +588,24 @@ export function useGatewayBoot({
offExit()
offWindowState?.()
offBootProgress()
// HMR teardown vs. real unmount. On a hot update we must NOT close the
// socket — that's the whole bug. Detach this instance's listeners (their
// closures capture the disposed module), park the still-open gateway, and
// let the freshly loaded effect re-adopt it. Secondaries are owned by the
// gateway store (HMR-stable module state), so they survive untouched.
// Production: import.meta.hot is undefined, so this branch never runs and
// the original destructive teardown below is byte-for-byte preserved.
if (import.meta.hot && gateway.connectionState === 'open') {
stashGatewaySurvivor({
gateway,
profile: survivor?.profile ?? $activeGatewayProfile.get(),
connection: $connection.get()
})
return
}
closeSecondaryGateways()
gateway.close()
publish(null)

View file

@ -181,10 +181,12 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'view.closeTerminal': () => $terminalTakeover.get() && closeActiveTerminal(),
'view.flipPanes': togglePanesFlipped,
// ⌘W: close the focused tab (terminal / preview target / zone tree tab).
// On macOS the menu accelerator owns ⌘W and routes through the same
// On the main tab with session tabs stacked, it shifts the next one in —
// the loader navigates to that session's route (loads it into main). On
// macOS the menu accelerator owns ⌘W and routes through the same
// closeActiveTab via IPC (see use-desktop-integrations); this binding is
// the Win/Linux path where ⌘W reaches the renderer directly.
'view.closeTab': () => void closeActiveTab(),
'view.closeTab': () => void closeActiveTab(id => navigate(sessionRoute(id))),
'view.reopenTab': reopenLastClosedTile,
'appearance.toggleMode': () => setMode(resolvedMode === 'dark' ? 'light' : 'dark'),

View file

@ -66,6 +66,13 @@ export function OverlayView({
'p-[calc(var(--titlebar-height)+0.625rem)]',
'sm:p-[calc(var(--titlebar-height)+0.875rem)]'
)}
// Every OverlayView-based overlay (settings, command-center, agents, cron,
// profiles, star map, …) covers the chat while the composer stays mounted
// beneath it. This marker tells `composerFocusBlockedBySurface` to stand
// the global type-to-focus / soft `/` / Enter down, so keystrokes don't
// leak into the hidden composer (and the overlay's own bare-key shortcuts,
// e.g. star map's Space, keep working).
data-overlay-surface=""
onClick={event => {
if (event.target === event.currentTarget) {
closeOverlay()

View file

@ -19,7 +19,7 @@ import { isDesktopFsRemoteMode } from '@/lib/desktop-fs'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import { cn } from '@/lib/utils'
import { $renamingPath, copyFilePath, revealFile, toRelativePath } from '@/store/file-actions'
import { $sidebarWorkspaceCollapsedIds, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { $sidebarWorkspaceNodeOpen, revealFileInTree, toggleWorkspaceNodeCollapsed } from '@/store/layout'
import { notifyError } from '@/store/notifications'
import { setCurrentSessionPreviewTarget } from '@/store/preview'
import {
@ -198,9 +198,9 @@ function ReviewDirRow({
motion: boolean
node: ReviewTreeNode
}) {
const collapsed = useStore($sidebarWorkspaceCollapsedIds)
const nodeOpen = useStore($sidebarWorkspaceNodeOpen)
const id = `review:${node.id}`
const open = !collapsed.includes(id)
const open = nodeOpen[id] ?? true
const toggle = () => toggleWorkspaceNodeCollapsed(id)
return (

View file

@ -0,0 +1,125 @@
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')
})
})

View file

@ -0,0 +1,84 @@
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
}

View file

@ -47,133 +47,37 @@ describe('useHermesConfig refreshHermesConfig', () => {
persistString(WORKSPACE_CWD_KEY, null)
})
it('applies terminal.cwd from config even when localStorage has a stale value', async () => {
// Simulate a stale remembered workspace cwd
persistString(WORKSPACE_CWD_KEY, '/Users/old/stale-project')
setCurrentCwd('/Users/old/stale-project')
it('does not let terminal.cwd replace an inactive selected workspace', async () => {
setCurrentCwd('/Users/example/repo/.worktrees/feature')
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
await act(async () => {
await result.current.refreshHermesConfig()
})
// The configured terminal.cwd must override the stale localStorage value
expect($currentCwd.get()).toBe('/Users/example/new-workspace')
expect($currentCwd.get()).toBe('/Users/example/repo/.worktrees/feature')
})
it('keeps the active session workspace when a session is running', async () => {
setCurrentCwd('/workspace/attached-project')
it('does not let terminal.cwd replace an active session workspace', async () => {
setCurrentCwd('/Users/example/repo/.worktrees/attached')
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: 'session-1' },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: 'session-1' } }))
await act(async () => {
await result.current.refreshHermesConfig()
})
// Config refreshes mid-session must not yank the workspace out from
// under the attached session.
expect($currentCwd.get()).toBe('/workspace/attached-project')
})
it('uses empty string when terminal.cwd is not set and localStorage is empty', async () => {
mockConfig({})
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
await act(async () => {
await result.current.refreshHermesConfig()
})
expect($currentCwd.get()).toBe('')
})
it('ignores terminal.cwd when it is "."', async () => {
mockConfig({ terminal: { cwd: '.' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
await act(async () => {
await result.current.refreshHermesConfig()
})
expect($currentCwd.get()).toBe('')
})
it('calls refreshProjectBranch with the configured cwd', async () => {
const refreshProjectBranch = vi.fn().mockResolvedValue(undefined)
setCurrentCwd('')
mockConfig({ terminal: { cwd: '/workspace/project-a' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch
})
)
await act(async () => {
await result.current.refreshHermesConfig()
})
expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/project-a')
})
it('refreshes the branch for the session cwd (not config) when a session is active', async () => {
const refreshProjectBranch = vi.fn().mockResolvedValue(undefined)
setCurrentCwd('/workspace/attached-project')
mockConfig({ terminal: { cwd: '/Users/example/new-workspace' } })
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: 'session-1' },
refreshProjectBranch
})
)
await act(async () => {
await result.current.refreshHermesConfig()
})
expect(refreshProjectBranch).toHaveBeenCalledWith('/workspace/attached-project')
expect($currentCwd.get()).toBe('/Users/example/repo/.worktrees/attached')
})
it('does not let a stale forced config refresh overwrite newer draft selector intent', async () => {
const profileConfig = deferred<Awaited<ReturnType<typeof getHermesConfig>>>()
vi.mocked(getHermesConfig).mockReturnValueOnce(profileConfig.promise)
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
let pendingRefresh!: Promise<void>
act(() => {
@ -203,12 +107,7 @@ describe('useHermesConfig refreshHermesConfig', () => {
const profileC = deferred<Awaited<ReturnType<typeof getHermesConfig>>>()
vi.mocked(getHermesConfig).mockReturnValueOnce(profileB.promise).mockReturnValueOnce(profileC.promise)
const { result } = renderHook(() =>
useHermesConfig({
activeSessionIdRef: { current: null },
refreshProjectBranch: vi.fn().mockResolvedValue(undefined)
})
)
const { result } = renderHook(() => useHermesConfig({ activeSessionIdRef: { current: null } }))
let refreshB!: Promise<void>
let refreshC!: Promise<void>

Some files were not shown because too many files have changed in this diff Show more