mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
Merge upstream main into feat/hermes-relay-shared-metrics
# Conflicts: # MANIFEST.in # pyproject.toml # tests/test_project_metadata.py
This commit is contained in:
commit
b4e105031a
506 changed files with 33840 additions and 5678 deletions
|
|
@ -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
|
||||
|
|
|
|||
3
.github/actions/detect-changes/action.yml
vendored
3
.github/actions/detect-changes/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
26
.github/actions/get-app-token/action.yml
vendored
26
.github/actions/get-app-token/action.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
100
.github/workflows/ci.yml
vendored
100
.github/workflows/ci.yml
vendored
|
|
@ -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)
|
||||
|
|
|
|||
2
.github/workflows/deploy-site.yml
vendored
2
.github/workflows/deploy-site.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
125
.github/workflows/docker.yml
vendored
125
.github/workflows/docker.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
91
.github/workflows/e2e-desktop.yml
vendored
91
.github/workflows/e2e-desktop.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
3
.github/workflows/js-autofix.yml
vendored
3
.github/workflows/js-autofix.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
5
.github/workflows/osv-scanner.yml
vendored
5
.github/workflows/osv-scanner.yml
vendored
|
|
@ -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
|
||||
|
||||
|
|
|
|||
71
.github/workflows/publish-e2e-evidence.yml
vendored
Normal file
71
.github/workflows/publish-e2e-evidence.yml
vendored
Normal 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"
|
||||
13
.github/workflows/review-labels.yml
vendored
13
.github/workflows/review-labels.yml
vendored
|
|
@ -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'
|
||||
|
|
|
|||
3
.github/workflows/skills-index-freshness.yml
vendored
3
.github/workflows/skills-index-freshness.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
6
.github/workflows/skills-index.yml
vendored
6
.github/workflows/skills-index.yml
vendored
|
|
@ -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:
|
||||
|
|
|
|||
19
.github/workflows/supply-chain-audit.yml
vendored
19
.github/workflows/supply-chain-audit.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
5
.github/workflows/tests.yml
vendored
5
.github/workflows/tests.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
188
.github/workflows/upload_to_pypi.yml
vendored
188
.github/workflows/upload_to_pypi.yml
vendored
|
|
@ -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
5
.gitignore
vendored
|
|
@ -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/
|
||||
|
|
|
|||
17
MANIFEST.in
17
MANIFEST.in
|
|
@ -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]
|
||||
|
|
@ -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.
|
||||
"""
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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 |
|
|
@ -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=(
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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", ""),
|
||||
|
|
|
|||
|
|
@ -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
124
agent/billing_links.py
Normal 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 "")
|
||||
|
|
@ -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"):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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, "
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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') })
|
||||
})
|
||||
})
|
||||
|
|
|
|||
210
apps/desktop/e2e/correction-session-switch.spec.ts
Normal file
210
apps/desktop/e2e/correction-session-switch.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
|
|
@ -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 () => {
|
||||
|
|
|
|||
215
apps/desktop/e2e/interim-messages.spec.ts
Normal file
215
apps/desktop/e2e/interim-messages.spec.ts
Normal 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)
|
||||
}
|
||||
})
|
||||
})
|
||||
235
apps/desktop/e2e/large-session-resume.spec.ts
Normal file
235
apps/desktop/e2e/large-session-resume.spec.ts
Normal 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)
|
||||
})
|
||||
}
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
119
apps/desktop/e2e/queue-turn-boundary.spec.ts
Normal file
119
apps/desktop/e2e/queue-turn-boundary.spec.ts
Normal 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])
|
||||
})
|
||||
})
|
||||
52
apps/desktop/e2e/scripts/seed_large_session.py
Normal file
52
apps/desktop/e2e/scripts/seed_large_session.py
Normal 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()
|
||||
146
apps/desktop/e2e/session-compression-and-queue-stop.spec.ts
Normal file
146
apps/desktop/e2e/session-compression-and-queue-stop.spec.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
252
apps/desktop/e2e/sidebar-states.spec.ts
Normal file
252
apps/desktop/e2e/sidebar-states.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
75
apps/desktop/e2e/submit-drift.spec.ts
Normal file
75
apps/desktop/e2e/submit-drift.spec.ts
Normal 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') })
|
||||
})
|
||||
210
apps/desktop/e2e/tile-unread-bug.spec.ts
Normal file
210
apps/desktop/e2e/tile-unread-bug.spec.ts
Normal 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' })
|
||||
})
|
||||
})
|
||||
484
apps/desktop/e2e/warm-resume-jitter.spec.ts
Normal file
484
apps/desktop/e2e/warm-resume-jitter.spec.ts
Normal 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)
|
||||
})
|
||||
94
apps/desktop/e2e/worktree-branch-status.spec.ts
Normal file
94
apps/desktop/e2e/worktree-branch-status.spec.ts
Normal 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') })
|
||||
})
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
})
|
||||
|
|
|
|||
|
|
@ -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) ---
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => {
|
|||
assert.equal(pair.method, 'S256')
|
||||
// Verifier length within RFC 7636 range (43–128).
|
||||
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
|
||||
|
||||
// Challenge must be the base64url SHA-256 of the verifier.
|
||||
const expected = createHash('sha256')
|
||||
.update(pair.verifier, 'ascii')
|
||||
|
|
@ -41,6 +42,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => {
|
|||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
|
||||
assert.equal(pair.challenge, expected)
|
||||
// No padding / URL-unsafe chars.
|
||||
assert.doesNotMatch(pair.verifier, /[+/=]/)
|
||||
|
|
@ -91,6 +93,7 @@ test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
|
|||
state: 'STATE',
|
||||
provider: 'nous'
|
||||
})
|
||||
|
||||
const parsed = new URL(url)
|
||||
|
||||
assert.equal(parsed.origin, 'https://gw.example.com')
|
||||
|
|
@ -108,6 +111,7 @@ test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix
|
|||
redirectUri: 'http://127.0.0.1:1/cb',
|
||||
state: 'S'
|
||||
})
|
||||
|
||||
const parsed = new URL(url)
|
||||
|
||||
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
|
||||
|
|
@ -128,10 +132,7 @@ test('parseLoopbackCallback returns the code on a state match', () => {
|
|||
})
|
||||
|
||||
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
|
||||
assert.throws(
|
||||
() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'),
|
||||
/state mismatch/i
|
||||
)
|
||||
assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i)
|
||||
})
|
||||
|
||||
test('parseLoopbackCallback surfaces a gateway error param', () => {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
79
apps/desktop/src/app/chat/composer/controls.test.tsx
Normal file
79
apps/desktop/src/app/chat/composer/controls.test.tsx
Normal 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+↵')
|
||||
})
|
||||
})
|
||||
|
|
@ -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" />
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
})
|
||||
})
|
||||
|
|
@ -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 }
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
|
|
|
|||
141
apps/desktop/src/app/chat/session-status-dot.tsx
Normal file
141
apps/desktop/src/app/chat/session-status-dot.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
|
|
@ -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)}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
128
apps/desktop/src/app/contrib/dev/credits-notice-demo.ts
Normal file
128
apps/desktop/src/app/contrib/dev/credits-notice-demo.ts
Normal 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
|
||||
}
|
||||
}
|
||||
|
|
@ -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(() => {
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
})
|
||||
})
|
||||
})
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
})
|
||||
})
|
||||
56
apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts
Normal file
56
apps/desktop/src/app/gateway/hooks/gateway-hmr-survivor.ts
Normal 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()
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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'),
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
125
apps/desktop/src/app/session/hooks/session-context-drift.test.ts
Normal file
125
apps/desktop/src/app/session/hooks/session-context-drift.test.ts
Normal 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')
|
||||
})
|
||||
})
|
||||
84
apps/desktop/src/app/session/hooks/session-context-drift.ts
Normal file
84
apps/desktop/src/app/session/hooks/session-context-drift.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue