Compare commits
3
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7acd6c902c | ||
|
|
0cb42c10a4 | ||
|
|
169bfe20e4 |
@@ -57,26 +57,10 @@ runs:
|
||||
# event payload instead of the "current PR files" endpoint. The SHAs
|
||||
# are frozen at trigger time, so the file list is deterministic even
|
||||
# if the PR receives a new push between trigger and detect.
|
||||
#
|
||||
# Retried: a rate-limit blip or eventual-consistency 404 on a
|
||||
# freshly-pushed HEAD would otherwise silently fall open (all lanes
|
||||
# run — safe, but wasteful and it masks the API failure).
|
||||
CHANGED=""
|
||||
for i in 1 2 3; do
|
||||
if CHANGED="$(gh api \
|
||||
--paginate \
|
||||
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
|
||||
--jq '.files[].filename')"; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" = 3 ]; then
|
||||
echo "::warning::compare API failed after 3 attempts — failing open (all lanes run)"
|
||||
CHANGED=""
|
||||
break
|
||||
fi
|
||||
echo "::warning::compare API failed (attempt $i); retrying in 10s"
|
||||
sleep 10
|
||||
done
|
||||
CHANGED="$(gh api \
|
||||
--paginate \
|
||||
"repos/${REPO}/compare/${BASE_SHA}...${HEAD_SHA}" \
|
||||
--jq '.files[].filename' || true)"
|
||||
fi
|
||||
|
||||
echo "Changed files:"
|
||||
|
||||
@@ -1,80 +0,0 @@
|
||||
name: Fetch PR labels (retried)
|
||||
description: >-
|
||||
Fetch a PR's labels via `gh pr view`, retrying on transient GitHub API
|
||||
failures so a network blip is never misread as "label absent". Distinguishes
|
||||
an API failure (hard error after N attempts) from a genuinely-missing label
|
||||
(clean output that simply doesn't contain the required label), and exposes a
|
||||
ready-made `has-label` output for the required-label gate.
|
||||
|
||||
inputs:
|
||||
pr:
|
||||
description: PR number to read labels from.
|
||||
required: true
|
||||
required-label:
|
||||
description: >-
|
||||
Label to check for. When set, the `has-label` output is 'true'/'false'.
|
||||
When empty, only `labels` is populated.
|
||||
required: false
|
||||
default: ""
|
||||
attempts:
|
||||
description: Max attempts before treating it as a hard API failure.
|
||||
required: false
|
||||
default: "3"
|
||||
delay:
|
||||
description: Seconds to wait between attempts.
|
||||
required: false
|
||||
default: "10"
|
||||
|
||||
outputs:
|
||||
labels:
|
||||
description: Newline-separated list of label names on the PR.
|
||||
value: ${{ steps.fetch.outputs.labels }}
|
||||
has-label:
|
||||
description: >-
|
||||
'true' if `required-label` is present, 'false' otherwise. Empty when
|
||||
no `required-label` was supplied.
|
||||
value: ${{ steps.fetch.outputs.has-label }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
steps:
|
||||
- id: fetch
|
||||
shell: bash
|
||||
env:
|
||||
_PR: ${{ inputs.pr }}
|
||||
_REQUIRED: ${{ inputs.required-label }}
|
||||
_ATTEMPTS: ${{ inputs.attempts }}
|
||||
_DELAY: ${{ inputs.delay }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
# Retry the label fetch: a transient API blip must not read as
|
||||
# "label absent" (which would hard-fail the required-label gate on a
|
||||
# PR that actually carries the label). A hard API failure after all
|
||||
# attempts is a distinct exit-1 (re-runnable); a clean fetch that
|
||||
# simply lacks the label is a normal 'false'.
|
||||
labels=""
|
||||
for i in $(seq 1 "$_ATTEMPTS"); do
|
||||
if labels=$(gh pr view "$_PR" --json labels --jq '.labels[].name'); then
|
||||
break
|
||||
fi
|
||||
if [ "$i" = "$_ATTEMPTS" ]; then
|
||||
echo "::error::Could not fetch PR labels after $_ATTEMPTS attempts (GitHub API failure — re-run this job)."
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::gh pr view failed (attempt $i); retrying in ${_DELAY}s"
|
||||
sleep "$_DELAY"
|
||||
done
|
||||
|
||||
{
|
||||
echo "labels<<__HERMES_LABELS__"
|
||||
echo "$labels"
|
||||
echo "__HERMES_LABELS__"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
|
||||
if [ -n "$_REQUIRED" ]; then
|
||||
if echo "$labels" | grep -Fxq "$_REQUIRED"; then
|
||||
echo "has-label=true" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "has-label=false" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
fi
|
||||
@@ -35,7 +35,6 @@ jobs:
|
||||
detect:
|
||||
name: Detect affected areas
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
python: ${{ steps.classify.outputs.python }}
|
||||
frontend: ${{ steps.classify.outputs.frontend }}
|
||||
@@ -162,7 +161,6 @@ jobs:
|
||||
# - docker
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Evaluate job results
|
||||
env:
|
||||
@@ -193,7 +191,6 @@ jobs:
|
||||
needs: [all-checks-pass, docker]
|
||||
if: always()
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -220,8 +217,6 @@ jobs:
|
||||
--summary-out ci-timings-summary.md
|
||||
|
||||
- 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
|
||||
with:
|
||||
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
jobs:
|
||||
check-attribution:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -28,9 +27,7 @@ jobs:
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# An email is mapped if it has a file in contributors/emails/
|
||||
# (one file per email — conflict-free) or an entry in the frozen
|
||||
# legacy AUTHOR_MAP in scripts/release.py.
|
||||
# Check each email against AUTHOR_MAP in release.py
|
||||
MISSING=""
|
||||
while IFS= read -r email; do
|
||||
# Skip teknium and bot emails
|
||||
@@ -39,12 +36,9 @@ jobs:
|
||||
continue ;;
|
||||
esac
|
||||
|
||||
# Check if email is in AUTHOR_MAP (either as a key or matches noreply pattern)
|
||||
if echo "$email" | grep -qP '\+.*@users\.noreply\.github\.com'; then
|
||||
continue # GitHub id+login noreply emails auto-resolve
|
||||
fi
|
||||
|
||||
if [ -f "contributors/emails/${email}" ]; then
|
||||
continue # mapped via the contributors directory
|
||||
continue # GitHub noreply emails auto-resolve
|
||||
fi
|
||||
|
||||
if ! grep -qF "\"${email}\"" scripts/release.py 2>/dev/null; then
|
||||
@@ -55,19 +49,19 @@ jobs:
|
||||
|
||||
if [ -n "$MISSING" ]; then
|
||||
echo ""
|
||||
echo "⚠️ New contributor email(s) without a mapping:"
|
||||
echo "⚠️ New contributor email(s) not in AUTHOR_MAP:"
|
||||
echo -e "$MISSING"
|
||||
echo ""
|
||||
echo "Add a mapping file (do NOT edit AUTHOR_MAP in release.py):"
|
||||
echo "Please add mappings to scripts/release.py AUTHOR_MAP:"
|
||||
echo -e "$MISSING" | while read -r line; do
|
||||
email=$(echo "$line" | sed 's/^ *//' | cut -d' ' -f1)
|
||||
[ -z "$email" ] && continue
|
||||
echo " python3 scripts/add_contributor.py ${email} <github-username>"
|
||||
echo " \"${email}\": \"<github-username>\","
|
||||
done
|
||||
echo ""
|
||||
echo "To find the GitHub username for an email:"
|
||||
echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'"
|
||||
exit 1
|
||||
else
|
||||
echo "✅ All contributor emails are mapped."
|
||||
echo "✅ All contributor emails are mapped in AUTHOR_MAP."
|
||||
fi
|
||||
|
||||
@@ -41,15 +41,13 @@ jobs:
|
||||
# doesn't auto-deploy via the deploy-docs path.
|
||||
if: github.event_name == 'release' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- name: Trigger Vercel Deploy
|
||||
run: curl -fsS --retry 3 --retry-delay 10 -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
|
||||
run: curl -X POST "${{ secrets.VERCEL_DEPLOY_HOOK }}"
|
||||
|
||||
deploy-docs:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: github-pages
|
||||
url: ${{ steps.deploy.outputs.page_url }}
|
||||
@@ -67,9 +65,7 @@ jobs:
|
||||
python-version: '3.11'
|
||||
|
||||
- name: Install PyYAML for skill extraction
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
env:
|
||||
@@ -154,10 +150,8 @@ jobs:
|
||||
run: python3 website/scripts/generate-skill-docs.py
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
working-directory: website
|
||||
run: npm ci
|
||||
working-directory: website
|
||||
|
||||
- name: Build Docusaurus
|
||||
run: npm run build
|
||||
|
||||
@@ -127,13 +127,12 @@ jobs:
|
||||
run: uv python install 3.11
|
||||
|
||||
- name: Install Python dependencies (for docker tests)
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra dev
|
||||
run: |
|
||||
# ``dev`` extra pulls in pytest, pytest-asyncio —
|
||||
# everything tests/docker/ needs. We deliberately avoid ``all``
|
||||
# here because the docker tests only drive the container via
|
||||
# subprocess and don't import hermes_agent's optional deps.
|
||||
uv sync --locked --python 3.11 --extra dev
|
||||
|
||||
- name: Run docker integration tests
|
||||
env:
|
||||
@@ -189,23 +188,15 @@ jobs:
|
||||
args+=("${IMAGE_NAME}@sha256:${digest_file}")
|
||||
done
|
||||
if [ "${{ github.event_name }}" = "release" ]; then
|
||||
tags=(-t "${IMAGE_NAME}:${RELEASE_TAG}")
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:${RELEASE_TAG}" \
|
||||
"${args[@]}"
|
||||
else
|
||||
tags=(-t "${IMAGE_NAME}:main" -t "${IMAGE_NAME}:latest")
|
||||
docker buildx imagetools create \
|
||||
-t "${IMAGE_NAME}:main" \
|
||||
-t "${IMAGE_NAME}:latest" \
|
||||
"${args[@]}"
|
||||
fi
|
||||
# Retry: Docker Hub API + just-pushed digest eventual consistency
|
||||
# can transiently fail the create; the operation is idempotent.
|
||||
for i in 1 2 3; do
|
||||
if docker buildx imagetools create "${tags[@]}" "${args[@]}"; then
|
||||
break
|
||||
fi
|
||||
if [ "$i" = 3 ]; then
|
||||
echo "::error::imagetools create failed after 3 attempts"
|
||||
exit 1
|
||||
fi
|
||||
echo "::warning::imagetools create failed (attempt $i); retrying in 20s"
|
||||
sleep 20
|
||||
done
|
||||
|
||||
- name: Inspect image
|
||||
env:
|
||||
|
||||
@@ -9,7 +9,6 @@ permissions:
|
||||
jobs:
|
||||
docs-site-checks:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
|
||||
@@ -22,7 +22,6 @@ permissions:
|
||||
jobs:
|
||||
check-common-ancestor:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
|
||||
@@ -8,7 +8,6 @@ jobs:
|
||||
workspaces:
|
||||
name: List npm workspaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
packages: ${{ steps.set-matrix.outputs.packages }}
|
||||
steps:
|
||||
@@ -33,7 +32,6 @@ jobs:
|
||||
name: Typecheck & Test
|
||||
needs: workspaces
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
strategy:
|
||||
matrix:
|
||||
package: ${{ fromJson(needs.workspaces.outputs.packages) }}
|
||||
|
||||
@@ -177,19 +177,15 @@ jobs:
|
||||
steps:
|
||||
- name: Checkout code
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
- name: Fetch ci-reviewed label
|
||||
id: labels
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/gh-pr-labels
|
||||
with:
|
||||
pr: ${{ github.event.pull_request.number }}
|
||||
required-label: ci-reviewed
|
||||
- name: Require ci-reviewed label
|
||||
id: label-check
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ steps.labels.outputs.has-label }}" = "true" ]; then
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then
|
||||
echo "reviewed=true" >> "$GITHUB_OUTPUT"
|
||||
echo "ci-reviewed label present."
|
||||
exit 0
|
||||
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
check-freshness:
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Probe live index
|
||||
id: probe
|
||||
@@ -29,7 +28,7 @@ jobs:
|
||||
URL="https://hermes-agent.nousresearch.com/docs/api/skills-index.json"
|
||||
echo "Probing $URL"
|
||||
# -L follows redirects; -f fails on HTTP errors; -s suppresses progress
|
||||
if ! curl -fsSL --retry 3 --retry-delay 10 -o /tmp/skills-index.json "$URL"; then
|
||||
if ! curl -fsSL -o /tmp/skills-index.json "$URL"; then
|
||||
echo "status=fetch-failed" >> "$GITHUB_OUTPUT"
|
||||
echo "detail=Could not download $URL" >> "$GITHUB_OUTPUT"
|
||||
exit 0
|
||||
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
# Only run on the upstream repository, not on forks
|
||||
if: github.repository == 'NousResearch/hermes-agent'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -29,9 +28,7 @@ jobs:
|
||||
python-version: "3.11"
|
||||
|
||||
- name: Install dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: pip install httpx==0.28.1 pyyaml==6.0.2
|
||||
run: pip install httpx==0.28.1 pyyaml==6.0.2
|
||||
|
||||
- name: Build skills index
|
||||
env:
|
||||
@@ -52,7 +49,6 @@ jobs:
|
||||
needs: build-index
|
||||
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
|
||||
@@ -43,7 +43,6 @@ jobs:
|
||||
name: Scan PR for critical supply chain risks
|
||||
if: inputs.scan
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -165,7 +164,6 @@ jobs:
|
||||
name: Check PyPI dependency upper bounds
|
||||
if: inputs.deps
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
@@ -231,28 +229,20 @@ jobs:
|
||||
name: MCP catalog security review
|
||||
if: inputs.mcp_catalog
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 15
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Fetch mcp-catalog-reviewed label
|
||||
id: labels
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
uses: ./.github/actions/gh-pr-labels
|
||||
with:
|
||||
pr: ${{ github.event.pull_request.number }}
|
||||
required-label: mcp-catalog-reviewed
|
||||
|
||||
- name: Require explicit MCP catalog review label
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
if [ "${{ steps.labels.outputs.has-label }}" = "true" ]; then
|
||||
PR="${{ github.event.pull_request.number }}"
|
||||
LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true)
|
||||
if echo "$LABELS" | grep -Fxq 'mcp-catalog-reviewed'; then
|
||||
echo "MCP catalog review label present."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -20,7 +20,6 @@ jobs:
|
||||
generate:
|
||||
name: "Generate slices"
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
outputs:
|
||||
matrix: ${{ steps.matrix.outputs.matrix }}
|
||||
steps:
|
||||
@@ -32,12 +31,6 @@ jobs:
|
||||
with:
|
||||
path: test_durations.json
|
||||
key: test-durations
|
||||
# Saves use test-durations-${run_id}, so the exact key above never
|
||||
# matches — without this prefix fallback the cache ALWAYS missed,
|
||||
# LPT slicing ran on no data, and unbalanced slices pushed heavy
|
||||
# files toward the per-file timeout under load.
|
||||
restore-keys: |
|
||||
test-durations-
|
||||
|
||||
- name: Generate test slices
|
||||
id: matrix
|
||||
@@ -121,9 +114,6 @@ jobs:
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
- name: Upload per-slice durations
|
||||
# Advisory artifact (feeds slice balancing) — a transient artifact-
|
||||
# service blip must not fail an otherwise-green test slice.
|
||||
continue-on-error: true
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: test-durations-slice-${{ matrix.slice.index }}
|
||||
@@ -136,7 +126,6 @@ jobs:
|
||||
needs: test
|
||||
if: needs.test.result == 'success' && github.ref == 'refs/heads/main'
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 10
|
||||
steps:
|
||||
- name: Download all slice durations
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
|
||||
@@ -26,7 +26,6 @@ jobs:
|
||||
build:
|
||||
name: Build distribution 📦
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
@@ -57,24 +56,10 @@ jobs:
|
||||
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
|
||||
run: cd web && npm ci && npm run build
|
||||
|
||||
- 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
|
||||
run: cd ui-tui && npm ci && npm run build
|
||||
|
||||
- name: Bundle TUI into hermes_cli
|
||||
run: |
|
||||
@@ -105,7 +90,6 @@ jobs:
|
||||
name: Publish to PyPI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/hermes-agent
|
||||
@@ -131,7 +115,6 @@ jobs:
|
||||
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
|
||||
|
||||
@@ -74,20 +74,7 @@ jobs:
|
||||
# rebase and regenerate uv.lock."
|
||||
- name: Verify uv.lock is up-to-date
|
||||
run: |
|
||||
# uv lock --check re-resolves against PyPI (network). Retry so a
|
||||
# registry blip doesn't read as "lockfile stale". A genuinely stale
|
||||
# lockfile fails all attempts (deterministic), costing only seconds.
|
||||
ok=false
|
||||
for i in 1 2 3; do
|
||||
if uv lock --check; then
|
||||
ok=true
|
||||
break
|
||||
fi
|
||||
[ "$i" = 3 ] && break
|
||||
echo "::warning::uv lock --check failed (attempt $i); retrying in 10s"
|
||||
sleep 10
|
||||
done
|
||||
if [ "$ok" != true ]; then
|
||||
if ! uv lock --check; then
|
||||
cat <<'EOF' >> "$GITHUB_STEP_SUMMARY"
|
||||
## ❌ uv.lock is out of sync with pyproject.toml
|
||||
|
||||
|
||||
@@ -1294,14 +1294,6 @@ scripts/run_tests.sh tests/agent/test_foo.py::test_x # one test
|
||||
scripts/run_tests.sh -v --tb=long # pass-through pytest flags
|
||||
```
|
||||
|
||||
**Flake policy:** the runner auto-retries a failing test FILE once in a fresh
|
||||
subprocess (`--file-retries`, default 1; `HERMES_TEST_FILE_RETRIES=0` to
|
||||
disable). Pass-on-retry counts as green but is printed in a `⚠ FLAKY` summary
|
||||
section with both attempts' output. A FLAKY report is a bug to fix, not noise
|
||||
to ignore — timing-sensitive tests must not assume a quiet runner (loose
|
||||
wall-clock bounds ≥ 2s, event-based sync, no `assert not _wait_until(...)`
|
||||
negative-timing races).
|
||||
|
||||
#### Subprocess-per-test-file isolation
|
||||
|
||||
Every test file runs in a freshly-spawned Python subprocess via `run_tests_parallel.py`. This means module-level dicts/sets and
|
||||
|
||||
+18
-23
@@ -26,8 +26,8 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
|
||||
# replaces tini with s6-overlay's /init (PID 1 = s6-svscan), which reaps
|
||||
# zombies non-blockingly on SIGCHLD and additionally supervises the main
|
||||
# hermes process, the dashboard, and per-profile gateways.
|
||||
RUN apt-get -o Acquire::Retries=3 update && \
|
||||
apt-get -o Acquire::Retries=3 install -y --no-install-recommends \
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends \
|
||||
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
@@ -40,30 +40,33 @@ RUN apt-get -o Acquire::Retries=3 update && \
|
||||
# we map between them inline. The noarch + symlinks tarballs are
|
||||
# architecture-independent and reused as-is.
|
||||
#
|
||||
# We use `curl` instead of `ADD` for ALL three tarballs: `ADD` evaluates its
|
||||
# URL at parse time (no ARG / TARGETARCH substitution) and — critically for
|
||||
# CI reliability — cannot retry, so a single GitHub-release CDN blip fails
|
||||
# the whole 15-45 min build. curl -fsSL --retry 3 self-heals those blips,
|
||||
# and every tarball is still checksum-verified below before extraction.
|
||||
# We use `curl` instead of `ADD` for the per-arch tarball because `ADD`
|
||||
# evaluates its URL at parse time, before any ARG / TARGETARCH substitution
|
||||
# — splitting one URL per arch into two ADDs would download both on every
|
||||
# build and leave dead bytes in the cache. A single curl + arch-keyed URL
|
||||
# is simpler and cache-friendlier.
|
||||
#
|
||||
# Supply-chain integrity: every tarball is checksum-verified against the
|
||||
# upstream-published SHA256. To bump S6_OVERLAY_VERSION, fetch the four
|
||||
# `.sha256` files from the corresponding release and update the ARGs. The
|
||||
# checksum lookup happens during build, so a compromised release artifact
|
||||
# fails the build loudly instead of silently producing a tampered image.
|
||||
ARG TARGETARCH
|
||||
ARG S6_OVERLAY_VERSION=3.2.3.0
|
||||
ARG S6_OVERLAY_NOARCH_SHA256=b720f9d9340efc8bb07528b9743813c836e4b02f8693d90241f047998b4c53cf
|
||||
ARG S6_OVERLAY_X86_64_SHA256=a93f02882c6ed46b21e7adb5c0add86154f01236c93cd82c7d682722e8840563
|
||||
ARG S6_OVERLAY_AARCH64_SHA256=0952056ff913482163cc30e35b2e944b507ba1025d78f5becbb89367bf344581
|
||||
ARG S6_OVERLAY_SYMLINKS_SHA256=a60dc5235de3ecbcf874b9c1f18d73263ab99b289b9329aa950e8729c4789f0e
|
||||
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-noarch.tar.xz /tmp/
|
||||
ADD https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-symlinks-noarch.tar.xz /tmp/
|
||||
RUN set -eu; \
|
||||
case "${TARGETARCH:-amd64}" in \
|
||||
amd64) s6_arch="x86_64"; s6_arch_sha="${S6_OVERLAY_X86_64_SHA256}" ;; \
|
||||
arm64) s6_arch="aarch64"; s6_arch_sha="${S6_OVERLAY_AARCH64_SHA256}" ;; \
|
||||
*) echo "Unsupported TARGETARCH=${TARGETARCH} for s6-overlay" >&2; exit 1 ;; \
|
||||
esac; \
|
||||
base="https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}"; \
|
||||
curl -fsSL --retry 3 -o /tmp/s6-overlay-noarch.tar.xz \
|
||||
"${base}/s6-overlay-noarch.tar.xz"; \
|
||||
curl -fsSL --retry 3 -o /tmp/s6-overlay-symlinks-noarch.tar.xz \
|
||||
"${base}/s6-overlay-symlinks-noarch.tar.xz"; \
|
||||
curl -fsSL --retry 3 -o /tmp/s6-overlay-arch.tar.xz \
|
||||
"${base}/s6-overlay-${s6_arch}.tar.xz"; \
|
||||
"https://github.com/just-containers/s6-overlay/releases/download/v${S6_OVERLAY_VERSION}/s6-overlay-${s6_arch}.tar.xz"; \
|
||||
{ \
|
||||
printf '%s %s\n' "${S6_OVERLAY_NOARCH_SHA256}" /tmp/s6-overlay-noarch.tar.xz; \
|
||||
printf '%s %s\n' "${s6_arch_sha}" /tmp/s6-overlay-arch.tar.xz; \
|
||||
@@ -132,16 +135,8 @@ COPY apps/shared/ apps/shared/
|
||||
# guards against a future regression if the source npm version changes.
|
||||
ENV npm_config_install_links=false
|
||||
|
||||
RUN npm install --prefer-offline --no-audit --fetch-retries=5 && \
|
||||
for i in 1 2 3; do \
|
||||
npx playwright install --with-deps chromium --only-shell && break; \
|
||||
if [ "$i" = 3 ]; then \
|
||||
echo "playwright install failed after 3 attempts" >&2; \
|
||||
exit 1; \
|
||||
fi; \
|
||||
echo "playwright install failed (attempt $i); retrying in 10s"; \
|
||||
sleep 10; \
|
||||
done && \
|
||||
RUN npm install --prefer-offline --no-audit && \
|
||||
npx playwright install --with-deps chromium --only-shell && \
|
||||
npm cache clean --force
|
||||
|
||||
# ---------- Layer-cached Python dependency install ----------
|
||||
|
||||
+46
-46
@@ -275,71 +275,71 @@ def _merge_custom_provider_extra_body(agent, custom_providers: List[Dict[str, An
|
||||
|
||||
def init_agent(
|
||||
agent,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
provider: str = None,
|
||||
api_mode: str = None,
|
||||
acp_command: str = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
acp_command: str | None = None,
|
||||
acp_args: list[str] | None = None,
|
||||
command: str = None,
|
||||
command: str | None = None,
|
||||
args: list[str] | None = None,
|
||||
model: str = "",
|
||||
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
|
||||
tool_delay: float = 1.0,
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
enabled_toolsets: List[str] | None = None,
|
||||
disabled_toolsets: List[str] | None = None,
|
||||
save_trajectories: bool = False,
|
||||
verbose_logging: bool = False,
|
||||
quiet_mode: bool = False,
|
||||
tool_progress_mode: str = "all",
|
||||
ephemeral_system_prompt: str = None,
|
||||
ephemeral_system_prompt: str | None = None,
|
||||
log_prefix_chars: int = 100,
|
||||
log_prefix: str = "",
|
||||
providers_allowed: List[str] = None,
|
||||
providers_ignored: List[str] = None,
|
||||
providers_order: List[str] = None,
|
||||
provider_sort: str = None,
|
||||
providers_allowed: List[str] | None = None,
|
||||
providers_ignored: List[str] | None = None,
|
||||
providers_order: List[str] | None = None,
|
||||
provider_sort: str | None = None,
|
||||
provider_require_parameters: bool = False,
|
||||
provider_data_collection: str = None,
|
||||
provider_data_collection: str | None = None,
|
||||
openrouter_min_coding_score: Optional[float] = None,
|
||||
session_id: str = None,
|
||||
tool_progress_callback: callable = None,
|
||||
tool_start_callback: callable = None,
|
||||
tool_complete_callback: callable = None,
|
||||
thinking_callback: callable = None,
|
||||
reasoning_callback: callable = None,
|
||||
clarify_callback: callable = None,
|
||||
read_terminal_callback: callable = None,
|
||||
step_callback: callable = None,
|
||||
stream_delta_callback: callable = None,
|
||||
interim_assistant_callback: callable = None,
|
||||
tool_gen_callback: callable = None,
|
||||
status_callback: callable = None,
|
||||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
session_id: str | None = None,
|
||||
tool_progress_callback: Callable | None = None,
|
||||
tool_start_callback: Callable | None = None,
|
||||
tool_complete_callback: Callable | None = None,
|
||||
thinking_callback: Callable | None = None,
|
||||
reasoning_callback: Callable | None = None,
|
||||
clarify_callback: Callable | None = None,
|
||||
read_terminal_callback: Callable | None = None,
|
||||
step_callback: Callable | None = None,
|
||||
stream_delta_callback: Callable | None = None,
|
||||
interim_assistant_callback: Callable | None = None,
|
||||
tool_gen_callback: Callable | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
notice_callback: Callable | None = None,
|
||||
notice_clear_callback: Callable | None = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
reaction_callback: Optional[Callable[[str], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
request_overrides: Dict[str, Any] = None,
|
||||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
platform: str = None,
|
||||
user_id: str = None,
|
||||
user_id_alt: str = None,
|
||||
user_name: str = None,
|
||||
chat_id: str = None,
|
||||
chat_name: str = None,
|
||||
chat_type: str = None,
|
||||
thread_id: str = None,
|
||||
gateway_session_key: str = None,
|
||||
max_tokens: int | None = None,
|
||||
reasoning_config: Dict[str, Any] | None = None,
|
||||
service_tier: str | None = None,
|
||||
request_overrides: Dict[str, Any] | None = None,
|
||||
prefill_messages: List[Dict[str, Any]] | None = None,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id_alt: str | None = None,
|
||||
user_name: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
chat_name: str | None = None,
|
||||
chat_type: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
gateway_session_key: str | None = None,
|
||||
skip_context_files: bool = False,
|
||||
load_soul_identity: bool = False,
|
||||
skip_memory: bool = False,
|
||||
session_db=None,
|
||||
parent_session_id: str = None,
|
||||
iteration_budget: "IterationBudget" = None,
|
||||
fallback_model: Dict[str, Any] = None,
|
||||
parent_session_id: str | None = None,
|
||||
iteration_budget: Optional["IterationBudget"] = None,
|
||||
fallback_model: Dict[str, Any] | None = None,
|
||||
credential_pool=None,
|
||||
checkpoints_enabled: bool = False,
|
||||
checkpoint_max_snapshots: int = 20,
|
||||
|
||||
@@ -246,7 +246,7 @@ def sanitize_tool_call_arguments(
|
||||
messages: list,
|
||||
*,
|
||||
logger=None,
|
||||
session_id: str = None,
|
||||
session_id: str | None = None,
|
||||
) -> int:
|
||||
"""Repair corrupted assistant tool-call argument JSON in-place."""
|
||||
log = logger or logging.getLogger(__name__)
|
||||
|
||||
@@ -633,8 +633,8 @@ def _common_betas_for_base_url(
|
||||
|
||||
def _build_anthropic_client_with_bearer_hook(
|
||||
token_provider,
|
||||
base_url: str = None,
|
||||
timeout: float = None,
|
||||
base_url: str | None = None,
|
||||
timeout: float | None = None,
|
||||
*,
|
||||
drop_context_1m_beta: bool = False,
|
||||
):
|
||||
@@ -709,8 +709,8 @@ def _build_anthropic_client_with_bearer_hook(
|
||||
|
||||
def build_anthropic_client(
|
||||
api_key,
|
||||
base_url: str = None,
|
||||
timeout: float = None,
|
||||
base_url: str | None = None,
|
||||
timeout: float | None = None,
|
||||
*,
|
||||
drop_context_1m_beta: bool = False,
|
||||
):
|
||||
|
||||
+35
-35
@@ -3972,7 +3972,7 @@ async def _call_fallback_candidate_async(
|
||||
|
||||
def _try_payment_fallback(
|
||||
failed_provider: str,
|
||||
task: str = None,
|
||||
task: str | None = None,
|
||||
reason: str = "payment error",
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Try alternative providers after a payment/credit or connection error.
|
||||
@@ -4023,7 +4023,7 @@ def _try_payment_fallback(
|
||||
|
||||
def _try_main_agent_model_fallback(
|
||||
failed_provider: str,
|
||||
task: str = None,
|
||||
task: str | None = None,
|
||||
reason: str = "error",
|
||||
) -> Tuple[Optional[Any], Optional[str], str]:
|
||||
"""Last-resort fallback to the user's main agent provider + model.
|
||||
@@ -4665,12 +4665,12 @@ def _normalize_resolved_model(model_name: Optional[str], provider: str) -> Optio
|
||||
|
||||
def resolve_provider_client(
|
||||
provider: str,
|
||||
model: str = None,
|
||||
model: str | None = None,
|
||||
async_mode: bool = False,
|
||||
raw_codex: bool = False,
|
||||
explicit_base_url: str = None,
|
||||
explicit_api_key: str = None,
|
||||
api_mode: str = None,
|
||||
explicit_base_url: str | None = None,
|
||||
explicit_api_key: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
@@ -6086,11 +6086,11 @@ def _compat_model(client: Any, model: Optional[str], cached_default: Optional[st
|
||||
|
||||
def _get_cached_client(
|
||||
provider: str,
|
||||
model: str = None,
|
||||
model: str | None = None,
|
||||
async_mode: bool = False,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
api_mode: str = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
is_vision: bool = False,
|
||||
task: Optional[str] = None,
|
||||
@@ -6222,11 +6222,11 @@ _AUX_DIRECT_API_BASE_URLS: Dict[str, str] = {
|
||||
|
||||
|
||||
def _resolve_task_provider_model(
|
||||
task: str = None,
|
||||
provider: str = None,
|
||||
model: str = None,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
task: str | None = None,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
) -> Tuple[str, Optional[str], Optional[str], Optional[str], Optional[str]]:
|
||||
"""Determine provider + model for a call.
|
||||
|
||||
@@ -6900,23 +6900,23 @@ def _obj_get(obj: Any, key: str, default: Any = None) -> Any:
|
||||
|
||||
|
||||
def call_llm(
|
||||
task: str = None,
|
||||
task: str | None = None,
|
||||
*,
|
||||
provider: str = None,
|
||||
model: str = None,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
messages: list,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: int = None,
|
||||
tools: list = None,
|
||||
timeout: float = None,
|
||||
extra_body: dict = None,
|
||||
max_tokens: int | None = None,
|
||||
tools: list | None = None,
|
||||
timeout: float | None = None,
|
||||
extra_body: dict | None = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
api_mode: str = None,
|
||||
api_mode: str | None = None,
|
||||
stream: bool = False,
|
||||
stream_options: dict = None,
|
||||
stream_options: dict | None = None,
|
||||
) -> Any:
|
||||
"""Centralized synchronous LLM call.
|
||||
|
||||
@@ -7567,19 +7567,19 @@ def extract_content_or_reasoning(response) -> str:
|
||||
|
||||
|
||||
async def async_call_llm(
|
||||
task: str = None,
|
||||
task: str | None = None,
|
||||
*,
|
||||
provider: str = None,
|
||||
model: str = None,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
main_runtime: Optional[Dict[str, Any]] = None,
|
||||
messages: list,
|
||||
temperature: Optional[float] = None,
|
||||
max_tokens: int = None,
|
||||
tools: list = None,
|
||||
timeout: float = None,
|
||||
extra_body: dict = None,
|
||||
max_tokens: int | None = None,
|
||||
tools: list | None = None,
|
||||
timeout: float | None = None,
|
||||
extra_body: dict | None = None,
|
||||
reasoning_config: Optional[dict] = None,
|
||||
) -> Any:
|
||||
"""Centralized asynchronous LLM call.
|
||||
|
||||
@@ -36,7 +36,6 @@ from agent.message_sanitization import (
|
||||
_sanitize_surrogates,
|
||||
_repair_tool_call_arguments,
|
||||
)
|
||||
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
|
||||
from tools.terminal_tool import is_persistent_env
|
||||
from utils import base_url_host_matches, base_url_hostname, env_float, env_int
|
||||
|
||||
@@ -2147,7 +2146,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
|
||||
# Claim the delta sink for this bedrock stream (#65991) so a
|
||||
# superseded attempt's callbacks are fenced by the sink guard.
|
||||
claim_stream_writer(agent)
|
||||
agent._claim_stream_writer()
|
||||
|
||||
def _on_text(text):
|
||||
_fire_first()
|
||||
@@ -2351,7 +2350,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
# stream is somehow still alive (a stale-stream reconnect whose socket
|
||||
# abort raced), this claim supersedes it so its late chunks are fenced
|
||||
# out of the turn instead of interleaving with ours.
|
||||
_writer_token = claim_stream_writer(agent)
|
||||
_writer_token = agent._claim_stream_writer()
|
||||
|
||||
# Some OpenAI-compatible adapters (for example copilot-acp, and the MoA
|
||||
# openai-codex aggregator) accept stream=True but still return a
|
||||
@@ -2428,7 +2427,7 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
# (#65991): this attempt has been superseded, so it must neither
|
||||
# fire deltas (incl. the tool-suppressed raw-callback path below)
|
||||
# nor keep consuming a stream that would interleave into the turn.
|
||||
if not stream_writer_is_current(agent, _writer_token):
|
||||
if not agent._stream_writer_is_current(_writer_token):
|
||||
logger.warning(
|
||||
"Streaming attempt superseded by a newer stream; stopping "
|
||||
"consumption to preserve the single-writer invariant "
|
||||
@@ -2760,11 +2759,11 @@ def interruptible_streaming_api_call(agent, api_kwargs: dict, *, on_first_delta=
|
||||
pass
|
||||
# Claim the delta sink for THIS attempt (#65991) — parity with the
|
||||
# chat_completions path so a superseded anthropic stream is fenced.
|
||||
_writer_token = claim_stream_writer(agent)
|
||||
_writer_token = agent._claim_stream_writer()
|
||||
for event in stream:
|
||||
# Bail the instant a newer attempt supersedes this one so a
|
||||
# stale stream can't interleave tokens into the turn.
|
||||
if not stream_writer_is_current(agent, _writer_token):
|
||||
if not agent._stream_writer_is_current(_writer_token):
|
||||
logger.warning(
|
||||
"Anthropic streaming attempt superseded by a newer "
|
||||
"stream; stopping consumption to preserve the "
|
||||
|
||||
+22
-64
@@ -23,8 +23,6 @@ import time
|
||||
from types import SimpleNamespace
|
||||
from typing import Any, Callable, Dict, List
|
||||
|
||||
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@@ -456,27 +454,6 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
|
||||
# even when codex doesn't report durationMs.
|
||||
started: dict[str, tuple[str, dict, float]] = {}
|
||||
|
||||
def _stable_call_id(item: dict, name: str) -> str:
|
||||
"""Deterministic tool_call id mirroring CodexEventProjector, so a
|
||||
live TUI tool card correlates with the same tool call after the
|
||||
session is resumed and history is projected."""
|
||||
from agent.transports.codex_event_projector import _deterministic_call_id
|
||||
|
||||
item_id = item.get("id") or ""
|
||||
item_type = item.get("type") or ""
|
||||
if item_type == "commandExecution":
|
||||
return _deterministic_call_id("exec", item_id)
|
||||
if item_type == "fileChange":
|
||||
return _deterministic_call_id("apply_patch", item_id)
|
||||
if item_type == "mcpToolCall":
|
||||
server = item.get("server") or "mcp"
|
||||
tool = item.get("tool") or "unknown"
|
||||
return _deterministic_call_id(f"mcp__{server}__{tool}", item_id)
|
||||
if item_type == "dynamicToolCall":
|
||||
tool = item.get("tool") or "unknown"
|
||||
return _deterministic_call_id(f"dyn_{tool}", item_id)
|
||||
return _deterministic_call_id(name, item_id)
|
||||
|
||||
def _fire_tool_started(item: dict) -> None:
|
||||
item_id = item.get("id") or ""
|
||||
name = _codex_item_to_tool_name(item)
|
||||
@@ -484,26 +461,15 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
|
||||
if item_id:
|
||||
started[item_id] = (name, args, time.monotonic())
|
||||
cb = getattr(agent, "tool_progress_callback", None)
|
||||
if cb is not None:
|
||||
try:
|
||||
cb("tool.started", name, _codex_item_to_preview(item), args)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_progress_callback raised on tool.started for %s",
|
||||
name, exc_info=True,
|
||||
)
|
||||
# Authoritative stable-ID tool card (TUI / desktop). Fires
|
||||
# alongside tool_progress so surfaces that render structured tool
|
||||
# cards (not just progress bubbles) stay correlated with the
|
||||
# projected history entry after a resume.
|
||||
start_cb = getattr(agent, "tool_start_callback", None)
|
||||
if start_cb is not None:
|
||||
try:
|
||||
start_cb(_stable_call_id(item, name), name, args)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_start_callback raised for %s", name, exc_info=True,
|
||||
)
|
||||
if cb is None:
|
||||
return
|
||||
try:
|
||||
cb("tool.started", name, _codex_item_to_preview(item), args)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_progress_callback raised on tool.started for %s",
|
||||
name, exc_info=True,
|
||||
)
|
||||
|
||||
def _fire_tool_completed(item: dict) -> None:
|
||||
item_id = item.get("id") or ""
|
||||
@@ -521,24 +487,16 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
|
||||
duration = time.monotonic() - prior[2]
|
||||
result, is_error = _codex_item_completion_payload(item)
|
||||
cb = getattr(agent, "tool_progress_callback", None)
|
||||
if cb is not None:
|
||||
try:
|
||||
cb("tool.completed", name, None, None,
|
||||
duration=duration, is_error=is_error, result=result)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_progress_callback raised on tool.completed for %s",
|
||||
name, exc_info=True,
|
||||
)
|
||||
complete_cb = getattr(agent, "tool_complete_callback", None)
|
||||
if complete_cb is not None:
|
||||
args = prior[1] if prior is not None else _codex_item_to_args(item)
|
||||
try:
|
||||
complete_cb(_stable_call_id(item, name), name, args, result)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_complete_callback raised for %s", name, exc_info=True,
|
||||
)
|
||||
if cb is None:
|
||||
return
|
||||
try:
|
||||
cb("tool.completed", name, None, None,
|
||||
duration=duration, is_error=is_error, result=result)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"tool_progress_callback raised on tool.completed for %s",
|
||||
name, exc_info=True,
|
||||
)
|
||||
|
||||
def _fire_text_delta(params: dict) -> None:
|
||||
text = params.get("delta") or params.get("text") or ""
|
||||
@@ -593,7 +551,7 @@ def make_codex_app_server_event_bridge(agent) -> Callable[[dict], None]:
|
||||
if method == "item/agentMessage/delta":
|
||||
_fire_text_delta(params)
|
||||
return
|
||||
if method in {"item/reasoning/delta", "item/reasoning/summaryDelta"}:
|
||||
if method == "item/reasoning/delta":
|
||||
_fire_reasoning_delta(params)
|
||||
return
|
||||
item = params.get("item")
|
||||
@@ -1232,12 +1190,12 @@ def run_codex_stream(agent, api_kwargs: dict, client: Any = None, on_first_delta
|
||||
# late deltas are fenced out of the turn; conversely, a newer
|
||||
# attempt supersedes us and the interrupt_check below stops our
|
||||
# consumption immediately.
|
||||
_writer_token = claim_stream_writer(agent)
|
||||
_writer_token = agent._claim_stream_writer()
|
||||
|
||||
def _interrupt_or_superseded(_tok=_writer_token) -> bool:
|
||||
if agent._interrupt_requested:
|
||||
return True
|
||||
if not stream_writer_is_current(agent, _tok):
|
||||
if not agent._stream_writer_is_current(_tok):
|
||||
logger.warning(
|
||||
"Codex streaming attempt superseded by a newer stream; "
|
||||
"stopping consumption to preserve the single-writer "
|
||||
|
||||
@@ -775,14 +775,6 @@ def classify_api_error(
|
||||
if classified is not None:
|
||||
return classified
|
||||
|
||||
# Local MoA config drift is deterministic: a persisted session can retain
|
||||
# a preset name that was later renamed/deleted. Retrying the same lookup
|
||||
# cannot recover and makes a clear config error look like an API outage.
|
||||
from agent.errors import MoAPresetNotFoundError
|
||||
|
||||
if isinstance(error, MoAPresetNotFoundError):
|
||||
return _result(FailoverReason.model_not_found, retryable=False)
|
||||
|
||||
# ── 3. Error code classification ────────────────────────────────
|
||||
|
||||
if error_code:
|
||||
|
||||
@@ -7,7 +7,3 @@ class EmptyStreamError(RuntimeError):
|
||||
"""Raised when a provider closes a stream without yielding a response."""
|
||||
|
||||
pass
|
||||
|
||||
|
||||
class MoAPresetNotFoundError(ValueError):
|
||||
"""Raised when a persisted MoA preset no longer exists in config."""
|
||||
|
||||
@@ -1,70 +0,0 @@
|
||||
"""Best-effort accessors for the single-writer stream fence (#65991).
|
||||
|
||||
The fence itself lives on ``AIAgent`` (``_claim_stream_writer`` /
|
||||
``_stream_writer_is_current`` in ``run_agent.py``), but the streaming code paths
|
||||
that use it live in *other* modules — ``chat_completion_helpers`` (chat /
|
||||
anthropic / bedrock) and ``codex_runtime`` (codex responses). Calling the fence
|
||||
directly as ``agent._claim_stream_writer()`` from those modules makes them
|
||||
hard-depend on the method being present on whatever object is passed in as
|
||||
``agent``.
|
||||
|
||||
That coupling is a latent crash: a partially-updated checkout (the streaming
|
||||
helper module newer than ``run_agent``), a hot-reloaded gateway, a duck-typed
|
||||
agent, or a test double without the method turns an *additive* safety net into a
|
||||
fatal ``AttributeError`` that aborts the whole turn. A cron job died exactly
|
||||
this way with ``'AIAgent' object has no attribute '_claim_stream_writer'``.
|
||||
|
||||
The fence is only ever allowed to drop a *provably* superseded stream — never
|
||||
the sole legitimate writer. So when the guard is unavailable (or raises), the
|
||||
correct degradation is "no fence": keep streaming. These helpers make the
|
||||
claim/check best-effort to guarantee that.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import Any
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def claim_stream_writer(agent: Any) -> int:
|
||||
"""Claim the delta sink for the calling stream attempt, best-effort.
|
||||
|
||||
Returns the agent's monotonic writer token when the fence is available, or
|
||||
``0`` when the agent doesn't expose it (or the claim raised). A ``0`` token
|
||||
pairs with :func:`stream_writer_is_current` always returning ``True``, so a
|
||||
guard-less agent is simply never fenced instead of crashing the turn.
|
||||
"""
|
||||
claim = getattr(agent, "_claim_stream_writer", None)
|
||||
if callable(claim):
|
||||
try:
|
||||
return int(claim())
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"stream single-writer: claim failed; proceeding unfenced",
|
||||
exc_info=True,
|
||||
)
|
||||
return 0
|
||||
|
||||
|
||||
def stream_writer_is_current(agent: Any, token: int) -> bool:
|
||||
"""True when ``token`` is still the active writer, best-effort.
|
||||
|
||||
A falsy token (from a claim that no-oped) or an agent without the fence
|
||||
means we cannot prove supersession, so the stream is treated as current and
|
||||
never fenced. This preserves the single-writer invariant's one-way promise:
|
||||
only a demonstrably stale writer is ever stopped.
|
||||
"""
|
||||
if not token:
|
||||
return True
|
||||
is_current = getattr(agent, "_stream_writer_is_current", None)
|
||||
if callable(is_current):
|
||||
try:
|
||||
return bool(is_current(token))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"stream single-writer: is_current check failed; treating as current",
|
||||
exc_info=True,
|
||||
)
|
||||
return True
|
||||
@@ -13,20 +13,6 @@ from agent.transports.base import ProviderTransport
|
||||
from agent.transports.types import NormalizedResponse, ToolCall
|
||||
|
||||
|
||||
def _bounded_prompt_cache_key(value: Any) -> Optional[str]:
|
||||
"""Return a provider-safe cache key without changing session identity."""
|
||||
if value is None:
|
||||
return None
|
||||
key = str(value).strip()
|
||||
if not key:
|
||||
return None
|
||||
if len(key) <= 64:
|
||||
return key
|
||||
# Match _content_cache_key's compact, collision-resistant routing-key shape.
|
||||
digest = hashlib.sha256(key.encode("utf-8", errors="replace")).hexdigest()[:24]
|
||||
return f"pck_{digest}"
|
||||
|
||||
|
||||
def _content_cache_key(instructions: str, tools: Optional[List[Dict[str, Any]]]) -> Optional[str]:
|
||||
"""Content-address the prompt cache key from the static request prefix.
|
||||
|
||||
@@ -318,13 +304,6 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
if request_overrides:
|
||||
kwargs.update(request_overrides)
|
||||
|
||||
if "prompt_cache_key" in kwargs:
|
||||
bounded_cache_key = _bounded_prompt_cache_key(kwargs["prompt_cache_key"])
|
||||
if bounded_cache_key:
|
||||
kwargs["prompt_cache_key"] = bounded_cache_key
|
||||
else:
|
||||
kwargs.pop("prompt_cache_key", None)
|
||||
|
||||
# xAI Responses API rejects ``service_tier`` (HTTP 400 "Argument not
|
||||
# supported: service_tier") — hit when ``/fast`` priority-processing
|
||||
# mode lingers from a prior model in the same session, or when a
|
||||
@@ -358,7 +337,7 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
# remain high. Send session_id / x-client-request-id as HTTP
|
||||
# headers while keeping ``prompt_cache_key`` in the body for
|
||||
# standard OpenAI routing as a belt-and-braces fallback.
|
||||
cache_scope_id = _bounded_prompt_cache_key(session_id)
|
||||
cache_scope_id = str(session_id or "").strip()
|
||||
if cache_scope_id:
|
||||
existing_extra_headers = kwargs.get("extra_headers")
|
||||
merged_extra_headers: Dict[str, str] = {}
|
||||
@@ -403,14 +382,6 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
merged_extra_body.setdefault("prompt_cache_key", cache_key)
|
||||
kwargs["extra_body"] = merged_extra_body
|
||||
|
||||
extra_body = kwargs.get("extra_body")
|
||||
if isinstance(extra_body, dict) and "prompt_cache_key" in extra_body:
|
||||
bounded_cache_key = _bounded_prompt_cache_key(extra_body["prompt_cache_key"])
|
||||
if bounded_cache_key:
|
||||
extra_body["prompt_cache_key"] = bounded_cache_key
|
||||
else:
|
||||
extra_body.pop("prompt_cache_key", None)
|
||||
|
||||
return kwargs
|
||||
|
||||
def normalize_response(self, response: Any, **kwargs) -> NormalizedResponse:
|
||||
@@ -499,26 +470,11 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
Normalizes input items, strips unsupported fields, validates structure.
|
||||
"""
|
||||
from agent.codex_responses_adapter import _preflight_codex_api_kwargs
|
||||
|
||||
normalized = _preflight_codex_api_kwargs(
|
||||
return _preflight_codex_api_kwargs(
|
||||
api_kwargs,
|
||||
allow_stream=allow_stream,
|
||||
is_github_responses=is_github_responses,
|
||||
)
|
||||
if "prompt_cache_key" in normalized:
|
||||
bounded = _bounded_prompt_cache_key(normalized["prompt_cache_key"])
|
||||
if bounded:
|
||||
normalized["prompt_cache_key"] = bounded
|
||||
else:
|
||||
normalized.pop("prompt_cache_key", None)
|
||||
extra_body = normalized.get("extra_body")
|
||||
if isinstance(extra_body, dict) and "prompt_cache_key" in extra_body:
|
||||
bounded = _bounded_prompt_cache_key(extra_body["prompt_cache_key"])
|
||||
if bounded:
|
||||
extra_body["prompt_cache_key"] = bounded
|
||||
else:
|
||||
extra_body.pop("prompt_cache_key", None)
|
||||
return normalized
|
||||
|
||||
def map_finish_reason(self, raw_reason: str) -> str:
|
||||
"""Map Codex response.status to OpenAI finish_reason.
|
||||
|
||||
@@ -733,13 +733,6 @@ fn update_child_env(install_root: &Path) -> Vec<(String, OsString)> {
|
||||
"HERMES_HOME".to_string(),
|
||||
hermes_home.as_os_str().to_os_string(),
|
||||
)];
|
||||
// `hermes update` is a Python CLI writing to a pipe here, so CPython
|
||||
// block-buffers its stdout: nothing reaches run_streamed (and the live
|
||||
// log UI) until 8 KB accumulate or the process exits. Long quiet steps —
|
||||
// the pre-update backup can zip multi-GB archives for minutes — render as
|
||||
// a frozen stage, and users cancel a healthy update. Force line-by-line
|
||||
// output instead.
|
||||
envs.push(("PYTHONUNBUFFERED".to_string(), OsString::from("1")));
|
||||
if let Some(path) = path_with_prepended_entries(&[
|
||||
hermes_home.join("node").join("bin"),
|
||||
venv_bin_dir(install_root),
|
||||
@@ -1053,16 +1046,6 @@ mod tests {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn update_child_env_forces_unbuffered_python() {
|
||||
let envs = update_child_env(Path::new("/x/hermes-agent"));
|
||||
assert!(
|
||||
envs.iter()
|
||||
.any(|(k, v)| k == "PYTHONUNBUFFERED" && v.to_str() == Some("1")),
|
||||
"update children must run unbuffered so long steps stream to the live log"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_probe_paths_include_desktop_app_payload() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
@@ -1073,12 +1056,7 @@ mod tests {
|
||||
"venv shim remains part of the update lock probe"
|
||||
);
|
||||
assert!(
|
||||
// Windows/Linux payloads live under `resources/`, the macOS bundle
|
||||
// under `Contents/Resources/` — Path::ends_with is case-sensitive.
|
||||
probes.iter().any(|p| {
|
||||
p.ends_with(Path::new("resources/app.asar"))
|
||||
|| p.ends_with(Path::new("Resources/app.asar"))
|
||||
}),
|
||||
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
|
||||
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -2715,13 +2715,8 @@ async function applyUpdatesPosixInApp(opts: any) {
|
||||
// Put the Hermes-managed Node and the venv on PATH so `hermes desktop`'s
|
||||
// npm build can find them on a machine with no system Node. Windows portable
|
||||
// Node lives directly under %LOCALAPPDATA%\hermes\node, not node\bin.
|
||||
// PYTHONUNBUFFERED: `hermes update` writes to a pipe here, so CPython
|
||||
// block-buffers stdout and long quiet steps (the pre-update backup can zip
|
||||
// multi-GB archives for minutes) stream nothing to the progress UI — users
|
||||
// read the silence as a hang and cancel a healthy update.
|
||||
const env: Record<string, string> = {
|
||||
HERMES_HOME,
|
||||
PYTHONUNBUFFERED: '1',
|
||||
PATH: pathWithHermesManagedNode(path.join(updateRoot, 'venv', 'bin'))
|
||||
}
|
||||
|
||||
|
||||
@@ -1,159 +0,0 @@
|
||||
// Measure a profile switch end-to-end: click a profile square in the rail,
|
||||
// then break the wall time into the phases the renderer can observe:
|
||||
// - getConnection IPC (Electron: pool backend spawn / reuse + readiness)
|
||||
// - gateway WS connect
|
||||
// - swap-target clear ($gatewaySwapTarget → sidebar loader gone)
|
||||
// - sidebar session rows for the new profile painted
|
||||
//
|
||||
// Instruments window.hermesDesktop.getConnection + WebSocket to timestamp the
|
||||
// phases without touching app code.
|
||||
//
|
||||
// Usage:
|
||||
// node apps/desktop/scripts/measure-profile-switch.mjs <profileName> [settleTimeoutMs]
|
||||
|
||||
const CDP_HTTP = 'http://127.0.0.1:9222'
|
||||
const PROFILE = process.argv[2]
|
||||
const SETTLE_TIMEOUT = Number(process.argv[3] || 60000)
|
||||
|
||||
if (!PROFILE) {
|
||||
console.error('usage: measure-profile-switch.mjs <profileName>')
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
class CDP {
|
||||
constructor(ws) { this.ws = ws; this.id = 0; this.pending = new Map() }
|
||||
static async open(url) {
|
||||
const ws = new WebSocket(url)
|
||||
await new Promise((r) => ws.addEventListener('open', r, { once: true }))
|
||||
const cdp = new CDP(ws)
|
||||
ws.addEventListener('message', (ev) => {
|
||||
const m = JSON.parse(ev.data.toString())
|
||||
if (m.id != null && cdp.pending.has(m.id)) {
|
||||
const { resolve, reject } = cdp.pending.get(m.id)
|
||||
cdp.pending.delete(m.id)
|
||||
if (m.error) reject(new Error(m.error.message))
|
||||
else resolve(m.result)
|
||||
}
|
||||
})
|
||||
ws.addEventListener('close', () => {
|
||||
for (const { reject } of cdp.pending.values()) reject(new Error('CDP socket closed'))
|
||||
cdp.pending.clear()
|
||||
})
|
||||
return cdp
|
||||
}
|
||||
send(method, params) {
|
||||
const id = ++this.id
|
||||
return new Promise((res, rej) => {
|
||||
this.pending.set(id, { resolve: res, reject: rej })
|
||||
this.ws.send(JSON.stringify({ id, method, params }))
|
||||
})
|
||||
}
|
||||
async eval(expr) {
|
||||
const r = await this.send('Runtime.evaluate', { expression: expr, returnByValue: true, awaitPromise: true })
|
||||
if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || 'eval failed')
|
||||
return r.result.value
|
||||
}
|
||||
close() { this.ws.close() }
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const list = await (await fetch(`${CDP_HTTP}/json`)).json()
|
||||
const target = list.find((t) => t.type === 'page' && /5174/.test(t.url))
|
||||
if (!target) { console.error('renderer not found on 9222'); process.exit(1) }
|
||||
const cdp = await CDP.open(target.webSocketDebuggerUrl)
|
||||
|
||||
// Instrument getConnection + WebSocket once.
|
||||
await cdp.eval(`(() => {
|
||||
if (window.__PROFILE_SWITCH_OBS__) return 'already'
|
||||
const obs = { events: [] }
|
||||
const mark = (name, extra) => obs.events.push({ name, t: performance.now(), ...(extra || {}) })
|
||||
window.__PROFILE_SWITCH_OBS__ = obs
|
||||
window.__psMark = mark
|
||||
|
||||
const desktop = window.hermesDesktop
|
||||
if (desktop && desktop.getConnection) {
|
||||
const orig = desktop.getConnection.bind(desktop)
|
||||
desktop.getConnection = async (profile) => {
|
||||
mark('getConnection:start', { profile })
|
||||
try {
|
||||
const res = await orig(profile)
|
||||
mark('getConnection:done', { profile })
|
||||
return res
|
||||
} catch (e) {
|
||||
mark('getConnection:error', { profile, error: String(e).slice(0, 120) })
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const OrigWS = window.WebSocket
|
||||
window.WebSocket = function (url, ...rest) {
|
||||
const ws = new OrigWS(url, ...rest)
|
||||
if (String(url).includes('/api/ws')) {
|
||||
mark('ws:new', { url: String(url).replace(/token=[^&]+/, 'token=…').slice(0, 90) })
|
||||
ws.addEventListener('open', () => mark('ws:open'))
|
||||
}
|
||||
return ws
|
||||
}
|
||||
window.WebSocket.prototype = OrigWS.prototype
|
||||
Object.assign(window.WebSocket, OrigWS)
|
||||
return 'installed'
|
||||
})()`)
|
||||
|
||||
const before = await cdp.eval(`(() => {
|
||||
const rail = document.querySelector('[data-slot="profile-rail"]')
|
||||
return {
|
||||
railButtons: rail ? [...rail.querySelectorAll('[role="tab"], button')].map(b => (b.getAttribute('aria-label') || b.title || b.textContent || '').slice(0, 30)) : [],
|
||||
sessions: document.querySelectorAll('[data-slot="sidebar-session-row"], [data-session-id]').length
|
||||
}
|
||||
})()`)
|
||||
console.log('rail buttons:', JSON.stringify(before.railButtons))
|
||||
|
||||
const clicked = await cdp.eval(`(() => {
|
||||
window.__psMark('click', { profile: ${JSON.stringify(PROFILE)} })
|
||||
const rail = document.querySelector('[data-slot="profile-rail"]')
|
||||
if (!rail) return 'no-rail'
|
||||
const target = [...rail.querySelectorAll('button, [role="tab"]')].find(b =>
|
||||
((b.getAttribute('aria-label') || '') + ' ' + (b.title || '') + ' ' + (b.textContent || '')).toLowerCase().includes(${JSON.stringify(PROFILE.toLowerCase())}))
|
||||
if (!target) return 'not-found'
|
||||
target.click()
|
||||
return 'clicked'
|
||||
})()`)
|
||||
console.log('click:', clicked)
|
||||
if (clicked !== 'clicked') { cdp.close(); process.exit(2) }
|
||||
|
||||
// Poll until the swap settles: loader gone + session rows painted (or empty
|
||||
// list settled) + active profile pill shows the target.
|
||||
const t0 = Date.now()
|
||||
let settled = null
|
||||
while (Date.now() - t0 < SETTLE_TIMEOUT) {
|
||||
await new Promise((r) => setTimeout(r, 100))
|
||||
const s = await cdp.eval(`(() => {
|
||||
// The swap overlay stays mounted at opacity-0 after the swap — check the
|
||||
// computed opacity of the container that holds the "Waking up …" label.
|
||||
const label = [...document.querySelectorAll('div[aria-hidden]')].find(el => /waking up/i.test(el.textContent || ''))
|
||||
const overlayVisible = label ? Number(getComputedStyle(label).opacity) > 0.05 : false
|
||||
return {
|
||||
t: performance.now(),
|
||||
overlayVisible,
|
||||
sessions: document.querySelectorAll('[data-slot="row-button"]').length
|
||||
}
|
||||
})()`)
|
||||
if (!s.overlayVisible && s.sessions > 0) { settled = s; break }
|
||||
}
|
||||
|
||||
await new Promise((r) => setTimeout(r, 400))
|
||||
const obs = await cdp.eval('window.__PROFILE_SWITCH_OBS__')
|
||||
const events = obs.events
|
||||
const click = events.find((e) => e.name === 'click' && e.profile === PROFILE)
|
||||
console.log('\n=== PHASES (ms after click) ===')
|
||||
for (const e of events) {
|
||||
if (e.t < click.t - 5) continue
|
||||
console.log(`${(e.t - click.t).toFixed(0).padStart(7)} ${e.name}${e.profile ? ' [' + e.profile + ']' : ''}${e.error ? ' ' + e.error : ''}${e.url ? ' ' + e.url : ''}`)
|
||||
}
|
||||
console.log(settled ? `\nsettled (loader gone + rows painted) at ~${Date.now() - t0} ms wall` : '\nTIMEOUT waiting for settle')
|
||||
|
||||
cdp.close()
|
||||
}
|
||||
|
||||
main().catch((e) => { console.error(e); process.exit(1) })
|
||||
@@ -1,71 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
import type { ChatBarState } from '@/app/chat/composer/types'
|
||||
import { $activeSessionId, $currentModel, setCurrentModel, setCurrentModelSource } from '@/store/session'
|
||||
|
||||
import { ModelPill } from './model-pill'
|
||||
|
||||
const modelState = (over: Partial<ChatBarState['model']> = {}): ChatBarState['model'] => ({
|
||||
canSwitch: true,
|
||||
model: 'gpt-6',
|
||||
provider: 'openai',
|
||||
...over
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
$activeSessionId.set(null)
|
||||
setCurrentModel('')
|
||||
setCurrentModelSource('')
|
||||
})
|
||||
|
||||
// #62055: a manual composer pick is sticky and silently overrides the
|
||||
// Settings → Model default for every NEW chat. The pill must say so.
|
||||
describe('ModelPill pinned-override badge', () => {
|
||||
it('shows the pin dot on a draft running a manual pick', () => {
|
||||
setCurrentModel('deepseek/deepseek-v4-flash')
|
||||
setCurrentModelSource('manual')
|
||||
$activeSessionId.set(null)
|
||||
|
||||
render(<ModelPill disabled={false} model={modelState()} />)
|
||||
|
||||
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('stays quiet when the composer reflects the profile default', () => {
|
||||
setCurrentModel('google/gemma-4-26b-a4b-it:free')
|
||||
setCurrentModelSource('default')
|
||||
$activeSessionId.set(null)
|
||||
|
||||
render(<ModelPill disabled={false} model={modelState()} />)
|
||||
|
||||
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
|
||||
})
|
||||
|
||||
it('stays quiet on a live session (footer shows that session, not the pin)', () => {
|
||||
setCurrentModel('deepseek/deepseek-v4-flash')
|
||||
setCurrentModelSource('manual')
|
||||
$activeSessionId.set('live-1')
|
||||
|
||||
render(<ModelPill disabled={false} model={modelState()} />)
|
||||
|
||||
expect(screen.queryByTestId('model-pinned-dot')).toBeNull()
|
||||
})
|
||||
|
||||
it('is exercised in both render paths', () => {
|
||||
setCurrentModel('deepseek/deepseek-v4-flash')
|
||||
setCurrentModelSource('manual')
|
||||
$activeSessionId.set(null)
|
||||
|
||||
// Fallback (no live menu) path.
|
||||
const { unmount } = render(<ModelPill disabled={false} model={modelState()} />)
|
||||
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
|
||||
unmount()
|
||||
|
||||
// Live-menu (dropdown) path.
|
||||
render(<ModelPill disabled={false} model={modelState({ modelMenuContent: <div /> })} />)
|
||||
expect(screen.getByTestId('model-pinned-dot')).toBeTruthy()
|
||||
expect($currentModel.get()).toBe('deepseek/deepseek-v4-flash')
|
||||
})
|
||||
})
|
||||
@@ -11,10 +11,8 @@ import { ChevronDown } from '@/lib/icons'
|
||||
import { formatModelStatusLabel } from '@/lib/model-status-label'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$currentFastMode,
|
||||
$currentModel,
|
||||
$currentModelSource,
|
||||
$currentProvider,
|
||||
$currentReasoningEffort,
|
||||
setModelPickerOpen
|
||||
@@ -46,17 +44,8 @@ export function ModelPill({
|
||||
const currentProvider = useStore($currentProvider)
|
||||
const fastMode = useStore($currentFastMode)
|
||||
const reasoningEffort = useStore($currentReasoningEffort)
|
||||
const modelSource = useStore($currentModelSource)
|
||||
const activeSessionId = useStore($activeSessionId)
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
// The composer pick is sticky: a manual selection is pinned and every NEW
|
||||
// chat uses it instead of the Settings → Model default — silently, which has
|
||||
// cost users real money on a forgotten paid-model pick (#62055). Surface the
|
||||
// pin whenever a draft (no live session) is running on a manual override. A
|
||||
// live session's footer reflects that session's model, so no badge there.
|
||||
const pinnedOverride = !activeSessionId && modelSource === 'manual' && Boolean(currentModel.trim())
|
||||
|
||||
// The model resolves a beat after the gateway/session comes up. Rather than
|
||||
// flash a literal "No model", show a quiet loader (inherits the pill text
|
||||
// color at half opacity) until a model lands.
|
||||
@@ -69,14 +58,6 @@ export function ModelPill({
|
||||
) : (
|
||||
<GlyphSpinner className="opacity-50" spinner="braille" />
|
||||
)}
|
||||
{pinnedOverride && (
|
||||
<span
|
||||
aria-label={copy.modelPinned}
|
||||
className="size-1 shrink-0 rounded-full bg-(--ui-accent)"
|
||||
data-testid="model-pinned-dot"
|
||||
role="img"
|
||||
/>
|
||||
)}
|
||||
<ChevronDown className="size-2.5 shrink-0 opacity-50" />
|
||||
</>
|
||||
)
|
||||
@@ -90,15 +71,11 @@ export function ModelPill({
|
||||
)
|
||||
: PILL
|
||||
|
||||
const baseTitle = currentProvider
|
||||
? copy.modelTitle(currentProvider, currentModel || copy.modelNone)
|
||||
: copy.switchModel
|
||||
|
||||
const title = pinnedOverride ? `${baseTitle} — ${copy.modelPinned}` : baseTitle
|
||||
const title = currentProvider ? copy.modelTitle(currentProvider, currentModel || copy.modelNone) : copy.switchModel
|
||||
|
||||
if (!model.modelMenuContent) {
|
||||
return (
|
||||
<Tip label={pinnedOverride ? `${copy.openModelPicker} — ${copy.modelPinned}` : copy.openModelPicker} side="top">
|
||||
<Tip label={copy.openModelPicker} side="top">
|
||||
<Button
|
||||
aria-label={copy.openModelPicker}
|
||||
className={pillClass}
|
||||
|
||||
@@ -23,7 +23,7 @@ import { cn } from '@/lib/utils'
|
||||
import { $pinnedSessionIds } from '@/store/layout'
|
||||
import { $petActive } from '@/store/pet'
|
||||
import { $petOverlayActive } from '@/store/pet-overlay'
|
||||
import { $gatewaySwapTarget, $profiles } from '@/store/profile'
|
||||
import { $gatewaySwapTarget } from '@/store/profile'
|
||||
import {
|
||||
$contextSuggestions,
|
||||
$freshDraftReady,
|
||||
@@ -50,7 +50,6 @@ import { useComposerScope } from './composer/scope'
|
||||
import type { ChatBarState } from './composer/types'
|
||||
import { type DroppedFile, partitionDroppedFiles } from './hooks/use-composer-actions'
|
||||
import { type DragKind, useFileDropZone } from './hooks/use-file-drop-zone'
|
||||
import { ProfileTag } from './profile-tag'
|
||||
import { useRuntimeMessageRepository } from './runtime-repository'
|
||||
import { ScrollToBottomButton } from './scroll-to-bottom-button'
|
||||
import { useSessionView } from './session-view'
|
||||
@@ -102,18 +101,12 @@ function ChatHeader({
|
||||
}: ChatHeaderProps) {
|
||||
const sessions = useStore($sessions)
|
||||
const pinnedSessionIds = useStore($pinnedSessionIds)
|
||||
const profiles = useStore($profiles)
|
||||
|
||||
const activeStoredSession =
|
||||
(selectedSessionId && sessions.find(session => sessionMatchesStoredId(session, selectedSessionId))) || null
|
||||
|
||||
const title = activeStoredSession ? sessionTitle(activeStoredSession) : 'New session'
|
||||
|
||||
// Which agent/persona owns this chat — glanceable in the header once a
|
||||
// second profile exists, so the open session's ownership is never ambiguous
|
||||
// (#66003). Single-profile users see the unchanged header.
|
||||
const showProfileTag = profiles.length > 1 && Boolean(activeStoredSession)
|
||||
|
||||
// Pins live on the durable lineage-root id, but selectedSessionId is the live
|
||||
// (tip) id — resolve through the loaded row so the menu reflects the pin
|
||||
// state after auto-compression rotates the id.
|
||||
@@ -133,13 +126,12 @@ function ChatHeader({
|
||||
return (
|
||||
<header className={cn(titlebarHeaderBaseClass, isRoutedSessionView && titlebarHeaderShadowClass)}>
|
||||
<div
|
||||
className={cn(titlebarHeaderTitleClass, showProfileTag && 'flex items-center')}
|
||||
className={titlebarHeaderTitleClass}
|
||||
style={{
|
||||
maxWidth:
|
||||
'calc(100vw - var(--titlebar-content-inset,0px) - var(--titlebar-tools-right) - var(--titlebar-tools-width) - 1.5rem)'
|
||||
}}
|
||||
>
|
||||
{showProfileTag && <ProfileTag className="pointer-events-auto mr-1.5" profile={activeStoredSession?.profile} />}
|
||||
<SessionActionsMenu
|
||||
align="start"
|
||||
onDelete={selectedSessionId ? onDeleteSelectedSession : undefined}
|
||||
|
||||
@@ -1,49 +0,0 @@
|
||||
import { cleanup, render, screen } from '@testing-library/react'
|
||||
import { atom } from 'nanostores'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
// Keep store/profile's side-effecting imports inert (gateway socket layer +
|
||||
// REST client) — same seam as store/profile.test.ts.
|
||||
vi.mock('@/store/gateway', () => ({
|
||||
$gateway: atom<unknown>(null),
|
||||
ensureGatewayForProfile: vi.fn(async () => undefined)
|
||||
}))
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
setApiRequestProfile: vi.fn()
|
||||
}))
|
||||
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
|
||||
vi.mock('@/store/starmap', () => ({ resetStarmapGraph: vi.fn() }))
|
||||
|
||||
const { ProfileTag } = await import('./profile-tag')
|
||||
const { setProfileColor } = await import('@/store/profile')
|
||||
|
||||
afterEach(cleanup)
|
||||
|
||||
describe('ProfileTag', () => {
|
||||
it('shows the profile initial with an accessible owner label', () => {
|
||||
render(<ProfileTag profile="xavier" />)
|
||||
|
||||
const tag = screen.getByRole('img', { name: 'Profile: xavier' })
|
||||
expect(tag.textContent).toBe('x')
|
||||
})
|
||||
|
||||
it('normalizes an empty profile to default and stays neutral', () => {
|
||||
render(<ProfileTag profile="" />)
|
||||
|
||||
const tag = screen.getByRole('img', { name: 'Profile: default' })
|
||||
expect(tag.textContent).toBe('d')
|
||||
// Default/root profile carries no identity color.
|
||||
expect(tag.style.color).toBe('')
|
||||
})
|
||||
|
||||
it('uses the profile identity color (user override wins)', () => {
|
||||
setProfileColor('xavier', 'hsl(120 68% 58%)')
|
||||
|
||||
render(<ProfileTag profile="xavier" />)
|
||||
|
||||
const tag = screen.getByRole('img', { name: 'Profile: xavier' })
|
||||
// jsdom normalizes hsl() to rgb(); assert the override landed, not the format.
|
||||
expect(tag.style.color).toBe('rgb(75, 221, 75)')
|
||||
})
|
||||
})
|
||||
@@ -1,36 +0,0 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { profileColorSoft, resolveProfileColor } from '@/lib/profile-color'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $profileColors, normalizeProfileKey } from '@/store/profile'
|
||||
|
||||
/** Owning-profile chip: soft profile-tint square with the initial, tooltip +
|
||||
* accessible label carrying the full name. Same visual language as the
|
||||
* profile rail; the default profile stays neutral. Identity, not status —
|
||||
* session state dots keep their own semantics (#66003). */
|
||||
export function ProfileTag({ className, profile }: { className?: string; profile: null | string | undefined }) {
|
||||
const { t } = useI18n()
|
||||
const colors = useStore($profileColors)
|
||||
const key = normalizeProfileKey(profile)
|
||||
const color = resolveProfileColor(key, colors)
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
const label = t.sidebar.row.ownedByProfile(key)
|
||||
|
||||
return (
|
||||
<Tip label={label}>
|
||||
<span
|
||||
aria-label={label}
|
||||
className={cn(
|
||||
'grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none',
|
||||
className
|
||||
)}
|
||||
role="img"
|
||||
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
|
||||
>
|
||||
{key.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</span>
|
||||
</Tip>
|
||||
)
|
||||
}
|
||||
@@ -93,10 +93,11 @@ import {
|
||||
$sessions,
|
||||
$sessionsLoading,
|
||||
$sessionsTotal,
|
||||
$workingSessionIds,
|
||||
sessionPinId,
|
||||
setCurrentCwd
|
||||
} from '@/store/session'
|
||||
import { $focusedStoredSessionId, $workingSessionIds, type SplitDir } from '@/store/session-states'
|
||||
import { $focusedStoredSessionId, type SplitDir } from '@/store/session-states'
|
||||
|
||||
import {
|
||||
type AppView,
|
||||
@@ -1231,7 +1232,6 @@ export function ChatSidebar({
|
||||
pinned={false}
|
||||
rootClassName="min-h-32 flex-1 overflow-hidden p-0"
|
||||
sessions={searchResults}
|
||||
showProfileTags={showAllProfiles}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
)}
|
||||
@@ -1254,7 +1254,6 @@ export function ChatSidebar({
|
||||
pinned
|
||||
rootClassName="shrink-0 p-0 pb-1"
|
||||
sessions={pinnedSessions}
|
||||
showProfileTags={showAllProfiles}
|
||||
sortable={pinnedSessions.length > 1}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
|
||||
@@ -66,8 +66,6 @@ import { DeleteProfileDialog } from '../../profiles/delete-profile-dialog'
|
||||
import { RenameProfileDialog } from '../../profiles/rename-profile-dialog'
|
||||
import { PROFILES_ROUTE } from '../../routes'
|
||||
|
||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||
|
||||
const RAIL_GAP = 4 // px — matches gap-1 between squares.
|
||||
|
||||
// Past this many profiles the strip of colored squares stops scaling (tiny
|
||||
@@ -459,40 +457,30 @@ function ProfileDropdown({
|
||||
<SelectValue placeholder={p.title} />
|
||||
</SelectTrigger>
|
||||
<SelectContent collisionPadding={{ bottom: 44, left: 8, right: 8, top: 8 }} side="top">
|
||||
{profiles.map(profile => (
|
||||
<ProfileDropdownItem
|
||||
color={resolveProfileColor(profile.name, colors)}
|
||||
key={profile.name}
|
||||
name={profile.name}
|
||||
/>
|
||||
))}
|
||||
{profiles.map(profile => {
|
||||
const color = resolveProfileColor(profile.name, colors)
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
|
||||
return (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
|
||||
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
|
||||
>
|
||||
{profile.name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</span>
|
||||
<span className="truncate">{profile.name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
})}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)
|
||||
}
|
||||
|
||||
// One dropdown row per profile — its own component so each row can own a
|
||||
// hover-intent prewarm timer (see useProfilePrewarm).
|
||||
function ProfileDropdownItem({ color, name }: { color: null | string; name: string }) {
|
||||
const hue = color ?? 'var(--ui-text-quaternary)'
|
||||
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(name)
|
||||
|
||||
return (
|
||||
<SelectItem onPointerEnter={startPrewarm} onPointerLeave={cancelPrewarm} value={name}>
|
||||
<span className="flex min-w-0 items-center gap-1.5">
|
||||
<span
|
||||
aria-hidden="true"
|
||||
className="grid size-4 shrink-0 place-items-center rounded-[3px] text-[0.5rem] font-semibold uppercase leading-none"
|
||||
style={{ backgroundColor: profileColorSoft(hue, 22), color: color ?? undefined }}
|
||||
>
|
||||
{name.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
</span>
|
||||
<span className="truncate">{name}</span>
|
||||
</span>
|
||||
</SelectItem>
|
||||
)
|
||||
}
|
||||
|
||||
interface ProfilePillProps {
|
||||
active: boolean
|
||||
// home / All / Manage are glyph action buttons (navigation, not identity).
|
||||
@@ -560,9 +548,6 @@ function ProfileSquare({
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const pressTimer = useRef<null | number>(null)
|
||||
const suppressClick = useRef(false)
|
||||
// Hovering a square telegraphs the switch — start that profile's backend
|
||||
// spawn now so a cold click doesn't pay the full boot.
|
||||
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(label)
|
||||
|
||||
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({
|
||||
id: label,
|
||||
@@ -652,11 +637,7 @@ function ProfileSquare({
|
||||
setPickerOpen(true)
|
||||
}, LONG_PRESS_MS)
|
||||
}}
|
||||
onPointerEnter={startPrewarm}
|
||||
onPointerLeave={() => {
|
||||
clearPress()
|
||||
cancelPrewarm()
|
||||
}}
|
||||
onPointerLeave={clearPress}
|
||||
onPointerUp={clearPress}
|
||||
>
|
||||
{label.replace(/[^a-z0-9]/gi, '').charAt(0) || '?'}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useStore } from '@nanostores/react'
|
||||
import type * as React from 'react'
|
||||
|
||||
import { ProfileTag } from '@/app/chat/profile-tag'
|
||||
import { startSessionDrag } from '@/app/chat/session-drag'
|
||||
import { PlatformAvatar } from '@/app/messaging/platform-icon'
|
||||
import { Button } from '@/components/ui/button'
|
||||
@@ -15,13 +14,12 @@ 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 { $attentionSessionIds, openSessionTile } from '@/store/session-states'
|
||||
import { $attentionSessionIds, $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { openSessionTile } from '@/store/session-states'
|
||||
import { canOpenSessionWindow, openSessionInNewWindow } from '@/store/windows'
|
||||
|
||||
import { SidebarRowBody, SidebarRowGrab, SidebarRowLabel, SidebarRowLead, SidebarRowShell } from './chrome'
|
||||
import { SessionActionsMenu, SessionContextMenu } from './session-actions-menu'
|
||||
import { useProfilePrewarm } from './use-profile-prewarm'
|
||||
|
||||
interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
session: SessionInfo
|
||||
@@ -38,10 +36,6 @@ interface SidebarSessionRowProps extends React.ComponentProps<'div'> {
|
||||
reorderable?: boolean
|
||||
dragging?: boolean
|
||||
dragHandleProps?: React.HTMLAttributes<HTMLElement>
|
||||
/** Tag the row with its owning profile (initial chip + tooltip). Used by
|
||||
* flat cross-profile lists — Pinned and search results in the All-profiles
|
||||
* view — where no group header communicates ownership (#66003). */
|
||||
showProfile?: boolean
|
||||
}
|
||||
|
||||
const AGE_KEY = { day: 'ageDay', hour: 'ageHour', minute: 'ageMin' } as const
|
||||
@@ -67,7 +61,6 @@ export function SidebarSessionRow({
|
||||
reorderable = false,
|
||||
dragging = false,
|
||||
dragHandleProps,
|
||||
showProfile = false,
|
||||
className,
|
||||
style,
|
||||
ref,
|
||||
@@ -75,7 +68,6 @@ export function SidebarSessionRow({
|
||||
}: SidebarSessionRowProps) {
|
||||
const { t } = useI18n()
|
||||
const r = t.sidebar.row
|
||||
const { cancelPrewarm, startPrewarm } = useProfilePrewarm(session.profile)
|
||||
const title = sessionTitle(session)
|
||||
const age = formatAge(session.last_active || session.started_at, r)
|
||||
const handleLabel = `Reorder ${title}`
|
||||
@@ -169,12 +161,6 @@ export function SidebarSessionRow({
|
||||
|
||||
startSessionDrag({ id: session.id, profile: session.profile || 'default', title }, event)
|
||||
}}
|
||||
// Hovering a row from another profile (the all-profiles view) telegraphs
|
||||
// a cross-profile resume — start that backend's spawn now so the click
|
||||
// doesn't pay the full cold boot. Same-profile rows no-op inside
|
||||
// prewarmProfileBackend.
|
||||
onPointerEnter={startPrewarm}
|
||||
onPointerLeave={cancelPrewarm}
|
||||
ref={ref}
|
||||
style={style}
|
||||
{...rest}
|
||||
@@ -259,7 +245,6 @@ export function SidebarSessionRow({
|
||||
<SidebarRowLabel className="flex-1 font-normal group-hover:text-foreground group-data-[working=true]:text-foreground/90">
|
||||
{title}
|
||||
</SidebarRowLabel>
|
||||
{showProfile && <ProfileTag profile={session.profile} />}
|
||||
</SidebarRowBody>
|
||||
</SidebarRowShell>
|
||||
</SessionContextMenu>
|
||||
|
||||
@@ -135,10 +135,6 @@ interface SidebarSessionsSectionProps {
|
||||
// Rendered atop the entered-project body (a "back to overview" row).
|
||||
projectBackRow?: React.ReactNode
|
||||
dndSensors?: ReturnType<typeof useSensors>
|
||||
// Tag every row with its owning profile. Set on the flat cross-profile
|
||||
// lists (Pinned / search results) in the All-profiles view, where no group
|
||||
// header communicates ownership (#66003).
|
||||
showProfileTags?: boolean
|
||||
}
|
||||
|
||||
export function SidebarSessionsSection({
|
||||
@@ -178,8 +174,7 @@ export function SidebarSessionsSection({
|
||||
onReorderSessions,
|
||||
onReorderProjects,
|
||||
projectBackRow,
|
||||
dndSensors,
|
||||
showProfileTags = false
|
||||
dndSensors
|
||||
}: SidebarSessionsSectionProps) {
|
||||
const sectionOpen = collapsible ? open : true
|
||||
const hasGroupedSessions = Boolean(groups?.some(group => group.sessions.length > 0))
|
||||
@@ -208,8 +203,7 @@ export function SidebarSessionsSection({
|
||||
onPin: () => onTogglePin(sessionPinId(session)),
|
||||
onResume: () => onResumeSession(session.id),
|
||||
reorderable: draggable && !branchStem,
|
||||
session,
|
||||
showProfile: showProfileTags
|
||||
session
|
||||
}
|
||||
|
||||
return draggable && !branchStem ? (
|
||||
@@ -317,7 +311,6 @@ export function SidebarSessionsSection({
|
||||
onResumeSession={onResumeSession}
|
||||
onTogglePin={onTogglePin}
|
||||
pinned={pinned}
|
||||
showProfileTags={showProfileTags}
|
||||
sortable={sessionsDraggable}
|
||||
workingSessionIdSet={workingSessionIdSet}
|
||||
/>
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
import { useCallback, useEffect, useRef } from 'react'
|
||||
|
||||
import { prewarmProfileBackend } from '@/store/profile'
|
||||
|
||||
// Dwell before firing: long enough that sweeping the pointer across the rail
|
||||
// or a mixed-profile session list doesn't spawn a backend for every element
|
||||
// passed through, short enough to beat the click by hundreds of ms.
|
||||
const PREWARM_DWELL_MS = 120
|
||||
|
||||
/**
|
||||
* pointerenter/pointerleave handlers that pre-warm `profile`'s pool backend
|
||||
* after a short hover dwell (see prewarmProfileBackend in store/profile).
|
||||
* Consumers merge these with their own pointer handlers.
|
||||
*/
|
||||
export function useProfilePrewarm(profile: string | null | undefined) {
|
||||
const timer = useRef<null | number>(null)
|
||||
const profileRef = useRef(profile)
|
||||
profileRef.current = profile
|
||||
|
||||
const cancelPrewarm = useCallback(() => {
|
||||
if (timer.current != null) {
|
||||
clearTimeout(timer.current)
|
||||
timer.current = null
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => cancelPrewarm, [cancelPrewarm])
|
||||
|
||||
const startPrewarm = useCallback(() => {
|
||||
cancelPrewarm()
|
||||
timer.current = window.setTimeout(() => {
|
||||
timer.current = null
|
||||
prewarmProfileBackend(profileRef.current || 'default')
|
||||
}, PREWARM_DWELL_MS)
|
||||
}, [cancelPrewarm])
|
||||
|
||||
return { cancelPrewarm, startPrewarm }
|
||||
}
|
||||
@@ -21,7 +21,6 @@ interface SessionRowCommonProps {
|
||||
onPin: () => void
|
||||
onResume: () => void
|
||||
reorderable?: boolean
|
||||
showProfile?: boolean
|
||||
}
|
||||
|
||||
interface VirtualSessionListProps {
|
||||
@@ -34,7 +33,6 @@ interface VirtualSessionListProps {
|
||||
onResumeSession: (sessionId: string) => void
|
||||
onTogglePin: (sessionId: string) => void
|
||||
pinned: boolean
|
||||
showProfileTags?: boolean
|
||||
sortable: boolean
|
||||
workingSessionIdSet: Set<string>
|
||||
}
|
||||
@@ -52,7 +50,6 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
onResumeSession,
|
||||
onTogglePin,
|
||||
pinned,
|
||||
showProfileTags = false,
|
||||
sortable,
|
||||
workingSessionIdSet
|
||||
}) => {
|
||||
@@ -93,8 +90,7 @@ export const VirtualSessionList: FC<VirtualSessionListProps> = ({
|
||||
onDelete: () => onDeleteSession(session.id),
|
||||
onPin: () => onTogglePin(sessionPinId(session)),
|
||||
onResume: () => onResumeSession(session.id),
|
||||
reorderable,
|
||||
showProfile: showProfileTags
|
||||
reorderable
|
||||
}
|
||||
|
||||
return reorderable ? (
|
||||
|
||||
@@ -3,8 +3,7 @@ import { useEffect, useRef } from 'react'
|
||||
import { setPetActivity } from '@/store/pet'
|
||||
import { setPetScale } from '@/store/pet-gallery'
|
||||
import { setPetOverlayOpenAppHandler, setPetOverlayScaleHandler, setPetOverlaySubmitHandler } from '@/store/pet-overlay'
|
||||
import { $sessions } from '@/store/session'
|
||||
import { $attentionSessionIds } from '@/store/session-states'
|
||||
import { $attentionSessionIds, $sessions } from '@/store/session'
|
||||
import { isSecondaryWindow } from '@/store/windows'
|
||||
|
||||
import type { GatewayRequester } from '../types'
|
||||
|
||||
@@ -28,16 +28,18 @@ import { notify, notifyError } from '@/store/notifications'
|
||||
import { $activeGatewayProfile, normalizeProfileKey, touchActiveGatewayBackend } from '@/store/profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
ensureDefaultWorkspaceCwd,
|
||||
setConnection,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setSessionsLoading
|
||||
} from '@/store/session'
|
||||
import { $attentionSessionIds, $workingSessionIds, resetTileRuntimeBindings } from '@/store/session-states'
|
||||
import { resetTileRuntimeBindings } from '@/store/session-states'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
// After this many consecutive failed reconnects (≈45s with the 1→15s backoff)
|
||||
|
||||
@@ -5,8 +5,7 @@ import { useNavigate } from 'react-router-dom'
|
||||
|
||||
import { sessionTitle } from '@/lib/chat-runtime'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $unreadFinishedSessionIds } from '@/store/session'
|
||||
import { $attentionSessionIds, $workingSessionIds } from '@/store/session-states'
|
||||
import { $attentionSessionIds, $unreadFinishedSessionIds, $workingSessionIds } from '@/store/session'
|
||||
import { $switcherIndex, $switcherOpen, $switcherSessions, closeSwitcher } from '@/store/session-switcher'
|
||||
|
||||
import { HUD_ITEM, HUD_POSITION, HUD_SURFACE, HUD_TEXT } from './floating-hud'
|
||||
|
||||
@@ -2,9 +2,8 @@ import { act, cleanup, render, waitFor } from '@testing-library/react'
|
||||
import type { MutableRefObject } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { $queuedPromptsBySession, enqueueQueuedPrompt, getQueuedPrompts } from '@/store/composer-queue'
|
||||
import { clearAllSessionStates, publishSessionState } from '@/store/session-states'
|
||||
import { $workingSessionIds } from '@/store/session'
|
||||
|
||||
import { useBackgroundQueueDrain } from './use-background-queue-drain'
|
||||
import type { SubmitTextOptions } from './use-prompt-actions/utils'
|
||||
@@ -33,7 +32,6 @@ function Harness({
|
||||
describe('useBackgroundQueueDrain', () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -41,7 +39,7 @@ describe('useBackgroundQueueDrain', () => {
|
||||
vi.restoreAllMocks()
|
||||
vi.useRealTimers()
|
||||
$queuedPromptsBySession.set({})
|
||||
clearAllSessionStates()
|
||||
$workingSessionIds.set([])
|
||||
})
|
||||
|
||||
it('drains an idle queued prompt for a non-selected background session', async () => {
|
||||
@@ -49,7 +47,7 @@ describe('useBackgroundQueueDrain', () => {
|
||||
const submitText = vi.fn(async () => true)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'continue in the background', attachments: [] })
|
||||
clearAllSessionStates()
|
||||
$workingSessionIds.set([])
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
|
||||
|
||||
@@ -70,7 +68,7 @@ describe('useBackgroundQueueDrain', () => {
|
||||
const submitText = vi.fn(async () => true)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'visible queue entry', attachments: [] })
|
||||
clearAllSessionStates()
|
||||
$workingSessionIds.set([])
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} selectedStoredSessionId="stored-session-a" submitText={submitText} />)
|
||||
|
||||
@@ -85,8 +83,7 @@ describe('useBackgroundQueueDrain', () => {
|
||||
const submitText = vi.fn(async () => true)
|
||||
|
||||
enqueueQueuedPrompt('stored-session-a', { text: 'wait for current turn', attachments: [] })
|
||||
// Mark the session as working (busy) so the drain should skip it
|
||||
publishSessionState('rt-session-a', { ...createClientSessionState('stored-session-a'), busy: true })
|
||||
$workingSessionIds.set(['stored-session-a'])
|
||||
|
||||
render(<Harness runtimeMap={runtimeMap} submitText={submitText} />)
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
shouldAutoDrain
|
||||
} from '@/store/composer-queue'
|
||||
import { notify } from '@/store/notifications'
|
||||
import { $workingSessionIds } from '@/store/session-states'
|
||||
import { $workingSessionIds } from '@/store/session'
|
||||
|
||||
import type { SubmitTextOptions } from './use-prompt-actions/utils'
|
||||
|
||||
|
||||
@@ -249,15 +249,6 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
}
|
||||
|
||||
if (busy) {
|
||||
// Don't re-arm busy from a stale session.info if the user
|
||||
// just clicked Stop (interrupted=true). The backend's
|
||||
// cooperative interrupt may not have propagated yet, so
|
||||
// running is still true in the heartbeat. The turn's
|
||||
// finally block will emit running=false to clear busy.
|
||||
if (state.interrupted) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
busy,
|
||||
@@ -316,27 +307,14 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
triggerHaptic('streamStart')
|
||||
}
|
||||
|
||||
updateSessionState(sessionId, state => {
|
||||
// If the user clicked Stop (cancelRun set interrupted=true), don't
|
||||
// let a stale message.start from a chained turn (goal follow-up,
|
||||
// completion drain) or an in-flight LLM response re-arm busy.
|
||||
// The interrupt is user intent — the backend's cooperative cancel
|
||||
// may not have propagated yet, so its events are stale. The turn's
|
||||
// finally block will emit session.info with running=false to clear
|
||||
// busy for real once the agent loop actually exits.
|
||||
if (state.interrupted) {
|
||||
return state
|
||||
}
|
||||
|
||||
return {
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
turnStartedAt: Date.now()
|
||||
}
|
||||
})
|
||||
updateSessionState(sessionId, state => ({
|
||||
...state,
|
||||
busy: true,
|
||||
awaitingResponse: true,
|
||||
sawAssistantPayload: false,
|
||||
interrupted: false,
|
||||
turnStartedAt: Date.now()
|
||||
}))
|
||||
|
||||
if (isActiveEvent) {
|
||||
setTurnStartedAt(Date.now())
|
||||
|
||||
@@ -514,16 +514,7 @@ export function usePromptActions({
|
||||
)
|
||||
|
||||
const cancelRun = useCallback(async () => {
|
||||
// Read from the ref, not the closure-captured `activeSessionId`. The
|
||||
// actions bag is a stable ref mutated in place (Object.assign on each
|
||||
// ContribWiring render), and ChatRoutesSurface is memoized on that stable
|
||||
// ref — so it does NOT re-render when activeSessionId changes, which means
|
||||
// the ChatView element's onCancel prop holds a stale cancelRun closure.
|
||||
// The closure's `activeSessionId` can be a previous session's id (or null
|
||||
// from a new-chat draft), sending session.interrupt to the wrong session.
|
||||
// The ref is updated via useEffect on every activeSessionId change, so it
|
||||
// always reflects the current session — same pattern submitText uses.
|
||||
const sessionId = activeSessionIdRef.current
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
|
||||
const releaseBusy = () => {
|
||||
setMutableRef(busyRef, false)
|
||||
@@ -597,7 +588,15 @@ export function usePromptActions({
|
||||
releaseBusy()
|
||||
notifyError(stopError, copy.stopFailed)
|
||||
}
|
||||
}, [activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState])
|
||||
}, [
|
||||
activeSessionId,
|
||||
activeSessionIdRef,
|
||||
busyRef,
|
||||
copy.stopFailed,
|
||||
requestGateway,
|
||||
selectedStoredSessionIdRef,
|
||||
updateSessionState
|
||||
])
|
||||
|
||||
// Steer = nudge the live turn without interrupting: the gateway appends the
|
||||
// text to the next tool result so the model reads it on its next iteration
|
||||
|
||||
@@ -15,7 +15,9 @@ import {
|
||||
$messagingSessions,
|
||||
$selectedStoredSessionId,
|
||||
$sessions,
|
||||
$workingSessionIds,
|
||||
CRON_SECTION_LIMIT,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
MESSAGING_SECTION_LIMIT,
|
||||
setCronSessions,
|
||||
@@ -27,7 +29,6 @@ import {
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
} from '@/store/session'
|
||||
import { $workingSessionIds, getRecentlySettledSessionIds } from '@/store/session-states'
|
||||
|
||||
// The recents list is local-only: cron rows have their own section, and each
|
||||
// messaging platform (telegram, discord, …) is fetched separately into its own
|
||||
|
||||
@@ -6,18 +6,24 @@ import { preserveLocalAssistantErrors } from '@/lib/chat-messages'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { setMutableRef } from '@/lib/mutable-ref'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$busy,
|
||||
$messages,
|
||||
noteSessionActivity,
|
||||
onSessionWatchdogClear,
|
||||
setActiveSessionStoredId,
|
||||
setCurrentFastMode,
|
||||
setCurrentModel,
|
||||
setCurrentPersonality,
|
||||
setCurrentProvider,
|
||||
setCurrentReasoningEffort,
|
||||
setCurrentServiceTier,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
setTurnStartedAt,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
import { publishSessionState, setWatchdogClearFn } from '@/store/session-states'
|
||||
import { publishSessionState } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
@@ -97,20 +103,33 @@ export function useSessionStateCache({
|
||||
const existing = sessionStateByRuntimeIdRef.current.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
if (storedSessionId !== undefined && storedSessionId !== existing.storedSessionId) {
|
||||
// Stored id changed (e.g. auto-compression rotated it). Create a NEW
|
||||
// state object rather than mutating in place — updateSessionState needs
|
||||
// the PREVIOUS state to detect transitions (busy→idle, id rotation).
|
||||
const updated = { ...existing, storedSessionId }
|
||||
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, updated)
|
||||
if (storedSessionId !== undefined) {
|
||||
const previousStoredSessionId = existing.storedSessionId
|
||||
existing.storedSessionId = storedSessionId
|
||||
|
||||
if (storedSessionId) {
|
||||
runtimeIdByStoredSessionIdRef.current.set(storedSessionId, sessionId)
|
||||
|
||||
if (existing.busy) {
|
||||
setSessionWorking(storedSessionId, true)
|
||||
}
|
||||
}
|
||||
|
||||
if (previousStoredSessionId && previousStoredSessionId !== storedSessionId) {
|
||||
setSessionWorking(previousStoredSessionId, false)
|
||||
|
||||
// Auto-compression rotated the stored id on the active session. Signal
|
||||
// the route-following effect in use-session-actions so the URL + selection
|
||||
// re-anchor to the continuation id — otherwise the next send hits a stale
|
||||
// stored→runtime mapping (getRuntimeIdForStoredSession returns null) and
|
||||
// triggers a full thread reload via resumeStoredSession.
|
||||
if (sessionId === $activeSessionId.get()) {
|
||||
setActiveSessionStoredId(storedSessionId)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessionStateByRuntimeIdRef.current.get(sessionId)!
|
||||
return existing
|
||||
}
|
||||
|
||||
const created = createClientSessionState(storedSessionId ?? null)
|
||||
@@ -256,10 +275,30 @@ export function useSessionStateCache({
|
||||
const previous = ensureSessionState(sessionId, storedSessionId)
|
||||
const next = updater({ ...previous, messages: previous.messages })
|
||||
sessionStateByRuntimeIdRef.current.set(sessionId, next)
|
||||
// Publishing to $sessionStates automatically fires transition side-effects
|
||||
// (watchdog, settle grace, unread marker, compression id rotation) inside
|
||||
// publishSessionState — no manual transition call needed.
|
||||
// Mirror into the reactive multi-session store — session tiles (and any
|
||||
// other non-primary surface) subscribe per runtime id there instead of
|
||||
// through the single active $messages view.
|
||||
publishSessionState(sessionId, next)
|
||||
|
||||
if (previous.storedSessionId !== next.storedSessionId || !next.busy) {
|
||||
setSessionWorking(previous.storedSessionId, false)
|
||||
}
|
||||
|
||||
if (previous.storedSessionId !== next.storedSessionId || !next.needsInput) {
|
||||
setSessionAttention(previous.storedSessionId, false)
|
||||
}
|
||||
|
||||
setSessionWorking(next.storedSessionId, next.busy)
|
||||
setSessionAttention(next.storedSessionId, next.needsInput)
|
||||
|
||||
// Every state update is effectively a "still alive" heartbeat for
|
||||
// streaming events. The session-store watchdog uses this to keep the
|
||||
// working flag alive during long-running turns and to clear it once
|
||||
// the stream goes silent.
|
||||
if (next.busy) {
|
||||
noteSessionActivity(next.storedSessionId)
|
||||
}
|
||||
|
||||
syncSessionStateToView(sessionId, next)
|
||||
|
||||
return next
|
||||
@@ -279,32 +318,30 @@ export function useSessionStateCache({
|
||||
return runtimeState?.storedSessionId === storedSessionId ? runtimeId : null
|
||||
}, [])
|
||||
|
||||
// Wire the watchdog's force-clear callback to our cache. When the watchdog
|
||||
// fires (8 min of stream silence — a hung or looping turn that never
|
||||
// delivered its terminal event), it calls this to clear the session's busy
|
||||
// state. Clearing the sidebar dot alone would leave the composer wedged on
|
||||
// "Thinking"/Stop; updateSessionState propagates the clear to $sessionStates
|
||||
// → $workingSessionIds (computed) follows automatically, and
|
||||
// syncSessionStateToView re-syncs $busy when the healed session is the one
|
||||
// on screen.
|
||||
useEffect(() => {
|
||||
setWatchdogClearFn(runtimeId => {
|
||||
const state = sessionStateByRuntimeIdRef.current.get(runtimeId)
|
||||
// When the store watchdog force-clears a stuck session (8 min of stream
|
||||
// silence — a hung or looping turn that never delivered its terminal event),
|
||||
// also drop that session's busy/awaiting flags here. Clearing the sidebar dot
|
||||
// alone leaves the composer wedged on "Thinking"/Stop; updateSessionState
|
||||
// re-syncs `$busy` when the healed session is the one on screen.
|
||||
useEffect(
|
||||
() =>
|
||||
onSessionWatchdogClear(storedSessionId => {
|
||||
const runtimeId = runtimeIdByStoredSessionIdRef.current.get(storedSessionId)
|
||||
const state = runtimeId ? sessionStateByRuntimeIdRef.current.get(runtimeId) : undefined
|
||||
|
||||
if (!state?.busy) {
|
||||
return
|
||||
}
|
||||
if (!runtimeId || !state?.busy) {
|
||||
return
|
||||
}
|
||||
|
||||
updateSessionState(runtimeId, current => ({
|
||||
...current,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}))
|
||||
})
|
||||
|
||||
return () => setWatchdogClearFn(null)
|
||||
}, [updateSessionState])
|
||||
updateSessionState(runtimeId, current => ({
|
||||
...current,
|
||||
awaitingResponse: false,
|
||||
busy: false,
|
||||
needsInput: false
|
||||
}))
|
||||
}),
|
||||
[updateSessionState]
|
||||
)
|
||||
|
||||
return {
|
||||
activeSessionIdRef,
|
||||
|
||||
@@ -243,12 +243,7 @@ function assistantImageMessage(running = false): ThreadMessage {
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
interface StreamingControls {
|
||||
emitSecond: () => void
|
||||
complete: () => void
|
||||
}
|
||||
|
||||
function StreamingHarness({ onControls }: { onControls?: (controls: StreamingControls) => void } = {}) {
|
||||
function StreamingHarness() {
|
||||
const [messages, setMessages] = useState<ThreadMessage[]>([userMessage()])
|
||||
const [isRunning, setIsRunning] = useState(true)
|
||||
|
||||
@@ -257,19 +252,6 @@ function StreamingHarness({ onControls }: { onControls?: (controls: StreamingCon
|
||||
setMessages([userMessage(), assistantMessage('first chunk')])
|
||||
}, 50)
|
||||
|
||||
if (onControls) {
|
||||
onControls({
|
||||
emitSecond: () => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
|
||||
},
|
||||
complete: () => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk second chunk', false)])
|
||||
setIsRunning(false)
|
||||
}
|
||||
})
|
||||
return () => window.clearTimeout(first)
|
||||
}
|
||||
|
||||
const second = window.setTimeout(() => {
|
||||
setMessages([userMessage(), assistantMessage('first chunk second chunk')])
|
||||
}, 500)
|
||||
@@ -284,7 +266,7 @@ function StreamingHarness({ onControls }: { onControls?: (controls: StreamingCon
|
||||
window.clearTimeout(second)
|
||||
window.clearTimeout(complete)
|
||||
}
|
||||
}, [onControls])
|
||||
}, [])
|
||||
|
||||
const runtime = useExternalStoreRuntime<ThreadMessage>({
|
||||
messages,
|
||||
@@ -417,30 +399,26 @@ describe('assistant-ui streaming renderer', () => {
|
||||
})
|
||||
|
||||
it('renders assistant text incrementally before completion', async () => {
|
||||
let controls: StreamingControls | undefined
|
||||
const registerControls = (next: StreamingControls) => {
|
||||
controls = next
|
||||
}
|
||||
const { container } = render(<StreamingHarness onControls={registerControls} />)
|
||||
const { container } = render(<StreamingHarness />)
|
||||
|
||||
expect(screen.getByRole('status', { name: 'Hermes is loading a response' })).toBeTruthy()
|
||||
|
||||
await wait(80)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk')
|
||||
})
|
||||
expect(container.textContent).not.toContain('second chunk')
|
||||
expect(screen.queryByRole('status', { name: 'Hermes is loading a response' })).toBeNull()
|
||||
|
||||
// Producer-gated, not wall-clock-gated: the old test slept 80ms and
|
||||
// assumed a 500ms timer could not fire before the assertion. On a loaded
|
||||
// runner the test thread could be descheduled for >500ms, so both chunks
|
||||
// arrived and this clean behavior test flaked.
|
||||
act(() => controls?.emitSecond())
|
||||
await wait(500)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk second chunk')
|
||||
})
|
||||
|
||||
act(() => controls?.complete())
|
||||
await wait(250)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(container.textContent).toContain('first chunk second chunk')
|
||||
})
|
||||
|
||||
@@ -1660,7 +1660,6 @@ export const en: Translations = {
|
||||
finishedUnread: 'Finished — unread',
|
||||
backgroundRunning: 'Background task running',
|
||||
handoffOrigin: platform => `Handed off from ${platform}`,
|
||||
ownedByProfile: profile => `Profile: ${profile}`,
|
||||
renamed: 'Renamed',
|
||||
renameFailed: 'Rename failed',
|
||||
renameTitle: 'Rename session',
|
||||
@@ -2175,7 +2174,6 @@ export const en: Translations = {
|
||||
noModel: 'no model',
|
||||
switchModel: 'Switch model',
|
||||
openModelPicker: 'Open model picker',
|
||||
modelPinned: 'pinned by you; new chats use this instead of the Settings default',
|
||||
modelTitle: (provider, model) => `Model · ${provider}: ${model}`,
|
||||
providerModelTitle: (provider, model) => `${provider} · ${model}`
|
||||
}
|
||||
|
||||
@@ -1577,7 +1577,6 @@ export const ja = defineLocale({
|
||||
finishedUnread: '完了 — 未読',
|
||||
backgroundRunning: 'バックグラウンドタスク実行中',
|
||||
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
||||
ownedByProfile: profile => `プロファイル: ${profile}`,
|
||||
renamed: '名前を変更しました',
|
||||
renameFailed: '名前の変更に失敗しました',
|
||||
renameTitle: 'セッションの名前を変更',
|
||||
@@ -2094,7 +2093,6 @@ export const ja = defineLocale({
|
||||
noModel: 'モデルなし',
|
||||
switchModel: 'モデルを切り替え',
|
||||
openModelPicker: 'モデルピッカーを開く',
|
||||
modelPinned: '手動で固定中 — 新しいチャットは設定のデフォルトではなくこのモデルを使用します',
|
||||
modelTitle: (provider, model) => `モデル · ${provider}: ${model}`,
|
||||
providerModelTitle: (provider, model) => `${provider} · ${model}`
|
||||
}
|
||||
|
||||
@@ -1383,7 +1383,6 @@ export interface Translations {
|
||||
finishedUnread: string
|
||||
backgroundRunning: string
|
||||
handoffOrigin: (platform: string) => string
|
||||
ownedByProfile: (profile: string) => string
|
||||
renamed: string
|
||||
renameFailed: string
|
||||
renameTitle: string
|
||||
@@ -1804,7 +1803,6 @@ export interface Translations {
|
||||
noModel: string
|
||||
switchModel: string
|
||||
openModelPicker: string
|
||||
modelPinned: string
|
||||
modelTitle: (provider: string, model: string) => string
|
||||
providerModelTitle: (provider: string, model: string) => string
|
||||
}
|
||||
|
||||
@@ -1527,7 +1527,6 @@ export const zhHant = defineLocale({
|
||||
finishedUnread: '已完成 — 未讀',
|
||||
backgroundRunning: '背景任務執行中',
|
||||
handoffOrigin: platform => `從 ${platform} 轉接`,
|
||||
ownedByProfile: profile => `設定檔:${profile}`,
|
||||
renamed: '已重新命名',
|
||||
renameFailed: '重新命名失敗',
|
||||
renameTitle: '重新命名工作階段',
|
||||
@@ -2029,7 +2028,6 @@ export const zhHant = defineLocale({
|
||||
noModel: '無模型',
|
||||
switchModel: '切換模型',
|
||||
openModelPicker: '開啟模型選擇器',
|
||||
modelPinned: '已由你固定;新對話將使用此模型而非「設定」中的預設模型',
|
||||
modelTitle: (provider, model) => `模型 · ${provider}:${model}`,
|
||||
providerModelTitle: (provider, model) => `${provider} · ${model}`
|
||||
}
|
||||
|
||||
@@ -1836,7 +1836,6 @@ export const zh: Translations = {
|
||||
finishedUnread: '已完成 — 未读',
|
||||
backgroundRunning: '后台任务运行中',
|
||||
handoffOrigin: platform => `从 ${platform} 转接`,
|
||||
ownedByProfile: profile => `配置档:${profile}`,
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
renameTitle: '重命名会话',
|
||||
@@ -2340,7 +2339,6 @@ export const zh: Translations = {
|
||||
noModel: '无模型',
|
||||
switchModel: '切换模型',
|
||||
openModelPicker: '打开模型选择器',
|
||||
modelPinned: '已由你固定;新对话将使用此模型而非“设置”中的默认模型',
|
||||
modelTitle: (provider, model) => `模型 · ${provider}: ${model}`,
|
||||
providerModelTitle: (provider, model) => `${provider} · ${model}`
|
||||
}
|
||||
|
||||
@@ -3,8 +3,8 @@ import { atom } from 'nanostores'
|
||||
import { queryClient } from '@/lib/query-client'
|
||||
import { resetSessionsLimit } from '@/store/layout'
|
||||
import {
|
||||
$unreadFinishedSessionIds,
|
||||
setActiveSessionId,
|
||||
setAttentionSessionIds,
|
||||
setCronSessions,
|
||||
setFreshDraftReady,
|
||||
setMessages,
|
||||
@@ -15,9 +15,10 @@ import {
|
||||
setSessionProfileTotals,
|
||||
setSessions,
|
||||
setSessionsLoading,
|
||||
setSessionsTotal
|
||||
setSessionsTotal,
|
||||
setUnreadFinishedSessionIds,
|
||||
setWorkingSessionIds
|
||||
} from '@/store/session'
|
||||
import { clearAllSessionStates } from '@/store/session-states'
|
||||
|
||||
// True while a soft gateway-mode apply is mid-flight (wipe → re-dial). Lets the
|
||||
// boot hook suppress the backend-exit toast and keeps the cold-boot CONNECTING
|
||||
@@ -44,11 +45,9 @@ export function wipeSessionListsForGatewaySwitch(): void {
|
||||
setMessagingSessions([])
|
||||
setMessagingPlatformTotals({})
|
||||
setMessagingTruncated(false)
|
||||
// Clearing $sessionStates automatically clears $workingSessionIds and
|
||||
// $attentionSessionIds (they're computed from it). $unreadFinishedSessionIds
|
||||
// is separate (transient, not computable) so wipe it explicitly.
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
setWorkingSessionIds([])
|
||||
setAttentionSessionIds([])
|
||||
setUnreadFinishedSessionIds([])
|
||||
setSessionsLoading(true)
|
||||
resetSessionsLimit()
|
||||
|
||||
|
||||
@@ -181,27 +181,6 @@ function createSecondary(profile: string): Secondary {
|
||||
return entry
|
||||
}
|
||||
|
||||
// Open `profile`'s socket WITHOUT making it active — the hover-intent pre-warm
|
||||
// (store/profile). Runs the same spawn + connect chain as a real switch, so by
|
||||
// click time ensureGatewayForProfile finds an open socket and just activates
|
||||
// it. No scheduleReconnect on failure: a hover is speculative, so a dead
|
||||
// backend must not start a background retry loop — the real switch owns retry
|
||||
// and error UX. An already-open (or primary) profile is a no-op.
|
||||
export async function openGatewayForProfile(profile: string): Promise<void> {
|
||||
const key = normKey(profile)
|
||||
|
||||
if (key === primaryProfile) {
|
||||
return
|
||||
}
|
||||
|
||||
const entry = secondaries.get(key) ?? createSecondary(key)
|
||||
entry.wantOpen = true
|
||||
|
||||
if (!isOpen(entry.gateway)) {
|
||||
await openSecondary(entry)
|
||||
}
|
||||
}
|
||||
|
||||
// Make `profile` the active gateway, lazily opening its socket if needed. The
|
||||
// primary is a no-op fast path. Background sockets are never closed here.
|
||||
export async function ensureGatewayForProfile(profile: string): Promise<void> {
|
||||
|
||||
@@ -7,11 +7,10 @@ import type { ProfileInfo } from '@/types/hermes'
|
||||
// Keep profile.ts's side-effecting imports inert: the gateway socket layer and
|
||||
// the REST query client must not run for real in a unit test.
|
||||
const ensureGatewayForProfile = vi.fn(async () => undefined)
|
||||
const openGatewayForProfile = vi.fn(async (_profile: string) => undefined)
|
||||
const $gateway = atom<unknown>({ id: 'live-socket' })
|
||||
const resetStarmapGraph = vi.fn()
|
||||
|
||||
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile, openGatewayForProfile }))
|
||||
vi.mock('@/store/gateway', () => ({ $gateway, ensureGatewayForProfile }))
|
||||
vi.mock('@/hermes', () => ({
|
||||
getProfiles: vi.fn(async () => ({ profiles: [] })),
|
||||
setApiRequestProfile: vi.fn()
|
||||
@@ -19,9 +18,7 @@ vi.mock('@/hermes', () => ({
|
||||
vi.mock('@/lib/query-client', () => ({ queryClient: { invalidateQueries: vi.fn() } }))
|
||||
vi.mock('@/store/starmap', () => ({ resetStarmapGraph }))
|
||||
|
||||
const { $activeGatewayProfile, $profiles, ensureGatewayProfile, prewarmProfileBackend, refreshProfiles } =
|
||||
await import('./profile')
|
||||
|
||||
const { $activeGatewayProfile, $profiles, ensureGatewayProfile, refreshProfiles } = await import('./profile')
|
||||
const { $connection } = await import('./session')
|
||||
const { queryClient } = await import('@/lib/query-client')
|
||||
const { getProfiles } = await import('@/hermes')
|
||||
@@ -47,7 +44,6 @@ const getConnection = vi.fn<(profile?: string | null) => Promise<HermesConnectio
|
||||
beforeEach(() => {
|
||||
getConnection.mockReset()
|
||||
ensureGatewayForProfile.mockClear()
|
||||
openGatewayForProfile.mockClear()
|
||||
$gateway.set({ id: 'live-socket' })
|
||||
$activeGatewayProfile.set('default')
|
||||
$connection.set(localConn())
|
||||
@@ -119,40 +115,6 @@ describe('profile-scoped cache invalidation', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('prewarmProfileBackend (hover-intent pool spawn)', () => {
|
||||
it('opens the gateway (spawn + connect, no activation) for a non-active profile', () => {
|
||||
prewarmProfileBackend('warm-basic')
|
||||
|
||||
expect(openGatewayForProfile).toHaveBeenCalledWith('warm-basic')
|
||||
// Pre-warm must never activate — that's the click's job.
|
||||
expect(ensureGatewayForProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('skips the profile the gateway is already on', () => {
|
||||
$activeGatewayProfile.set('warm-active')
|
||||
|
||||
prewarmProfileBackend('warm-active')
|
||||
|
||||
expect(openGatewayForProfile).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('throttles repeat pre-warms for the same profile within the interval', () => {
|
||||
prewarmProfileBackend('warm-throttle-a')
|
||||
prewarmProfileBackend('warm-throttle-a')
|
||||
prewarmProfileBackend('warm-throttle-b')
|
||||
|
||||
const calls = openGatewayForProfile.mock.calls.map(([name]) => name)
|
||||
expect(calls.filter(name => name === 'warm-throttle-a')).toHaveLength(1)
|
||||
expect(calls.filter(name => name === 'warm-throttle-b')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('swallows spawn failures — error UX belongs to the real switch', () => {
|
||||
openGatewayForProfile.mockRejectedValueOnce(new Error('spawn failed'))
|
||||
|
||||
expect(() => prewarmProfileBackend('warm-failing')).not.toThrow()
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshProfiles shared rail list (#49289)', () => {
|
||||
it('removes a deleted profile from the shared $profiles cache after Manage Profiles refreshes', async () => {
|
||||
$profiles.set([profile('default', true), profile('test1')])
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
storedStringArray,
|
||||
storedStringRecord
|
||||
} from '@/lib/storage'
|
||||
import { $gateway, ensureGatewayForProfile, openGatewayForProfile } from '@/store/gateway'
|
||||
import { $gateway, ensureGatewayForProfile } from '@/store/gateway'
|
||||
import { setConnection } from '@/store/session'
|
||||
import { resetStarmapGraph } from '@/store/starmap'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
@@ -189,39 +189,6 @@ $activeGatewayProfile.subscribe(value => {
|
||||
// so a lazy spawn doesn't read as a hang. Single-profile users never swap.
|
||||
export const $gatewaySwapTarget = atom<string | null>(null)
|
||||
|
||||
// ── Hover-intent backend pre-warm ───────────────────────────────────────────
|
||||
// A cold switch to a profile whose pool backend isn't running pays the full
|
||||
// spawn (Python boot + port announce + readiness probe — measured ~2.5-3s)
|
||||
// plus the socket connect before the sidebar can repopulate. The pointer
|
||||
// entering a profile square in the rail signals the switch a few hundred ms
|
||||
// before the click lands, so we run the same spawn + connect chain then
|
||||
// (openGatewayForProfile — without activating). `ensureBackend` in the
|
||||
// Electron main is idempotent (a pooled profile returns its existing
|
||||
// connectionPromise), so the real switch joins the in-flight work instead of
|
||||
// duplicating it — and a pre-warm for an already-open profile is a no-op.
|
||||
// Throttled per profile so drive-by hovers can't spam spawn attempts; failures
|
||||
// stay silent here and surface on the real switch, which owns retry/error UX.
|
||||
const PREWARM_MIN_INTERVAL_MS = 60_000
|
||||
|
||||
const prewarmedAt = new Map<string, number>()
|
||||
|
||||
export function prewarmProfileBackend(name: string): void {
|
||||
const key = normalizeProfileKey(name)
|
||||
|
||||
if (key === normalizeProfileKey($activeGatewayProfile.get())) {
|
||||
return
|
||||
}
|
||||
|
||||
const now = Date.now()
|
||||
|
||||
if (now - (prewarmedAt.get(key) ?? 0) < PREWARM_MIN_INTERVAL_MS) {
|
||||
return
|
||||
}
|
||||
|
||||
prewarmedAt.set(key, now)
|
||||
openGatewayForProfile(key).catch(() => undefined)
|
||||
}
|
||||
|
||||
let gatewaySwitch: Promise<void> | null = null
|
||||
|
||||
// Keep the renderer's $connection (mode / baseUrl / profile) in lockstep with
|
||||
|
||||
@@ -1,453 +0,0 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { HermesReviewFile, HermesReviewShipInfo } from '@/global'
|
||||
|
||||
import {
|
||||
$reviewCommitDefault,
|
||||
$reviewCommitMsgBusy,
|
||||
$reviewDiff,
|
||||
$reviewDiffLoading,
|
||||
$reviewFiles,
|
||||
$reviewIsRepo,
|
||||
$reviewLoading,
|
||||
$reviewMaxChurn,
|
||||
$reviewOpen,
|
||||
$reviewRevertTarget,
|
||||
$reviewSelectedPath,
|
||||
$reviewShipBusy,
|
||||
$reviewShipInfo,
|
||||
$reviewTreeMode,
|
||||
cancelRevert,
|
||||
clearReviewSelection,
|
||||
closeReview,
|
||||
commitChanges,
|
||||
confirmRevert,
|
||||
createOrOpenPr,
|
||||
generateCommitMessage,
|
||||
openReview,
|
||||
pushChanges,
|
||||
refreshReview,
|
||||
refreshShipInfo,
|
||||
requestRevert,
|
||||
revertReviewFile,
|
||||
selectReviewFile,
|
||||
stageReviewFile,
|
||||
toggleReviewTreeMode,
|
||||
unstageReviewFile
|
||||
} from './review'
|
||||
import { $currentCwd } from './session'
|
||||
|
||||
// requestOneShot is the only cross-module dependency that must be faked (it
|
||||
// reaches the gateway); everything else routes through window.hermesDesktop.git,
|
||||
// which we stub per-test like the sibling coding-status.test.ts does.
|
||||
const requestOneShot = vi.fn(async (_args: unknown) => 'generated message')
|
||||
vi.mock('@/lib/oneshot', () => ({ requestOneShot: (args: unknown) => requestOneShot(args) }))
|
||||
// refreshRepoStatus is a fire-and-forget side effect of mutations; stub it so it
|
||||
// doesn't try to hit the (absent) probe and log.
|
||||
vi.mock('./coding-status', () => ({ refreshRepoStatus: vi.fn() }))
|
||||
|
||||
function file(path: string, over: Partial<HermesReviewFile> = {}): HermesReviewFile {
|
||||
return { path, status: 'modified', staged: false, added: 1, removed: 0, ...over } as HermesReviewFile
|
||||
}
|
||||
|
||||
type ReviewStub = Record<string, ReturnType<typeof vi.fn>>
|
||||
|
||||
// Install a review bridge on window.hermesDesktop. Any op not supplied defaults
|
||||
// to a resolved no-op so a test only declares what it exercises.
|
||||
function stubReview(over: ReviewStub = {}) {
|
||||
const review: ReviewStub = {
|
||||
list: vi.fn(async () => ({ files: [] })),
|
||||
diff: vi.fn(async () => ''),
|
||||
stage: vi.fn(async () => undefined),
|
||||
unstage: vi.fn(async () => undefined),
|
||||
revert: vi.fn(async () => undefined),
|
||||
commit: vi.fn(async () => undefined),
|
||||
commitContext: vi.fn(async () => ({ diff: 'd', recent: 'r' })),
|
||||
push: vi.fn(async () => undefined),
|
||||
shipInfo: vi.fn(async () => ({ ghReady: false, pr: null })),
|
||||
createPr: vi.fn(async () => ({ url: 'https://example.com/pr/1' })),
|
||||
...over
|
||||
}
|
||||
|
||||
;(window as unknown as { hermesDesktop?: unknown }).hermesDesktop = {
|
||||
git: { review },
|
||||
openExternal: vi.fn()
|
||||
}
|
||||
|
||||
return review
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
requestOneShot.mockClear()
|
||||
requestOneShot.mockResolvedValue('generated message')
|
||||
// Reset stores touched across tests.
|
||||
$reviewOpen.set(false)
|
||||
$reviewFiles.set([])
|
||||
$reviewLoading.set(false)
|
||||
$reviewIsRepo.set(true)
|
||||
$reviewDiff.set(null)
|
||||
$reviewDiffLoading.set(false)
|
||||
$reviewSelectedPath.set(null)
|
||||
$reviewShipInfo.set({ ghReady: false, pr: null })
|
||||
$reviewShipBusy.set(false)
|
||||
$reviewCommitMsgBusy.set(false)
|
||||
$reviewRevertTarget.set(undefined)
|
||||
$currentCwd.set('/repo')
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
})
|
||||
|
||||
describe('refreshReview', () => {
|
||||
it('is a no-op that clears state when the pane is closed', async () => {
|
||||
const review = stubReview()
|
||||
$reviewOpen.set(false)
|
||||
$reviewFiles.set([file('a.ts')])
|
||||
|
||||
await refreshReview()
|
||||
|
||||
expect(review.list).not.toHaveBeenCalled()
|
||||
expect($reviewFiles.get()).toEqual([])
|
||||
expect($reviewLoading.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('flags not-a-repo (and clears loading) when there is no bridge/cwd', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
$reviewOpen.set(true)
|
||||
$reviewLoading.set(true)
|
||||
|
||||
await refreshReview()
|
||||
|
||||
expect($reviewIsRepo.get()).toBe(false)
|
||||
expect($reviewLoading.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('populates the changed-file list from the bridge', async () => {
|
||||
stubReview({ list: vi.fn(async () => ({ files: [file('a.ts'), file('b.ts')] })) })
|
||||
$reviewOpen.set(true)
|
||||
|
||||
await refreshReview()
|
||||
|
||||
expect($reviewFiles.get().map(f => f.path)).toEqual(['a.ts', 'b.ts'])
|
||||
expect($reviewIsRepo.get()).toBe(true)
|
||||
expect($reviewLoading.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('filters excluded paths (node_modules et al.) out of the list', async () => {
|
||||
stubReview({ list: vi.fn(async () => ({ files: [file('src/a.ts'), file('node_modules/x/index.js')] })) })
|
||||
$reviewOpen.set(true)
|
||||
|
||||
await refreshReview()
|
||||
|
||||
expect($reviewFiles.get().map(f => f.path)).toEqual(['src/a.ts'])
|
||||
})
|
||||
|
||||
it('drops a selection whose file vanished from the new list', async () => {
|
||||
stubReview({ list: vi.fn(async () => ({ files: [file('kept.ts')] })) })
|
||||
$reviewOpen.set(true)
|
||||
$reviewSelectedPath.set('gone.ts')
|
||||
$reviewDiff.set('old diff')
|
||||
|
||||
await refreshReview()
|
||||
|
||||
expect($reviewSelectedPath.get()).toBeNull()
|
||||
expect($reviewDiff.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the list but keeps isRepo true when the bridge throws', async () => {
|
||||
stubReview({
|
||||
list: vi.fn(async () => {
|
||||
throw new Error('git failed')
|
||||
})
|
||||
})
|
||||
$reviewOpen.set(true)
|
||||
$reviewFiles.set([file('stale.ts')])
|
||||
|
||||
await refreshReview()
|
||||
|
||||
expect($reviewFiles.get()).toEqual([])
|
||||
expect($reviewIsRepo.get()).toBe(true)
|
||||
expect($reviewLoading.get()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('$reviewMaxChurn', () => {
|
||||
it('is the largest added+removed across files', () => {
|
||||
$reviewFiles.set([file('a', { added: 3, removed: 2 }), file('b', { added: 10, removed: 1 }), file('c')])
|
||||
expect($reviewMaxChurn.get()).toBe(11)
|
||||
})
|
||||
|
||||
it('is 0 for an empty list', () => {
|
||||
$reviewFiles.set([])
|
||||
expect($reviewMaxChurn.get()).toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('selectReviewFile / clearReviewSelection', () => {
|
||||
it('sets the selected path and fetches its diff', async () => {
|
||||
const review = stubReview({ diff: vi.fn(async () => 'the diff') })
|
||||
|
||||
await selectReviewFile(file('a.ts'))
|
||||
|
||||
expect($reviewSelectedPath.get()).toBe('a.ts')
|
||||
expect($reviewDiff.get()).toBe('the diff')
|
||||
expect($reviewDiffLoading.get()).toBe(false)
|
||||
expect(review.diff).toHaveBeenCalledWith('/repo', 'a.ts', 'uncommitted', null, false)
|
||||
})
|
||||
|
||||
it('coerces a falsy diff to empty string (not null)', async () => {
|
||||
stubReview({ diff: vi.fn(async () => '') })
|
||||
|
||||
await selectReviewFile(file('a.ts'))
|
||||
|
||||
expect($reviewDiff.get()).toBe('')
|
||||
})
|
||||
|
||||
it('sets diff null when there is no bridge', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
|
||||
await selectReviewFile(file('a.ts'))
|
||||
|
||||
expect($reviewSelectedPath.get()).toBe('a.ts')
|
||||
expect($reviewDiff.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('clears path, diff and loading', () => {
|
||||
$reviewSelectedPath.set('a.ts')
|
||||
$reviewDiff.set('x')
|
||||
$reviewDiffLoading.set(true)
|
||||
|
||||
clearReviewSelection()
|
||||
|
||||
expect($reviewSelectedPath.get()).toBeNull()
|
||||
expect($reviewDiff.get()).toBeNull()
|
||||
expect($reviewDiffLoading.get()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('view state', () => {
|
||||
it('toggleReviewTreeMode flips list <-> tree', () => {
|
||||
$reviewTreeMode.set('tree')
|
||||
toggleReviewTreeMode()
|
||||
expect($reviewTreeMode.get()).toBe('list')
|
||||
toggleReviewTreeMode()
|
||||
expect($reviewTreeMode.get()).toBe('tree')
|
||||
})
|
||||
|
||||
it('openReview opens the pane and kicks off a refresh', async () => {
|
||||
const review = stubReview()
|
||||
openReview()
|
||||
expect($reviewOpen.get()).toBe(true)
|
||||
// openReview fires refreshReview + refreshShipInfo without awaiting.
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(review.list).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('closeReview closes the pane and clears the selection', () => {
|
||||
stubReview()
|
||||
$reviewOpen.set(true)
|
||||
$reviewSelectedPath.set('a.ts')
|
||||
$reviewDiff.set('x')
|
||||
|
||||
closeReview()
|
||||
|
||||
expect($reviewOpen.get()).toBe(false)
|
||||
expect($reviewSelectedPath.get()).toBeNull()
|
||||
expect($reviewDiff.get()).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('mutations', () => {
|
||||
it('stageReviewFile forwards the path and re-syncs', async () => {
|
||||
const review = stubReview()
|
||||
$reviewOpen.set(true) // afterMutation's refreshReview only lists when the pane is open
|
||||
await stageReviewFile('a.ts')
|
||||
expect(review.stage).toHaveBeenCalledWith('/repo', 'a.ts')
|
||||
expect(review.list).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('unstageReviewFile forwards the path', async () => {
|
||||
const review = stubReview()
|
||||
await unstageReviewFile('a.ts')
|
||||
expect(review.unstage).toHaveBeenCalledWith('/repo', 'a.ts')
|
||||
})
|
||||
|
||||
it('revertReviewFile forwards the path', async () => {
|
||||
const review = stubReview()
|
||||
await revertReviewFile('a.ts')
|
||||
expect(review.revert).toHaveBeenCalledWith('/repo', 'a.ts')
|
||||
})
|
||||
|
||||
it('stage with null path means "all"', async () => {
|
||||
const review = stubReview()
|
||||
await stageReviewFile(null)
|
||||
expect(review.stage).toHaveBeenCalledWith('/repo', null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('revert confirm dialog', () => {
|
||||
it('requestRevert opens a target, cancelRevert closes it', () => {
|
||||
requestRevert('a.ts')
|
||||
expect($reviewRevertTarget.get()).toEqual({ path: 'a.ts' })
|
||||
cancelRevert()
|
||||
expect($reviewRevertTarget.get()).toBeUndefined()
|
||||
})
|
||||
|
||||
it('requestRevert(null) encodes the "revert all" target distinctly from closed', () => {
|
||||
requestRevert(null)
|
||||
expect($reviewRevertTarget.get()).toEqual({ path: null })
|
||||
})
|
||||
|
||||
it('confirmRevert closes the dialog then performs the revert', async () => {
|
||||
const review = stubReview()
|
||||
requestRevert('a.ts')
|
||||
|
||||
await confirmRevert()
|
||||
|
||||
expect($reviewRevertTarget.get()).toBeUndefined()
|
||||
expect(review.revert).toHaveBeenCalledWith('/repo', 'a.ts')
|
||||
})
|
||||
|
||||
it('confirmRevert is a no-op when nothing is pending', async () => {
|
||||
const review = stubReview()
|
||||
$reviewRevertTarget.set(undefined)
|
||||
|
||||
await confirmRevert()
|
||||
|
||||
expect(review.revert).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('ship flow', () => {
|
||||
it('commitChanges commits the trimmed message and toggles the busy flag', async () => {
|
||||
const review = stubReview()
|
||||
const seen: boolean[] = []
|
||||
const unsub = $reviewShipBusy.subscribe(v => seen.push(v))
|
||||
|
||||
await commitChanges(' a message ', { push: true })
|
||||
|
||||
expect(review.commit).toHaveBeenCalledWith('/repo', 'a message', true)
|
||||
expect(seen).toContain(true)
|
||||
expect($reviewShipBusy.get()).toBe(false)
|
||||
unsub()
|
||||
})
|
||||
|
||||
it('commitChanges bails on a blank message', async () => {
|
||||
const review = stubReview()
|
||||
await commitChanges(' ')
|
||||
expect(review.commit).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('pushChanges pushes and refreshes ship info', async () => {
|
||||
const review = stubReview()
|
||||
await pushChanges()
|
||||
expect(review.push).toHaveBeenCalledWith('/repo')
|
||||
})
|
||||
|
||||
it('createOrOpenPr opens the existing PR without creating a new one', async () => {
|
||||
const review = stubReview()
|
||||
$reviewShipInfo.set({ ghReady: true, pr: { url: 'https://example.com/pr/9' } } as HermesReviewShipInfo)
|
||||
|
||||
await createOrOpenPr()
|
||||
|
||||
expect(review.createPr).not.toHaveBeenCalled()
|
||||
expect(
|
||||
(window.hermesDesktop as unknown as { openExternal: ReturnType<typeof vi.fn> }).openExternal
|
||||
).toHaveBeenCalledWith('https://example.com/pr/9')
|
||||
})
|
||||
|
||||
it('createOrOpenPr creates a PR when none exists, then opens it', async () => {
|
||||
const review = stubReview({ createPr: vi.fn(async () => ({ url: 'https://example.com/pr/new' })) })
|
||||
$reviewShipInfo.set({ ghReady: true, pr: null })
|
||||
|
||||
await createOrOpenPr()
|
||||
|
||||
expect(review.createPr).toHaveBeenCalledWith('/repo')
|
||||
expect(
|
||||
(window.hermesDesktop as unknown as { openExternal: ReturnType<typeof vi.fn> }).openExternal
|
||||
).toHaveBeenCalledWith('https://example.com/pr/new')
|
||||
})
|
||||
})
|
||||
|
||||
describe('refreshShipInfo', () => {
|
||||
it('populates ship info from the bridge', async () => {
|
||||
const info: HermesReviewShipInfo = {
|
||||
ghReady: true,
|
||||
pr: { url: 'https://example.com/pr/3' }
|
||||
} as HermesReviewShipInfo
|
||||
|
||||
stubReview({ shipInfo: vi.fn(async () => info) })
|
||||
|
||||
await refreshShipInfo()
|
||||
|
||||
expect($reviewShipInfo.get()).toEqual(info)
|
||||
})
|
||||
|
||||
it('resets ship info when there is no bridge', async () => {
|
||||
delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop
|
||||
$reviewShipInfo.set({ ghReady: true, pr: { url: 'x' } } as HermesReviewShipInfo)
|
||||
|
||||
await refreshShipInfo()
|
||||
|
||||
expect($reviewShipInfo.get()).toEqual({ ghReady: false, pr: null })
|
||||
})
|
||||
|
||||
it('resets ship info when the bridge throws', async () => {
|
||||
stubReview({
|
||||
shipInfo: vi.fn(async () => {
|
||||
throw new Error('gh missing')
|
||||
})
|
||||
})
|
||||
$reviewShipInfo.set({ ghReady: true, pr: { url: 'x' } } as HermesReviewShipInfo)
|
||||
|
||||
await refreshShipInfo()
|
||||
|
||||
expect($reviewShipInfo.get()).toEqual({ ghReady: false, pr: null })
|
||||
})
|
||||
})
|
||||
|
||||
describe('generateCommitMessage', () => {
|
||||
it('returns a one-shot message from the working-tree diff', async () => {
|
||||
stubReview()
|
||||
|
||||
const msg = await generateCommitMessage('avoid this')
|
||||
|
||||
expect(msg).toBe('generated message')
|
||||
expect(requestOneShot).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
template: 'commit_message',
|
||||
variables: expect.objectContaining({ avoid: 'avoid this', diff: 'd', recent_commits: 'r' })
|
||||
})
|
||||
)
|
||||
expect($reviewCommitMsgBusy.get()).toBe(false)
|
||||
})
|
||||
|
||||
it('returns empty (no model call) when the diff is blank', async () => {
|
||||
stubReview({ commitContext: vi.fn(async () => ({ diff: ' ', recent: '' })) })
|
||||
|
||||
const msg = await generateCommitMessage()
|
||||
|
||||
expect(msg).toBe('')
|
||||
expect(requestOneShot).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('returns empty when the bridge lacks commitContext', async () => {
|
||||
const review = stubReview()
|
||||
delete review.commitContext
|
||||
|
||||
const msg = await generateCommitMessage()
|
||||
|
||||
expect(msg).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('$reviewCommitDefault', () => {
|
||||
it('remembers the split-button default action', () => {
|
||||
$reviewCommitDefault.set('commitPush')
|
||||
expect($reviewCommitDefault.get()).toBe('commitPush')
|
||||
$reviewCommitDefault.set('commit')
|
||||
expect($reviewCommitDefault.get()).toBe('commit')
|
||||
})
|
||||
})
|
||||
@@ -30,12 +30,7 @@ import {
|
||||
import { readJson, writeJson } from '@/lib/storage'
|
||||
|
||||
import { $activeGatewayProfile, normalizeProfileKey } from './profile'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$selectedStoredSessionId,
|
||||
$unreadFinishedSessionIds,
|
||||
setActiveSessionStoredId
|
||||
} from './session'
|
||||
import { $activeSessionId, $selectedStoredSessionId } from './session'
|
||||
import { isSecondaryWindow } from './windows'
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -44,127 +39,12 @@ import { isSecondaryWindow } from './windows'
|
||||
|
||||
export const $sessionStates = atom<Record<string, ClientSessionState>>({})
|
||||
|
||||
// --- Watchdog: force-clears busy after 8 min of stream silence -------------
|
||||
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
type WatchdogClearFn = (runtimeId: string) => void
|
||||
let watchdogClearFn: WatchdogClearFn | null = null
|
||||
|
||||
export function setWatchdogClearFn(fn: WatchdogClearFn | null) {
|
||||
watchdogClearFn = fn
|
||||
}
|
||||
|
||||
function armWatchdog(runtimeId: string) {
|
||||
const existing = sessionWatchdogTimers.get(runtimeId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
sessionWatchdogTimers.set(
|
||||
runtimeId,
|
||||
setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(runtimeId)
|
||||
watchdogClearFn?.(runtimeId)
|
||||
}, SESSION_WATCHDOG_TIMEOUT_MS)
|
||||
)
|
||||
}
|
||||
|
||||
function clearWatchdog(runtimeId: string) {
|
||||
const t = sessionWatchdogTimers.get(runtimeId)
|
||||
|
||||
if (t) {
|
||||
clearTimeout(t)
|
||||
sessionWatchdogTimers.delete(runtimeId)
|
||||
}
|
||||
}
|
||||
|
||||
// --- Settle grace: keeps a just-finished session in the sidebar merge set ---
|
||||
const SESSION_SETTLE_GRACE_MS = 30 * 1000
|
||||
const settledExpiry = new Map<string, number>()
|
||||
|
||||
function markSettled(storedId: string) {
|
||||
settledExpiry.set(storedId, Date.now() + SESSION_SETTLE_GRACE_MS)
|
||||
}
|
||||
|
||||
function clearSettled(storedId: string) {
|
||||
settledExpiry.delete(storedId)
|
||||
}
|
||||
|
||||
/** Stored ids whose turn ended within the grace window. Prunes expired. */
|
||||
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
|
||||
const live: string[] = []
|
||||
|
||||
for (const [id, expiry] of settledExpiry) {
|
||||
if (expiry > now) {
|
||||
live.push(id)
|
||||
} else {
|
||||
settledExpiry.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return live
|
||||
}
|
||||
|
||||
// --- Transition detection (called automatically from publishSessionState) ---
|
||||
function handleTransition(previous: ClientSessionState | null, next: ClientSessionState, runtimeId: string) {
|
||||
// Compression id rotation: signal the route-follow effect.
|
||||
if (previous?.storedSessionId && next.storedSessionId && previous.storedSessionId !== next.storedSessionId) {
|
||||
if (runtimeId === $activeSessionId.get()) {
|
||||
setActiveSessionStoredId(next.storedSessionId)
|
||||
}
|
||||
|
||||
clearSettled(previous.storedSessionId)
|
||||
}
|
||||
|
||||
// Watchdog: arm on any busy publish, disarm on idle.
|
||||
if (next.busy) {
|
||||
armWatchdog(runtimeId)
|
||||
} else {
|
||||
clearWatchdog(runtimeId)
|
||||
}
|
||||
|
||||
const storedId = next.storedSessionId
|
||||
|
||||
if (!storedId) {
|
||||
return
|
||||
}
|
||||
|
||||
const wasWorking = previous?.busy ?? false
|
||||
|
||||
if (next.busy && !wasWorking) {
|
||||
clearSettled(storedId)
|
||||
} else if (!next.busy && wasWorking) {
|
||||
markSettled(storedId)
|
||||
|
||||
if (storedId !== $selectedStoredSessionId.get()) {
|
||||
const cur = $unreadFinishedSessionIds.get()
|
||||
|
||||
if (!cur.includes(storedId)) {
|
||||
$unreadFinishedSessionIds.set([...cur, storedId])
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Publish one session's state. Automatically fires transition side-effects
|
||||
* (watchdog arm/disarm, settle grace, unread marker, compression id rotation)
|
||||
* by diffing previous vs next — callers never need to manually call a
|
||||
* transition handler. */
|
||||
/** Publish one session's state (immutable per-key — slices stay stable). */
|
||||
export function publishSessionState(runtimeId: string, state: ClientSessionState) {
|
||||
const prev = $sessionStates.get()[runtimeId] ?? null
|
||||
$sessionStates.set({ ...$sessionStates.get(), [runtimeId]: state })
|
||||
handleTransition(prev, state, runtimeId)
|
||||
}
|
||||
|
||||
export function dropSessionState(runtimeId: string) {
|
||||
// Disarm the watchdog — a dropped runtime must not fire a stale clear later.
|
||||
// Settle-grace entries are keyed by stored id and self-expire; leave them so
|
||||
// a just-finished session's row survives merge eviction even if its tile or
|
||||
// cached runtime is dropped in the meantime.
|
||||
clearWatchdog(runtimeId)
|
||||
|
||||
const current = $sessionStates.get()
|
||||
|
||||
if (!(runtimeId in current)) {
|
||||
@@ -175,38 +55,6 @@ export function dropSessionState(runtimeId: string) {
|
||||
$sessionStates.set(rest)
|
||||
}
|
||||
|
||||
/** Drop every cached session state — used on soft gateway-mode apply so the
|
||||
* computed working / attention sets drain to empty alongside the session list.
|
||||
* Also disarms every watchdog timer and drops all settle-grace entries: a
|
||||
* wiped gateway's sessions must not fire stale clears or linger in the
|
||||
* sidebar merge keep-set after the switch. */
|
||||
export function clearAllSessionStates() {
|
||||
for (const timer of sessionWatchdogTimers.values()) {
|
||||
clearTimeout(timer)
|
||||
}
|
||||
|
||||
sessionWatchdogTimers.clear()
|
||||
settledExpiry.clear()
|
||||
$sessionStates.set({})
|
||||
}
|
||||
|
||||
// Derived per-session status sets. `$sessionStates` already holds `busy` and
|
||||
// `needsInput` for every runtime session (written by updateSessionState); these
|
||||
// are pure projections of it, not independently maintained atoms. This keeps the
|
||||
// data flow one-directional: gateway event → cache → $sessionStates → computed
|
||||
// views, eliminating the "projection atom out of sync with cache" bug class.
|
||||
export const $workingSessionIds = computed($sessionStates, states =>
|
||||
Object.values(states)
|
||||
.filter(s => s.busy && s.storedSessionId)
|
||||
.map(s => s.storedSessionId!)
|
||||
)
|
||||
|
||||
export const $attentionSessionIds = computed($sessionStates, states =>
|
||||
Object.values(states)
|
||||
.filter(s => s.needsInput && s.storedSessionId)
|
||||
.map(s => s.storedSessionId!)
|
||||
)
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Session tiles.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -1,286 +1,59 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
|
||||
import { $activeSessionId, $selectedStoredSessionId, $unreadFinishedSessionIds } from './session'
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
$sessionStates,
|
||||
$workingSessionIds,
|
||||
clearAllSessionStates,
|
||||
getRecentlySettledSessionIds,
|
||||
publishSessionState,
|
||||
setWatchdogClearFn
|
||||
} from './session-states'
|
||||
import { $workingSessionIds, onSessionWatchdogClear, setSessionWorking, setWorkingSessionIds } from './session'
|
||||
|
||||
const WATCHDOG_MS = 8 * 60 * 1000
|
||||
|
||||
function state(over: Partial<ClientSessionState> = {}): ClientSessionState {
|
||||
return { ...createClientSessionState(null), storedSessionId: 's1', ...over }
|
||||
}
|
||||
|
||||
describe('session status transitions', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
// clearAllSessionStates also disarms watchdog timers + drops settle-grace
|
||||
// entries, so no leftover state can leak in from a previous test.
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('adds a session to $workingSessionIds when busy transitions to true', () => {
|
||||
const s = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
|
||||
// idle → working
|
||||
const next = { ...s, busy: true }
|
||||
publishSessionState('rt1', next)
|
||||
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
})
|
||||
|
||||
it('removes a session from $workingSessionIds when busy transitions to false', () => {
|
||||
const s = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
// Simulate the working state being set
|
||||
const working = { ...s, busy: true }
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
|
||||
// Now transition to idle
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($workingSessionIds.get()).not.toContain('s1')
|
||||
})
|
||||
|
||||
it('adds a session to $attentionSessionIds when needsInput is true', () => {
|
||||
const s = state({ busy: true, needsInput: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', s)
|
||||
|
||||
const next = { ...s, needsInput: true }
|
||||
publishSessionState('rt1', next)
|
||||
|
||||
expect($attentionSessionIds.get()).toContain('s1')
|
||||
})
|
||||
|
||||
it('marks a background session unread when its turn finishes', () => {
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does NOT mark unread when the finishing session is the active one', () => {
|
||||
$selectedStoredSessionId.set('s1')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const idle = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('grants settle grace when a working session goes idle', () => {
|
||||
$selectedStoredSessionId.set('other')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does not grant grace on idle→idle re-asserts', () => {
|
||||
const idle = state({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', idle)
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears settle grace when the session goes busy again', () => {
|
||||
$selectedStoredSessionId.set('other')
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's2' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
|
||||
|
||||
// New turn for the same session
|
||||
const workingAgain = { ...idle, busy: true }
|
||||
publishSessionState('rt1', workingAgain)
|
||||
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('session watchdog', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
setWorkingSessionIds(() => [])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.runOnlyPendingTimers()
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$activeSessionId.set(null)
|
||||
})
|
||||
|
||||
it('drops a stuck session from $workingSessionIds once the silence window elapses', () => {
|
||||
// Wire a clear fn like use-session-state-cache does in the real app: the
|
||||
// watchdog hands us the runtime id, we publish the busy:false state.
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => {
|
||||
clearedRuntimeIds.push(runtimeId)
|
||||
const current = $sessionStates.get()[runtimeId]
|
||||
|
||||
if (current) {
|
||||
publishSessionState(runtimeId, { ...current, busy: false, needsInput: false })
|
||||
}
|
||||
})
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
it('drops a stuck session and notifies listeners once the silence window elapses', () => {
|
||||
const cleared: string[] = []
|
||||
const off = onSessionWatchdogClear(id => cleared.push(id))
|
||||
|
||||
setSessionWorking('s1', true)
|
||||
expect($workingSessionIds.get()).toContain('s1')
|
||||
|
||||
// Watchdog fires after 8 min of silence → the wired clear fn runs and the
|
||||
// computed working set drops the session. This asserts the timer→callback
|
||||
// wiring, not just the projection.
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
expect(clearedRuntimeIds).toEqual(['rt1'])
|
||||
// Both the sidebar dot AND the busy-clearing signal fire — the contract
|
||||
// that lets the composer recover from a hung/looping turn, not just the dot.
|
||||
expect($workingSessionIds.get()).not.toContain('s1')
|
||||
expect(cleared).toEqual(['s1'])
|
||||
|
||||
setWatchdogClearFn(null)
|
||||
off()
|
||||
})
|
||||
|
||||
it('never fires for a session that settles before the window', () => {
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
|
||||
const cleared: string[] = []
|
||||
const off = onSessionWatchdogClear(id => cleared.push(id))
|
||||
|
||||
const working = state({ busy: true, storedSessionId: 's2' })
|
||||
publishSessionState('rt2', working)
|
||||
|
||||
// Session settles before the watchdog window
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt2', idle)
|
||||
setSessionWorking('s2', true)
|
||||
setSessionWorking('s2', false)
|
||||
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
// The watchdog was disarmed — the clear fn never ran.
|
||||
expect(clearedRuntimeIds).toEqual([])
|
||||
expect($workingSessionIds.get()).not.toContain('s2')
|
||||
expect(cleared).toEqual([])
|
||||
|
||||
setWatchdogClearFn(null)
|
||||
off()
|
||||
})
|
||||
|
||||
it('does not fire after clearAllSessionStates disarms every timer', () => {
|
||||
const clearedRuntimeIds: string[] = []
|
||||
setWatchdogClearFn(runtimeId => clearedRuntimeIds.push(runtimeId))
|
||||
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
clearAllSessionStates()
|
||||
it('stops notifying after unsubscribe', () => {
|
||||
const cleared: string[] = []
|
||||
const off = onSessionWatchdogClear(id => cleared.push(id))
|
||||
off()
|
||||
|
||||
setSessionWorking('s3', true)
|
||||
vi.advanceTimersByTime(WATCHDOG_MS)
|
||||
|
||||
expect(clearedRuntimeIds).toEqual([])
|
||||
|
||||
setWatchdogClearFn(null)
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed $workingSessionIds', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
it('is empty when no sessions are busy', () => {
|
||||
expect($workingSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('reflects sessions with busy=true and a storedSessionId', () => {
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
publishSessionState('rt2', state({ busy: false, storedSessionId: 's2' }))
|
||||
publishSessionState('rt3', state({ busy: true, storedSessionId: null }))
|
||||
|
||||
expect($workingSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('updates when session state changes', () => {
|
||||
publishSessionState('rt1', state({ busy: true, storedSessionId: 's1' }))
|
||||
expect($workingSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
publishSessionState('rt1', state({ busy: false, storedSessionId: 's1' }))
|
||||
expect($workingSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('computed $attentionSessionIds', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
it('reflects sessions with needsInput=true and a storedSessionId', () => {
|
||||
publishSessionState('rt1', state({ needsInput: true, storedSessionId: 's1' }))
|
||||
publishSessionState('rt2', state({ needsInput: false, storedSessionId: 's2' }))
|
||||
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('clears when $sessionStates is cleared', () => {
|
||||
publishSessionState('rt1', state({ needsInput: true, storedSessionId: 's1' }))
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
clearAllSessionStates()
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
expect(cleared).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,28 +1,24 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import {
|
||||
$activeSessionId,
|
||||
$attentionSessionIds,
|
||||
$connection,
|
||||
$currentCwd,
|
||||
$selectedStoredSessionId,
|
||||
$unreadFinishedSessionIds,
|
||||
$workingSessionIds,
|
||||
applyConfiguredDefaultProjectDir,
|
||||
getRecentlySettledSessionIds,
|
||||
mergeSessionPage,
|
||||
sessionPinId,
|
||||
setCurrentCwd,
|
||||
setSelectedStoredSessionId,
|
||||
setSessionAttention,
|
||||
setSessionWorking,
|
||||
workspaceCwdForNewSession
|
||||
} from './session'
|
||||
import {
|
||||
$attentionSessionIds,
|
||||
clearAllSessionStates,
|
||||
getRecentlySettledSessionIds,
|
||||
publishSessionState
|
||||
} from './session-states'
|
||||
|
||||
const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
archived: false,
|
||||
@@ -43,32 +39,30 @@ const session = (over: Partial<SessionInfo>): SessionInfo => ({
|
||||
...over
|
||||
})
|
||||
|
||||
describe('computed $attentionSessionIds', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
describe('setSessionAttention', () => {
|
||||
it('adds and removes a session id without duplicating it', () => {
|
||||
$attentionSessionIds.set([])
|
||||
|
||||
afterEach(() => {
|
||||
clearAllSessionStates()
|
||||
})
|
||||
|
||||
it('reflects sessions with needsInput=true and a storedSessionId', () => {
|
||||
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: true })
|
||||
publishSessionState('rt2', { ...createClientSessionState('s2'), needsInput: false })
|
||||
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('updates when needsInput changes', () => {
|
||||
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: true })
|
||||
setSessionAttention('s1', true)
|
||||
setSessionAttention('s1', true)
|
||||
expect($attentionSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
publishSessionState('rt1', { ...createClientSessionState('s1'), needsInput: false })
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
setSessionAttention('s2', true)
|
||||
expect($attentionSessionIds.get()).toEqual(['s1', 's2'])
|
||||
|
||||
setSessionAttention('s1', false)
|
||||
expect($attentionSessionIds.get()).toEqual(['s2'])
|
||||
|
||||
$attentionSessionIds.set([])
|
||||
})
|
||||
|
||||
it('ignores sessions without a storedSessionId', () => {
|
||||
publishSessionState('rt1', { ...createClientSessionState(null), needsInput: true })
|
||||
it('ignores empty ids and no-op clears', () => {
|
||||
$attentionSessionIds.set([])
|
||||
|
||||
setSessionAttention(null, true)
|
||||
setSessionAttention(undefined, true)
|
||||
setSessionAttention('', true)
|
||||
setSessionAttention('missing', false)
|
||||
expect($attentionSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -248,36 +242,25 @@ describe('workspaceCwdForNewSession', () => {
|
||||
})
|
||||
})
|
||||
|
||||
function makeState(over: Partial<ClientSessionState> = {}): ClientSessionState {
|
||||
return { ...createClientSessionState('s1'), ...over }
|
||||
}
|
||||
|
||||
describe('getRecentlySettledSessionIds', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
// clearAllSessionStates also drops settle-grace entries + watchdog timers,
|
||||
// so nothing leaks in from a previous test.
|
||||
clearAllSessionStates()
|
||||
$selectedStoredSessionId.set(null)
|
||||
$unreadFinishedSessionIds.set([])
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers()
|
||||
clearAllSessionStates()
|
||||
$selectedStoredSessionId.set(null)
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// Drain anything left in the grace map so tests stay isolated.
|
||||
for (const id of getRecentlySettledSessionIds(Number.MAX_SAFE_INTEGER)) {
|
||||
void id
|
||||
}
|
||||
})
|
||||
|
||||
it('keeps a session for the grace window after its turn settles, then drops it', () => {
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// A turn starts then ends: the working→idle transition grants grace.
|
||||
const working = makeState({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s1'])
|
||||
|
||||
// Still inside the window.
|
||||
@@ -290,87 +273,74 @@ describe('getRecentlySettledSessionIds', () => {
|
||||
})
|
||||
|
||||
it('does not grant grace when the session was never working (idle re-asserts)', () => {
|
||||
const idle = makeState({ busy: false, storedSessionId: 'idle' })
|
||||
publishSessionState('rt1', idle)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
|
||||
// updateSessionState re-asserts `false` for idle sessions on every tick;
|
||||
// these must not pin an idle chat into the keep-set indefinitely.
|
||||
setSessionWorking('idle', false)
|
||||
setSessionWorking('idle', false)
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears the grace timer when the session goes busy again', () => {
|
||||
const working = makeState({ busy: true, storedSessionId: 's2' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
vi.useFakeTimers()
|
||||
vi.setSystemTime(0)
|
||||
$workingSessionIds.set([])
|
||||
|
||||
setSessionWorking('s2', true)
|
||||
setSessionWorking('s2', false)
|
||||
expect(getRecentlySettledSessionIds()).toEqual(['s2'])
|
||||
|
||||
// A new turn for the same session is "working" again — drop it from the
|
||||
// settled set so it's tracked as working, not recently-finished.
|
||||
const workingAgain = { ...idle, busy: true }
|
||||
publishSessionState('rt1', workingAgain)
|
||||
|
||||
setSessionWorking('s2', true)
|
||||
expect(getRecentlySettledSessionIds()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('unread finished sessions', () => {
|
||||
beforeEach(() => {
|
||||
clearAllSessionStates()
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
$workingSessionIds.set([])
|
||||
setSelectedStoredSessionId(() => null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
clearAllSessionStates()
|
||||
$workingSessionIds.set([])
|
||||
$unreadFinishedSessionIds.set([])
|
||||
$selectedStoredSessionId.set(null)
|
||||
setSelectedStoredSessionId(() => null)
|
||||
})
|
||||
|
||||
it('marks a session unread when its turn finishes in the background', () => {
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const working = makeState({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
setSelectedStoredSessionId(() => 'other-session')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
})
|
||||
|
||||
it('does NOT mark unread when the finishing session is the active one', () => {
|
||||
$selectedStoredSessionId.set('s1')
|
||||
|
||||
const working = makeState({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
setSelectedStoredSessionId(() => 's1')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('does NOT mark unread on idle→idle re-asserts (no prior working state)', () => {
|
||||
$selectedStoredSessionId.set('other-session')
|
||||
|
||||
const idle = makeState({ busy: false, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
setSelectedStoredSessionId(() => 'other-session')
|
||||
setSessionWorking('s1', false)
|
||||
setSessionWorking('s1', false)
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
|
||||
it('clears unread when the user opens the session', () => {
|
||||
$selectedStoredSessionId.set('other')
|
||||
|
||||
const working = makeState({ busy: true, storedSessionId: 's1' })
|
||||
publishSessionState('rt1', working)
|
||||
|
||||
const idle = { ...working, busy: false }
|
||||
publishSessionState('rt1', idle)
|
||||
|
||||
setSelectedStoredSessionId(() => 'other')
|
||||
setSessionWorking('s1', true)
|
||||
setSessionWorking('s1', false)
|
||||
expect($unreadFinishedSessionIds.get()).toEqual(['s1'])
|
||||
|
||||
setSelectedStoredSessionId('s1')
|
||||
setSelectedStoredSessionId(() => 's1')
|
||||
expect($unreadFinishedSessionIds.get()).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -245,6 +245,7 @@ export const $messagingTruncated = atom<boolean>(false)
|
||||
// one. Empty for single-profile users (fall back to $sessionsTotal).
|
||||
export const $sessionProfileTotals = atom<Record<string, number>>({})
|
||||
export const $sessionsLoading = atom(true)
|
||||
export const $workingSessionIds = atom<string[]>([])
|
||||
export const $activeSessionId = atom<string | null>(null)
|
||||
export const $selectedStoredSessionId = atom<string | null>(null)
|
||||
// Reactive signal for when the active session's stored id rotates (auto-
|
||||
@@ -325,20 +326,17 @@ export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($mes
|
||||
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
|
||||
updateAtom($sessionProfileTotals, next)
|
||||
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
|
||||
export const setWorkingSessionIds = (next: Updater<string[]>) => updateAtom($workingSessionIds, next)
|
||||
export const setActiveSessionId = (next: Updater<string | null>) => updateAtom($activeSessionId, next)
|
||||
export const setActiveSessionStoredId = (next: Updater<string | null>) => updateAtom($activeSessionStoredId, next)
|
||||
|
||||
// Transient: a background session finished and the user hasn't opened it since.
|
||||
// Written by session-states.ts (handleTransition), cleared here on session open.
|
||||
export const $unreadFinishedSessionIds = atom<string[]>([])
|
||||
|
||||
export const setSelectedStoredSessionId = (next: Updater<string | null>) => {
|
||||
updateAtom($selectedStoredSessionId, next)
|
||||
// Opening a session clears its unread state — the user is now looking at it.
|
||||
const id = $selectedStoredSessionId.get()
|
||||
|
||||
if (id && $unreadFinishedSessionIds.get().includes(id)) {
|
||||
$unreadFinishedSessionIds.set($unreadFinishedSessionIds.get().filter(x => x !== id))
|
||||
toggleMembership(setUnreadFinishedSessionIds, id, false)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -365,14 +363,8 @@ export const getCurrentModelSource = (): ComposerModelSource => {
|
||||
return source === 'default' || source === 'manual' ? source : ''
|
||||
}
|
||||
|
||||
// Reactive mirror of the persisted source so UI (the composer pill's
|
||||
// override badge) can subscribe. The getter above stays storage-backed —
|
||||
// it's read cross-window, where this atom wouldn't see writes.
|
||||
export const $currentModelSource = atom<ComposerModelSource>(getCurrentModelSource())
|
||||
|
||||
export const setCurrentModelSource = (source: ComposerModelSource) => {
|
||||
persistString(COMPOSER_MODEL_SOURCE_KEY, source || null)
|
||||
$currentModelSource.set(source)
|
||||
}
|
||||
|
||||
export const setCurrentReasoningEffort = (next: Updater<string>) => {
|
||||
@@ -429,3 +421,176 @@ export const setIntroSeed = (next: Updater<number>) => updateAtom($introSeed, ne
|
||||
export const setContextSuggestions = (next: Updater<ContextSuggestion[]>) => updateAtom($contextSuggestions, next)
|
||||
export const setModelPickerOpen = (next: Updater<boolean>) => updateAtom($modelPickerOpen, next)
|
||||
export const setSessionPickerOpen = (next: Updater<boolean>) => updateAtom($sessionPickerOpen, next)
|
||||
|
||||
// Watchdog tracking — when does a "working" session count as stuck?
|
||||
// Long-running tool calls (LLM inference, long shell commands, web fetches)
|
||||
// can take a few minutes legitimately. We allow 8 minutes of complete
|
||||
// silence on the stream before clearing the working flag; in practice this
|
||||
// catches gateway hangs and dropped streams without false-positive-clearing
|
||||
// real long turns.
|
||||
const SESSION_WATCHDOG_TIMEOUT_MS = 8 * 60 * 1000
|
||||
const sessionWatchdogTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
||||
|
||||
// Notified (with the stored session id) whenever the watchdog force-clears a
|
||||
// stuck session. The session-state cache subscribes to also drop that session's
|
||||
// busy/awaiting flags — clearing `$workingSessionIds` alone only removes the
|
||||
// sidebar dot, leaving the composer stuck on "Thinking"/Stop for a hung or
|
||||
// looping turn that never streamed its terminal event.
|
||||
type SessionWatchdogListener = (storedSessionId: string) => void
|
||||
const sessionWatchdogListeners = new Set<SessionWatchdogListener>()
|
||||
|
||||
export function onSessionWatchdogClear(listener: SessionWatchdogListener): () => void {
|
||||
sessionWatchdogListeners.add(listener)
|
||||
|
||||
return () => void sessionWatchdogListeners.delete(listener)
|
||||
}
|
||||
|
||||
function armSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
}
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
|
||||
// Re-check the latest state at fire-time. If the user already navigated
|
||||
// away or the session genuinely finished, the timer is a no-op.
|
||||
if ($workingSessionIds.get().includes(sessionId)) {
|
||||
setWorkingSessionIds(current => current.filter(id => id !== sessionId))
|
||||
}
|
||||
|
||||
for (const listener of sessionWatchdogListeners) {
|
||||
listener(sessionId)
|
||||
}
|
||||
}, SESSION_WATCHDOG_TIMEOUT_MS)
|
||||
|
||||
sessionWatchdogTimers.set(sessionId, timer)
|
||||
}
|
||||
|
||||
function clearSessionWatchdog(sessionId: string) {
|
||||
const existing = sessionWatchdogTimers.get(sessionId)
|
||||
|
||||
if (existing) {
|
||||
clearTimeout(existing)
|
||||
sessionWatchdogTimers.delete(sessionId)
|
||||
}
|
||||
}
|
||||
|
||||
// A session's "working" flag clears the instant its turn ends, but the
|
||||
// cross-profile aggregator (listSessions with min_messages=1) only sees the
|
||||
// just-persisted first turn a beat later. The active chat is shielded from that
|
||||
// race by sessionsToKeep(), but a brand-new session that finished *while you
|
||||
// were viewing a different chat* is, at the next refresh, neither working,
|
||||
// pinned, nor active — so mergeSessionPage() evicts it. Nothing re-fetches
|
||||
// afterward, so it stays gone until the app restarts. (Repro: start a new chat,
|
||||
// then click another session before the first reply lands.)
|
||||
//
|
||||
// To bridge that window we keep a session in the merge keep-set for a short
|
||||
// grace period after its turn settles, giving the aggregator time to catch up.
|
||||
// Entries auto-expire, so this never accumulates and can't resurrect a deleted
|
||||
// session (mergeSessionPage only revives rows still present in the in-memory
|
||||
// list, which optimistic delete/archive already drops).
|
||||
const SESSION_SETTLE_GRACE_MS = 30 * 1000
|
||||
const settledSessionExpiry = new Map<string, number>()
|
||||
|
||||
function markSessionSettled(sessionId: string) {
|
||||
settledSessionExpiry.set(sessionId, Date.now() + SESSION_SETTLE_GRACE_MS)
|
||||
}
|
||||
|
||||
function clearSessionSettled(sessionId: string) {
|
||||
settledSessionExpiry.delete(sessionId)
|
||||
}
|
||||
|
||||
/** Stored ids of sessions whose turn ended within the grace window. Prunes
|
||||
* expired entries as it reads, so it stays bounded without a timer. */
|
||||
export function getRecentlySettledSessionIds(now: number = Date.now()): string[] {
|
||||
const live: string[] = []
|
||||
|
||||
for (const [id, expiry] of settledSessionExpiry) {
|
||||
if (expiry > now) {
|
||||
live.push(id)
|
||||
} else {
|
||||
settledSessionExpiry.delete(id)
|
||||
}
|
||||
}
|
||||
|
||||
return live
|
||||
}
|
||||
|
||||
/** Call when a streaming event for a session lands. Refreshes the watchdog
|
||||
* so the session keeps its "working" status as long as data keeps coming. */
|
||||
export function noteSessionActivity(sessionId: string | null | undefined) {
|
||||
if (!sessionId || !$workingSessionIds.get().includes(sessionId)) {
|
||||
return
|
||||
}
|
||||
|
||||
armSessionWatchdog(sessionId)
|
||||
}
|
||||
|
||||
// Toggle an id's membership in a string-set atom, no-op when unchanged (keeps
|
||||
// the same array reference so subscribers don't churn).
|
||||
const toggleMembership = (set: (next: Updater<string[]>) => void, id: string, on: boolean) =>
|
||||
set(current => {
|
||||
const present = current.includes(id)
|
||||
|
||||
if (on) {
|
||||
return present ? current : [...current, id]
|
||||
}
|
||||
|
||||
return present ? current.filter(x => x !== id) : current
|
||||
})
|
||||
|
||||
// Stored session ids whose most recent turn finished while the user was
|
||||
// looking at a different session. The sidebar renders a steady green dot for
|
||||
// these so the user can tab back and find newly-completed work. Cleared on
|
||||
// session open (setSelectedStoredSessionId) and on gateway-mode wipe.
|
||||
export const $unreadFinishedSessionIds = atom<string[]>([])
|
||||
export const setUnreadFinishedSessionIds = (next: Updater<string[]>) => updateAtom($unreadFinishedSessionIds, next)
|
||||
|
||||
// Stored session ids with a blocking prompt (clarify) waiting on the user.
|
||||
// Separate from $workingSessionIds: a session can be "working" (turn running)
|
||||
// AND need input. The sidebar row reads this for a persistent indicator that,
|
||||
// unlike a toast, survives window blur / alt-tab.
|
||||
export const $attentionSessionIds = atom<string[]>([])
|
||||
export const setAttentionSessionIds = (next: Updater<string[]>) => updateAtom($attentionSessionIds, next)
|
||||
|
||||
export function setSessionAttention(sessionId: string | null | undefined, needsInput: boolean) {
|
||||
if (sessionId) {
|
||||
toggleMembership(setAttentionSessionIds, sessionId, needsInput)
|
||||
}
|
||||
}
|
||||
|
||||
export function setSessionWorking(sessionId: string | null | undefined, working: boolean) {
|
||||
if (!sessionId) {
|
||||
return
|
||||
}
|
||||
|
||||
const wasWorking = $workingSessionIds.get().includes(sessionId)
|
||||
|
||||
toggleMembership(setWorkingSessionIds, sessionId, working)
|
||||
|
||||
// Bookend the watchdog: arm on enter, disarm on leave. A later
|
||||
// noteSessionActivity() from a streaming event refreshes the timer.
|
||||
if (working) {
|
||||
clearSessionSettled(sessionId)
|
||||
armSessionWatchdog(sessionId)
|
||||
} else {
|
||||
clearSessionWatchdog(sessionId)
|
||||
|
||||
// Only grant grace on a real working→idle transition (updateSessionState
|
||||
// re-asserts `false` on every state tick, which must not keep extending the
|
||||
// window). This keeps the just-finished session visible long enough for the
|
||||
// aggregator to return its now-persisted row.
|
||||
if (wasWorking) {
|
||||
markSessionSettled(sessionId)
|
||||
|
||||
// Mark unread when a background session finishes — only if the user
|
||||
// isn't currently viewing it. The active session's finish is seen live.
|
||||
if (sessionId !== $selectedStoredSessionId.get()) {
|
||||
toggleMembership(setUnreadFinishedSessionIds, sessionId, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+24
-24
@@ -536,22 +536,22 @@ class BatchRunner:
|
||||
run_name: str,
|
||||
distribution: str = "default",
|
||||
max_iterations: int = 10,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
model: str = "claude-opus-4-20250514",
|
||||
num_workers: int = 4,
|
||||
verbose: bool = False,
|
||||
ephemeral_system_prompt: str = None,
|
||||
ephemeral_system_prompt: str | None = None,
|
||||
log_prefix_chars: int = 100,
|
||||
providers_allowed: List[str] = None,
|
||||
providers_ignored: List[str] = None,
|
||||
providers_order: List[str] = None,
|
||||
provider_sort: str = None,
|
||||
providers_allowed: List[str] | None = None,
|
||||
providers_ignored: List[str] | None = None,
|
||||
providers_order: List[str] | None = None,
|
||||
provider_sort: str | None = None,
|
||||
openrouter_min_coding_score: Optional[float] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
max_samples: int = None,
|
||||
max_tokens: int | None = None,
|
||||
reasoning_config: Dict[str, Any] | None = None,
|
||||
prefill_messages: List[Dict[str, Any]] | None = None,
|
||||
max_samples: int | None = None,
|
||||
):
|
||||
"""
|
||||
Initialize the batch runner.
|
||||
@@ -1145,29 +1145,29 @@ class BatchRunner:
|
||||
|
||||
|
||||
def main(
|
||||
dataset_file: str = None,
|
||||
batch_size: int = None,
|
||||
run_name: str = None,
|
||||
dataset_file: str | None = None,
|
||||
batch_size: int | None = None,
|
||||
run_name: str | None = None,
|
||||
distribution: str = "default",
|
||||
model: str = "anthropic/claude-sonnet-4.6",
|
||||
api_key: str = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str = "https://openrouter.ai/api/v1",
|
||||
max_turns: int = 10,
|
||||
num_workers: int = 4,
|
||||
resume: bool = False,
|
||||
verbose: bool = False,
|
||||
list_distributions: bool = False,
|
||||
ephemeral_system_prompt: str = None,
|
||||
ephemeral_system_prompt: str | None = None,
|
||||
log_prefix_chars: int = 100,
|
||||
providers_allowed: str = None,
|
||||
providers_ignored: str = None,
|
||||
providers_order: str = None,
|
||||
provider_sort: str = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_effort: str = None,
|
||||
providers_allowed: str | None = None,
|
||||
providers_ignored: str | None = None,
|
||||
providers_order: str | None = None,
|
||||
provider_sort: str | None = None,
|
||||
max_tokens: int | None = None,
|
||||
reasoning_effort: str | None = None,
|
||||
reasoning_disabled: bool = False,
|
||||
prefill_messages_file: str = None,
|
||||
max_samples: int = None,
|
||||
prefill_messages_file: str | None = None,
|
||||
max_samples: int | None = None,
|
||||
):
|
||||
"""
|
||||
Run batch processing of agent prompts from a dataset.
|
||||
|
||||
@@ -3708,15 +3708,15 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
model: str = None,
|
||||
toolsets: List[str] = None,
|
||||
provider: str = None,
|
||||
api_key: str = None,
|
||||
base_url: str = None,
|
||||
max_turns: int = None,
|
||||
model: str | None = None,
|
||||
toolsets: List[str] | None = None,
|
||||
provider: str | None = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
max_turns: int | None = None,
|
||||
verbose: Optional[bool] = None,
|
||||
compact: bool = False,
|
||||
resume: str = None,
|
||||
resume: str | None = None,
|
||||
checkpoints: bool = False,
|
||||
pass_session_id: bool = False,
|
||||
ignore_rules: bool = False,
|
||||
@@ -15999,23 +15999,23 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
|
||||
|
||||
|
||||
def main(
|
||||
query: str = None,
|
||||
q: str = None,
|
||||
image: str = None,
|
||||
toolsets: str = None,
|
||||
skills: str | list[str] | tuple[str, ...] = None,
|
||||
model: str = None,
|
||||
provider: str = None,
|
||||
api_key: str = None,
|
||||
base_url: str = None,
|
||||
max_turns: int = None,
|
||||
query: str | None = None,
|
||||
q: str | None = None,
|
||||
image: str | None = None,
|
||||
toolsets: str | None = None,
|
||||
skills: str | list[str] | tuple[str, ...] | None = None,
|
||||
model: str | None = None,
|
||||
provider: str | None = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str | None = None,
|
||||
max_turns: int | None = None,
|
||||
verbose: Optional[bool] = None,
|
||||
quiet: bool = False,
|
||||
compact: bool = False,
|
||||
list_tools: bool = False,
|
||||
list_toolsets: bool = False,
|
||||
gateway: bool = False,
|
||||
resume: str = None,
|
||||
resume: str | None = None,
|
||||
worktree: bool = False,
|
||||
w: bool = False,
|
||||
checkpoints: bool = False,
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
# Contributor email → GitHub login mappings
|
||||
|
||||
This directory replaces appending entries to `AUTHOR_MAP` in
|
||||
`scripts/release.py`. The old dict caused constant merge conflicts when
|
||||
several salvage PRs landed at once — every PR edited the same lines of the
|
||||
same file. Here, **each mapping is its own file**, and file additions never
|
||||
conflict.
|
||||
|
||||
## Adding a mapping
|
||||
|
||||
One file per commit-author email, under `emails/`:
|
||||
|
||||
```bash
|
||||
python3 scripts/add_contributor.py <email> <github-login>
|
||||
# or by hand:
|
||||
echo "<github-login>" > contributors/emails/<email>
|
||||
```
|
||||
|
||||
- File **name** = the exact commit-author email (as shown by `git log --format='%ae'`).
|
||||
- File **content** = the GitHub login on the first non-comment line.
|
||||
Lines starting with `#` are comments (use them for the PR reference).
|
||||
|
||||
Example — `contributors/emails/jane.doe@example.com`:
|
||||
|
||||
```
|
||||
janedoe
|
||||
# PR #12345 salvage (gateway: fix session key routing)
|
||||
```
|
||||
|
||||
## Rules
|
||||
|
||||
- Do NOT add new entries to `AUTHOR_MAP` in `scripts/release.py`. That dict
|
||||
is frozen legacy data; the release tooling merges it with this directory
|
||||
(directory entries win on duplicates).
|
||||
- GitHub noreply emails (`<id>+<login>@users.noreply.github.com` and
|
||||
`<login>@users.noreply.github.com`) auto-resolve — no file needed.
|
||||
- The `Contributor Attribution Check` CI job fails a PR whose commits carry
|
||||
an unmapped email; the failure message prints the exact command to run.
|
||||
@@ -1,2 +0,0 @@
|
||||
_placeholder
|
||||
# keeps the directory present in git; not a real mapping
|
||||
@@ -2891,8 +2891,8 @@ class APIServerAdapter(BasePlatformAdapter):
|
||||
|
||||
async def _write_sse_chat_completion(
|
||||
self, request: "web.Request", completion_id: str, model: str,
|
||||
created: int, stream_q, agent_task, agent_ref=None, session_id: str = None,
|
||||
gateway_session_key: str = None,
|
||||
created: int, stream_q, agent_task, agent_ref | None = None, session_id: str = None,
|
||||
gateway_session_key: str | None = None,
|
||||
) -> "web.StreamResponse":
|
||||
"""Write real streaming SSE from agent's stream_delta_callback queue.
|
||||
|
||||
|
||||
+5
-5
@@ -219,7 +219,7 @@ def _gateway_platform_value(platform: Any) -> str:
|
||||
def _non_conversational_metadata(
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
*,
|
||||
platform: Any = None,
|
||||
platform: Any | None = None,
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""Mark Discord lifecycle/status sends without changing other platforms."""
|
||||
if _gateway_platform_value(platform) != "discord":
|
||||
@@ -542,7 +542,7 @@ def _resolve_gateway_display_bool(
|
||||
setting: str,
|
||||
*,
|
||||
default: bool = False,
|
||||
platform: Any = None,
|
||||
platform: Any | None = None,
|
||||
require_platform_override_for: set[Any] | None = None,
|
||||
) -> bool:
|
||||
"""Resolve a boolean display setting with optional platform-only opt-in.
|
||||
@@ -17311,7 +17311,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
history: List[Dict[str, Any]],
|
||||
source: "SessionSource",
|
||||
session_id: str,
|
||||
session_key: str = None,
|
||||
session_key: str | None = None,
|
||||
run_generation: Optional[int] = None,
|
||||
event_message_id: Optional[str] = None,
|
||||
) -> Dict[str, Any]:
|
||||
@@ -17610,7 +17610,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
history: List[Dict[str, Any]],
|
||||
source: SessionSource,
|
||||
session_id: str,
|
||||
session_key: str = None,
|
||||
session_key: str | None = None,
|
||||
run_generation: Optional[int] = None,
|
||||
_interrupt_depth: int = 0,
|
||||
event_message_id: Optional[str] = None,
|
||||
@@ -17760,7 +17760,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
history: List[Dict[str, Any]],
|
||||
source: SessionSource,
|
||||
session_id: str,
|
||||
session_key: str = None,
|
||||
session_key: str | None = None,
|
||||
run_generation: Optional[int] = None,
|
||||
_interrupt_depth: int = 0,
|
||||
event_message_id: Optional[str] = None,
|
||||
|
||||
+6
-6
@@ -2132,8 +2132,8 @@ def _scope_values(raw_scope: Any) -> set[str]:
|
||||
def _nous_invoke_jwt_status(
|
||||
token: Any,
|
||||
*,
|
||||
scope: Any = None,
|
||||
expires_at: Any = None,
|
||||
scope: Any | None = None,
|
||||
expires_at: Any | None = None,
|
||||
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
|
||||
) -> Optional[str]:
|
||||
"""Return None when the token can be used for inference, else a reason."""
|
||||
@@ -2161,8 +2161,8 @@ def _nous_invoke_jwt_status(
|
||||
def _nous_invoke_jwt_is_usable(
|
||||
token: Any,
|
||||
*,
|
||||
scope: Any = None,
|
||||
expires_at: Any = None,
|
||||
scope: Any | None = None,
|
||||
expires_at: Any | None = None,
|
||||
min_ttl_seconds: int = NOUS_INVOKE_JWT_MIN_TTL_SECONDS,
|
||||
) -> bool:
|
||||
return (
|
||||
@@ -2179,7 +2179,7 @@ def _nous_invoke_jwt_is_usable(
|
||||
def _assert_nous_inference_jwt_usable(
|
||||
state: Dict[str, Any],
|
||||
*,
|
||||
access_token: Any = None,
|
||||
access_token: Any | None = None,
|
||||
) -> None:
|
||||
token = state.get("access_token") if access_token is None else access_token
|
||||
reason = _nous_invoke_jwt_status(
|
||||
@@ -2263,7 +2263,7 @@ def _set_nous_agent_key_from_invoke_jwt(
|
||||
def _select_nous_invoke_jwt(
|
||||
state: Dict[str, Any],
|
||||
*,
|
||||
access_token: Any = None,
|
||||
access_token: Any | None = None,
|
||||
sequence_id: Optional[str] = None,
|
||||
) -> None:
|
||||
if isinstance(access_token, str) and access_token.strip():
|
||||
|
||||
@@ -578,12 +578,12 @@ def _display_toolset_name(toolset_name: str) -> str:
|
||||
|
||||
|
||||
def build_welcome_banner(console: "Console", model: str, cwd: str,
|
||||
tools: List[dict] = None,
|
||||
enabled_toolsets: List[str] = None,
|
||||
session_id: str = None,
|
||||
tools: List[dict] | None = None,
|
||||
enabled_toolsets: List[str] | None = None,
|
||||
session_id: str | None = None,
|
||||
get_toolset_for_tool=None,
|
||||
context_length: int = None,
|
||||
provider: str = None):
|
||||
context_length: int | None = None,
|
||||
provider: str | None = None):
|
||||
"""Build and print a welcome banner with caduceus on left and info on right.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -3391,7 +3391,7 @@ def _has_sticky_block(conn: sqlite3.Connection, task_id: str) -> bool:
|
||||
|
||||
|
||||
def recompute_ready(
|
||||
conn: sqlite3.Connection, failure_limit: int = None,
|
||||
conn: sqlite3.Connection, failure_limit: int | None = None,
|
||||
) -> int:
|
||||
"""Promote ``todo`` tasks to ``ready`` when all parents are ``done`` or ``archived``.
|
||||
|
||||
@@ -7025,7 +7025,7 @@ def _record_task_failure(
|
||||
error: str,
|
||||
*,
|
||||
outcome: str,
|
||||
failure_limit: int = None,
|
||||
failure_limit: int | None = None,
|
||||
force_trip: bool = False,
|
||||
release_claim: bool = False,
|
||||
end_run: bool = False,
|
||||
@@ -7190,7 +7190,7 @@ def _record_spawn_failure(
|
||||
task_id: str,
|
||||
error: str,
|
||||
*,
|
||||
failure_limit: int = None,
|
||||
failure_limit: int | None = None,
|
||||
) -> bool:
|
||||
return _record_task_failure(
|
||||
conn, task_id, error,
|
||||
|
||||
+2
-2
@@ -2008,8 +2008,8 @@ def _launch_tui(
|
||||
tui_dev: bool = False,
|
||||
model: Optional[str] = None,
|
||||
provider: Optional[str] = None,
|
||||
toolsets: object = None,
|
||||
skills: object = None,
|
||||
toolsets: object | None = None,
|
||||
skills: object | None = None,
|
||||
verbose: Optional[bool] = None,
|
||||
quiet: bool = False,
|
||||
query: Optional[str] = None,
|
||||
|
||||
@@ -295,13 +295,7 @@ def resolve_moa_preset(config: Any, name: str | None = None) -> dict[str, Any]:
|
||||
preset_name = str(name or cfg.get("default_preset") or DEFAULT_MOA_PRESET_NAME).strip()
|
||||
preset = cfg["presets"].get(preset_name)
|
||||
if preset is None:
|
||||
from agent.errors import MoAPresetNotFoundError
|
||||
|
||||
available = ", ".join(cfg["presets"]) or "(none)"
|
||||
raise MoAPresetNotFoundError(
|
||||
f"MoA preset '{preset_name}' was not found. Available presets: "
|
||||
f"{available}. Run `hermes moa list`."
|
||||
)
|
||||
raise KeyError(preset_name)
|
||||
return deepcopy(preset)
|
||||
|
||||
|
||||
|
||||
@@ -638,7 +638,7 @@ def resolve_alias(
|
||||
|
||||
def get_authenticated_provider_slugs(
|
||||
current_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
user_providers: dict | None = None,
|
||||
custom_providers: list | None = None,
|
||||
) -> list[str]:
|
||||
"""Return slugs of providers that have credentials.
|
||||
@@ -801,7 +801,7 @@ def switch_model(
|
||||
current_api_key: str = "",
|
||||
is_global: bool = False,
|
||||
explicit_provider: str = "",
|
||||
user_providers: dict = None,
|
||||
user_providers: dict | None = None,
|
||||
custom_providers: list | None = None,
|
||||
) -> ModelSwitchResult:
|
||||
"""Core model-switching pipeline shared between CLI and gateway.
|
||||
@@ -1471,7 +1471,7 @@ def prewarm_picker_cache_async() -> Optional["_threading.Thread"]:
|
||||
def list_authenticated_providers(
|
||||
current_provider: str = "",
|
||||
current_base_url: str = "",
|
||||
user_providers: dict = None,
|
||||
user_providers: dict | None = None,
|
||||
custom_providers: list | None = None,
|
||||
*,
|
||||
force_fresh_nous_tier: bool = False,
|
||||
@@ -2438,7 +2438,7 @@ def _prepend_moa_picker_provider(providers: List[dict], current_provider: str =
|
||||
def list_picker_providers(
|
||||
current_provider: str = "",
|
||||
current_base_url: str = "",
|
||||
user_providers: dict = None,
|
||||
user_providers: dict | None = None,
|
||||
custom_providers: list | None = None,
|
||||
max_models: int | None = None,
|
||||
current_model: str = "",
|
||||
|
||||
@@ -4042,9 +4042,9 @@ def get_sessions(
|
||||
min_messages: int = 0,
|
||||
archived: str = "exclude",
|
||||
order: str = "created",
|
||||
source: str = None,
|
||||
exclude_sources: str = None,
|
||||
cwd_prefix: str = None,
|
||||
source: str | None = None,
|
||||
exclude_sources: str | None = None,
|
||||
cwd_prefix: str | None = None,
|
||||
full: bool = False,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
@@ -4142,8 +4142,8 @@ def get_profiles_sessions(
|
||||
archived: str = "exclude",
|
||||
order: str = "recent",
|
||||
profile: str = "all",
|
||||
source: str = None,
|
||||
exclude_sources: str = None,
|
||||
source: str | None = None,
|
||||
exclude_sources: str | None = None,
|
||||
full: bool = False,
|
||||
):
|
||||
"""Unified, read-only session list aggregated across ALL profiles.
|
||||
|
||||
+49
-49
@@ -1928,17 +1928,17 @@ class SessionDB:
|
||||
self,
|
||||
session_id: str,
|
||||
source: str,
|
||||
model: str = None,
|
||||
model_config: Dict[str, Any] = None,
|
||||
system_prompt: str = None,
|
||||
user_id: str = None,
|
||||
session_key: str = None,
|
||||
chat_id: str = None,
|
||||
chat_type: str = None,
|
||||
thread_id: str = None,
|
||||
parent_session_id: str = None,
|
||||
cwd: str = None,
|
||||
profile_name: str = None,
|
||||
model: str | None = None,
|
||||
model_config: Dict[str, Any] | None = None,
|
||||
system_prompt: str | None = None,
|
||||
user_id: str | None = None,
|
||||
session_key: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
chat_type: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
parent_session_id: str | None = None,
|
||||
cwd: str | None = None,
|
||||
profile_name: str | None = None,
|
||||
) -> None:
|
||||
"""Insert a session row, enriching NULL metadata on conflict.
|
||||
|
||||
@@ -2005,13 +2005,13 @@ class SessionDB:
|
||||
session_id: str,
|
||||
*,
|
||||
source: str,
|
||||
user_id: str = None,
|
||||
session_key: str = None,
|
||||
chat_id: str = None,
|
||||
chat_type: str = None,
|
||||
thread_id: str = None,
|
||||
display_name: str = None,
|
||||
origin_json: str = None,
|
||||
user_id: str | None = None,
|
||||
session_key: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
chat_type: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
display_name: str | None = None,
|
||||
origin_json: str | None = None,
|
||||
) -> None:
|
||||
"""Persist the gateway routing peer for an existing session row.
|
||||
|
||||
@@ -2830,7 +2830,7 @@ class SessionDB:
|
||||
session_id: str,
|
||||
input_tokens: int = 0,
|
||||
output_tokens: int = 0,
|
||||
model: str = None,
|
||||
model: str | None = None,
|
||||
cache_read_tokens: int = 0,
|
||||
cache_write_tokens: int = 0,
|
||||
reasoning_tokens: int = 0,
|
||||
@@ -3100,7 +3100,7 @@ class SessionDB:
|
||||
self,
|
||||
session_id: str,
|
||||
source: str = "unknown",
|
||||
model: str = None,
|
||||
model: str | None = None,
|
||||
**kwargs,
|
||||
) -> str:
|
||||
"""Ensure a session row exists (INSERT OR IGNORE). Accepts optional kwargs."""
|
||||
@@ -3678,9 +3678,9 @@ class SessionDB:
|
||||
|
||||
def list_sessions_rich(
|
||||
self,
|
||||
source: str = None,
|
||||
exclude_sources: List[str] = None,
|
||||
cwd_prefix: str = None,
|
||||
source: str | None = None,
|
||||
exclude_sources: List[str] | None = None,
|
||||
cwd_prefix: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
include_children: bool = False,
|
||||
@@ -3689,8 +3689,8 @@ class SessionDB:
|
||||
order_by_last_active: bool = False,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
id_query: str = None,
|
||||
search_query: str = None,
|
||||
id_query: str | None = None,
|
||||
search_query: str | None = None,
|
||||
compact_rows: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""List sessions with preview (first user message) and last active timestamp.
|
||||
@@ -4123,21 +4123,21 @@ class SessionDB:
|
||||
self,
|
||||
session_id: str,
|
||||
role: str,
|
||||
content: str = None,
|
||||
tool_name: str = None,
|
||||
tool_calls: Any = None,
|
||||
tool_call_id: str = None,
|
||||
token_count: int = None,
|
||||
finish_reason: str = None,
|
||||
reasoning: str = None,
|
||||
reasoning_content: str = None,
|
||||
reasoning_details: Any = None,
|
||||
codex_reasoning_items: Any = None,
|
||||
codex_message_items: Any = None,
|
||||
platform_message_id: str = None,
|
||||
content: str | None = None,
|
||||
tool_name: str | None = None,
|
||||
tool_calls: Any | None = None,
|
||||
tool_call_id: str | None = None,
|
||||
token_count: int | None = None,
|
||||
finish_reason: str | None = None,
|
||||
reasoning: str | None = None,
|
||||
reasoning_content: str | None = None,
|
||||
reasoning_details: Any | None = None,
|
||||
codex_reasoning_items: Any | None = None,
|
||||
codex_message_items: Any | None = None,
|
||||
platform_message_id: str | None = None,
|
||||
observed: bool = False,
|
||||
effect_disposition: Optional[str] = None,
|
||||
timestamp: Any = None,
|
||||
timestamp: Any | None = None,
|
||||
) -> int:
|
||||
"""
|
||||
Append a message to a session. Returns the message row ID.
|
||||
@@ -5238,12 +5238,12 @@ class SessionDB:
|
||||
def search_messages(
|
||||
self,
|
||||
query: str,
|
||||
source_filter: List[str] = None,
|
||||
exclude_sources: List[str] = None,
|
||||
role_filter: List[str] = None,
|
||||
source_filter: List[str] | None = None,
|
||||
exclude_sources: List[str] | None = None,
|
||||
role_filter: List[str] | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
sort: str = None,
|
||||
sort: str | None = None,
|
||||
include_inactive: bool = False,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
@@ -5605,7 +5605,7 @@ class SessionDB:
|
||||
|
||||
def search_sessions(
|
||||
self,
|
||||
source: str = None,
|
||||
source: str | None = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0,
|
||||
) -> List[Dict[str, Any]]:
|
||||
@@ -5645,13 +5645,13 @@ class SessionDB:
|
||||
|
||||
def session_count(
|
||||
self,
|
||||
source: str = None,
|
||||
cwd_prefix: str = None,
|
||||
source: str | None = None,
|
||||
cwd_prefix: str | None = None,
|
||||
min_message_count: int = 0,
|
||||
include_archived: bool = False,
|
||||
archived_only: bool = False,
|
||||
exclude_children: bool = False,
|
||||
exclude_sources: List[str] = None,
|
||||
exclude_sources: List[str] | None = None,
|
||||
) -> int:
|
||||
"""Count sessions, optionally filtered by source.
|
||||
|
||||
@@ -6653,7 +6653,7 @@ class SessionDB:
|
||||
def list_prune_candidates(
|
||||
self,
|
||||
older_than_days: Optional[float] = None,
|
||||
source: str = None,
|
||||
source: str | None = None,
|
||||
**filters,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Return the sessions a matching :meth:`prune_sessions` /
|
||||
@@ -6681,7 +6681,7 @@ class SessionDB:
|
||||
def archive_sessions(
|
||||
self,
|
||||
older_than_days: Optional[float] = None,
|
||||
source: str = None,
|
||||
source: str | None = None,
|
||||
**filters,
|
||||
) -> int:
|
||||
"""Bulk-archive (soft-hide) every session matching the filters.
|
||||
@@ -6707,7 +6707,7 @@ class SessionDB:
|
||||
def prune_sessions(
|
||||
self,
|
||||
older_than_days: Optional[float] = 90,
|
||||
source: str = None,
|
||||
source: str | None = None,
|
||||
sessions_dir: Optional[Path] = None,
|
||||
**filters,
|
||||
) -> int:
|
||||
|
||||
+6
-6
@@ -163,8 +163,8 @@ class MiniSWERunner:
|
||||
def __init__(
|
||||
self,
|
||||
model: str = "anthropic/claude-sonnet-4.6",
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
env_type: str = "local",
|
||||
image: str = "python:3.11-slim",
|
||||
cwd: str = "/tmp",
|
||||
@@ -628,12 +628,12 @@ Complete the user's task step by step."""
|
||||
# ============================================================================
|
||||
|
||||
def main(
|
||||
task: str = None,
|
||||
prompts_file: str = None,
|
||||
task: str | None = None,
|
||||
prompts_file: str | None = None,
|
||||
output_file: str = "swe-runner-test1.jsonl",
|
||||
model: str = "claude-sonnet-4-20250514",
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
env: str = "local",
|
||||
image: str = "python:3.11-slim",
|
||||
cwd: str = "/tmp",
|
||||
|
||||
+25
-8
@@ -9,6 +9,7 @@
|
||||
stdenv,
|
||||
makeWrapper,
|
||||
callPackage,
|
||||
python311,
|
||||
python312,
|
||||
nodejs_22,
|
||||
electron,
|
||||
@@ -37,15 +38,28 @@
|
||||
}:
|
||||
let
|
||||
nodejs = nodejs_22;
|
||||
|
||||
mkHermesVenv =
|
||||
extraDependencyGroups:
|
||||
{
|
||||
extraDependencyGroups,
|
||||
python ? null,
|
||||
}:
|
||||
callPackage ./python.nix {
|
||||
inherit uv2nix pyproject-nix pyproject-build-systems;
|
||||
inherit
|
||||
uv2nix
|
||||
pyproject-nix
|
||||
pyproject-build-systems
|
||||
python
|
||||
;
|
||||
pythonSrc = hermesNpmLib.pythonSrc;
|
||||
dependency-groups = [ "all" ] ++ extraDependencyGroups;
|
||||
};
|
||||
|
||||
hermesVenv = (mkHermesVenv extraDependencyGroups).venv;
|
||||
hermesVenv =
|
||||
(mkHermesVenv {
|
||||
inherit extraDependencyGroups;
|
||||
python = python312;
|
||||
}).venv;
|
||||
|
||||
hermesNpmLib = callPackage ./lib.nix {
|
||||
inherit npm-lockfile-fix nodejs;
|
||||
@@ -61,8 +75,7 @@ let
|
||||
|
||||
bundledSkills = lib.cleanSourceWith {
|
||||
src = ../skills;
|
||||
filter =
|
||||
path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
|
||||
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
# Optional skills are NOT in the wheel (pythonSrc excludes them, see
|
||||
@@ -70,8 +83,7 @@ let
|
||||
# same mechanism Homebrew packaging uses.
|
||||
bundledOptionalSkills = lib.cleanSourceWith {
|
||||
src = ../optional-skills;
|
||||
filter =
|
||||
path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
|
||||
filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path);
|
||||
};
|
||||
|
||||
# Import bundled plugins (memory, context_engine, platforms/*). Keeping
|
||||
@@ -224,8 +236,13 @@ stdenv.mkDerivation (finalAttrs: {
|
||||
'';
|
||||
|
||||
passthru =
|
||||
python:
|
||||
let
|
||||
devPython = (mkHermesVenv (extraDependencyGroups ++ [ "dev" ])).editableVenv;
|
||||
devPython =
|
||||
(mkHermesVenv ({
|
||||
extraDependencyGroups = extraDependencyGroups ++ [ "dev" ];
|
||||
python = python311;
|
||||
})).editableVenv;
|
||||
in
|
||||
{
|
||||
inherit
|
||||
|
||||
+10
-10
@@ -1,6 +1,6 @@
|
||||
# nix/python.nix — uv2nix virtual environment builder
|
||||
{
|
||||
python312,
|
||||
python,
|
||||
lib,
|
||||
callPackage,
|
||||
uv2nix,
|
||||
@@ -65,30 +65,30 @@ let
|
||||
final: _prev:
|
||||
if isAarch64Darwin then
|
||||
{
|
||||
numpy = mkPrebuiltOverride final python312.pkgs.numpy { };
|
||||
numpy = mkPrebuiltOverride final python.pkgs.numpy { };
|
||||
|
||||
pyarrow = mkPrebuiltOverride final python312.pkgs.pyarrow { };
|
||||
pyarrow = mkPrebuiltOverride final python.pkgs.pyarrow { };
|
||||
|
||||
av = mkPrebuiltOverride final python312.pkgs.av { };
|
||||
av = mkPrebuiltOverride final python.pkgs.av { };
|
||||
|
||||
humanfriendly = mkPrebuiltOverride final python312.pkgs.humanfriendly { };
|
||||
humanfriendly = mkPrebuiltOverride final python.pkgs.humanfriendly { };
|
||||
|
||||
coloredlogs = mkPrebuiltOverride final python312.pkgs.coloredlogs {
|
||||
coloredlogs = mkPrebuiltOverride final python.pkgs.coloredlogs {
|
||||
humanfriendly = [ ];
|
||||
};
|
||||
|
||||
onnxruntime = mkPrebuiltOverride final python312.pkgs.onnxruntime {
|
||||
onnxruntime = mkPrebuiltOverride final python.pkgs.onnxruntime {
|
||||
coloredlogs = [ ];
|
||||
numpy = [ ];
|
||||
packaging = [ ];
|
||||
};
|
||||
|
||||
ctranslate2 = mkPrebuiltOverride final python312.pkgs.ctranslate2 {
|
||||
ctranslate2 = mkPrebuiltOverride final python.pkgs.ctranslate2 {
|
||||
numpy = [ ];
|
||||
pyyaml = [ ];
|
||||
};
|
||||
|
||||
faster-whisper = mkPrebuiltOverride final python312.pkgs.faster-whisper {
|
||||
faster-whisper = mkPrebuiltOverride final python.pkgs.faster-whisper {
|
||||
av = [ ];
|
||||
ctranslate2 = [ ];
|
||||
huggingface-hub = [ ];
|
||||
@@ -102,7 +102,7 @@ let
|
||||
|
||||
pythonSet =
|
||||
(callPackage pyproject-nix.build.packages {
|
||||
python = python312;
|
||||
python = python;
|
||||
}).overrideScope
|
||||
(
|
||||
lib.composeManyExtensions [
|
||||
|
||||
@@ -0,0 +1,312 @@
|
||||
# ty type-checking notes — antipatterns & refactors spotted
|
||||
|
||||
working through the codebase root-to-tip with astral.sh `ty`.
|
||||
logging antipatterns, clean refactors, and observations as we go.
|
||||
|
||||
---
|
||||
|
||||
## tools/registry.py (dependency root, 810 lines)
|
||||
|
||||
### invalid-parameter-default (FIXED)
|
||||
three params in `ToolEntry.register()` were annotated as bare `Callable` / `list`
|
||||
but defaulted to `None`. this is the #1 pattern ty flags across the whole codebase
|
||||
(~423 occurrences). the fix is always the same: add `| None` to the annotation.
|
||||
|
||||
```python
|
||||
# BEFORE
|
||||
check_fn: Callable = None,
|
||||
requires_env: list = None,
|
||||
dynamic_schema_overrides: Callable = None,
|
||||
|
||||
# AFTER
|
||||
check_fn: Callable | None = None,
|
||||
requires_env: list | None = None,
|
||||
dynamic_schema_overrides: Callable | None = None,
|
||||
```
|
||||
|
||||
this is the single highest-leverage fix across the codebase. each one of these
|
||||
cascades: when ty sees `None` as a possible value, every downstream `d["key"]`,
|
||||
`d.get(...)`, `d.pop()` on that variable becomes an `unresolved-attribute` or
|
||||
`not-subscriptable` or `invalid-argument-type` error. fixing the parameter
|
||||
default upstream makes all those downstream errors vanish.
|
||||
|
||||
### antipattern: ToolEntry.__init__ has zero annotations
|
||||
|
||||
```python
|
||||
def __init__(self, name, toolset, schema, handler, check_fn,
|
||||
requires_env, is_async, description, emoji,
|
||||
max_result_size_chars=None, dynamic_schema_overrides=None):
|
||||
```
|
||||
|
||||
this is a core data class (every tool in the system passes through it) but has
|
||||
no type annotations on any parameter. `__slots__` is used so the shape is
|
||||
well-defined — adding annotations would be straightforward and high-value.
|
||||
ty can't infer much here because everything comes through as `Unknown`.
|
||||
|
||||
not fixed yet — would benefit from a proper pass:
|
||||
```python
|
||||
def __init__(
|
||||
self,
|
||||
name: str,
|
||||
toolset: str,
|
||||
schema: dict,
|
||||
handler: Callable,
|
||||
check_fn: Callable | None,
|
||||
requires_env: list[str],
|
||||
is_async: bool,
|
||||
description: str,
|
||||
emoji: str,
|
||||
max_result_size_chars: int | float | None = None,
|
||||
dynamic_schema_overrides: Callable | None = None,
|
||||
):
|
||||
```
|
||||
|
||||
### antipattern: tool_error / tool_result helpers lack annotations
|
||||
|
||||
```python
|
||||
def tool_error(message, **extra) -> str: # message: str, **extra: Any
|
||||
def tool_result(data=None, **kwargs) -> str: # data: dict | None, **kwargs: Any
|
||||
```
|
||||
|
||||
these are the canonical serialization helpers used by hundreds of tool handlers.
|
||||
low-effort to annotate, high-value since they're the return path for everything.
|
||||
|
||||
### clean pattern: _check_fn_cached TTL + grace window
|
||||
|
||||
this is well-done code. the TTL cache with transient-failure suppression is
|
||||
a correct implementation of a flaky-external-check absorption pattern. the
|
||||
docstrings explain WHY (issue #21658 / #5304 — flaky docker probes stripping
|
||||
tools mid-session). nothing to change here, just noting it as a positive
|
||||
example of defensive caching done right.
|
||||
|
||||
### clean pattern: _snapshot_state() for thread safety
|
||||
|
||||
using `_lock` + snapshot copies for reads is the right pattern for a registry
|
||||
that can be mutated by MCP dynamic refresh while other threads read. the
|
||||
generation counter for cache invalidation is also clean.
|
||||
|
||||
### observation: `from typing import` vs PEP 604 `X | None`
|
||||
|
||||
the file imports `Optional`, `Callable`, `Dict`, `List`, `Set` from `typing`
|
||||
but also uses `int | float | None` (PEP 604) in the same signatures. the
|
||||
codebase targets python >=3.11 so PEP 604 is always available. there's a
|
||||
mix of `Optional[X]` and `X | None` styles across files — not a bug, but
|
||||
worth standardizing on `X | None` (PEP 604) as we type-sweep since it's
|
||||
shorter and the modern idiom.
|
||||
|
||||
---
|
||||
|
||||
## agent/agent_init.py + run_agent.py (AIAgent constructor, 60+ params)
|
||||
|
||||
### invalid-parameter-default (FIXED — 31 + 54 params)
|
||||
same pattern as registry.py but at massive scale. the AIAgent.__init__ in
|
||||
`agent_init.py` and the class `AIAgent.__init__` in `run_agent.py` each take
|
||||
~60 params, nearly all defaulting to None but annotated as bare `str`, `list`,
|
||||
`dict`, `int`, etc.
|
||||
|
||||
### antipattern: lowercase `callable` used as a type annotation
|
||||
|
||||
**this is a real runtime bug, not just a type error.** 14 callback params in
|
||||
both `agent_init.py` and `run_agent.py` were annotated as `callable` (the
|
||||
builtin *function*) instead of `Callable` (from `typing`).
|
||||
|
||||
```python
|
||||
# BEFORE — runtime crash if you try `X | None`
|
||||
tool_progress_callback: callable = None, # callable is the builtin function
|
||||
|
||||
# AFTER
|
||||
tool_progress_callback: Callable | None = None, # Callable is the type
|
||||
```
|
||||
|
||||
when the automated sweep script added `| None` to these, it produced
|
||||
`callable | None` which crashes at *import time* with:
|
||||
```
|
||||
TypeError: unsupported operand type(s) for |: 'builtin_function_or_method' and 'NoneType'
|
||||
```
|
||||
|
||||
this was lurking silently as long as nobody tried to make the annotation
|
||||
nullable — the `callable` builtin is truthy so `callable = None` didn't
|
||||
crash, it just stored a nonsensical annotation. the fix is `Callable` (capital C).
|
||||
|
||||
also spotted in `agent/transports/chat_completions.py` in docstring-like
|
||||
comments (lines 290, 314-315) — not executable code but worth standardizing.
|
||||
|
||||
### antipattern: string forward-ref with `| None` doesn't work
|
||||
|
||||
```python
|
||||
# WRONG — crashes at class-def time
|
||||
iteration_budget: "IterationBudget" | None = None,
|
||||
# TypeError: unsupported operand type(s) for |: 'str' and 'NoneType'
|
||||
|
||||
# RIGHT — wrap the whole thing in Optional
|
||||
iteration_budget: Optional["IterationBudget"] = None,
|
||||
```
|
||||
|
||||
when a type is a forward reference (string-quoted because it's not yet defined),
|
||||
you CANNOT use PEP 604 `| None` on it directly — the `|` operator tries to
|
||||
OR a `str` with `NoneType` and crashes. must use `Optional["ForwardRef"]`.
|
||||
|
||||
this affected both `agent_init.py` and `run_agent.py`.
|
||||
|
||||
---
|
||||
|
||||
## hermes_state.py (49 params fixed)
|
||||
same `X = None` → `X | None = None` sweep. this is the SQLite session store
|
||||
module — heavily imported by cli.py, run_agent.py, gateway/, etc.
|
||||
|
||||
## batch_runner.py (24 params fixed)
|
||||
parallel batch processing entry point.
|
||||
|
||||
## cli.py (18 params fixed)
|
||||
the HermesCLI class constructor + helper methods.
|
||||
|
||||
## tools/*.py (20+ params fixed across file_tools, file_operations,
|
||||
skills_tool, skills_hub, terminal_tool, browser_tool, memory_tool,
|
||||
delegate_tool, process_registry, skill_manager_tool, session_search_tool)
|
||||
|
||||
---
|
||||
|
||||
## summary of the invalid-parameter-default sweep
|
||||
|
||||
total params fixed: ~250 across ~25 core files
|
||||
the pattern is always identical: `param: Type = None` → `param: Type | None = None`
|
||||
|
||||
two runtime-breaking gotchas found during the sweep:
|
||||
1. lowercase `callable` (builtin function) → must be `Callable` (typing)
|
||||
2. string forward-refs can't use `| None` → must use `Optional["Ref"]`
|
||||
|
||||
---
|
||||
|
||||
## run_agent.py — unresolved-attribute analysis (190 errors)
|
||||
|
||||
### category 1: "Self@<method>" — init_agent() cross-module init (186/190)
|
||||
|
||||
these are ALL the same root cause: `AIAgent.__init__` in `run_agent.py` is a
|
||||
thin forwarder that calls `init_agent(self, ...)` from `agent/agent_init.py`.
|
||||
that function sets `self.model`, `self.provider`, `self.session_id`, etc. on
|
||||
the instance — but ty can't see across the module boundary that these
|
||||
attributes are being set. so every method that accesses `self.model`,
|
||||
`self.provider`, etc. gets flagged.
|
||||
|
||||
**these are NOT bugs.** the attributes are correctly set at runtime by
|
||||
`init_agent()`. this is a ty limitation — it can't track attribute assignments
|
||||
made in a function defined in a different module that receives `self` as a
|
||||
regular parameter (not as a method on the class).
|
||||
|
||||
example:
|
||||
```python
|
||||
# run_agent.py
|
||||
class AIAgent:
|
||||
def __init__(self, model: str = "", ...):
|
||||
from agent.agent_init import init_agent
|
||||
init_agent(self, model=model, ...) # sets self.model, self.provider, etc.
|
||||
|
||||
def _resolved_api_call_timeout(self):
|
||||
return self.provider # ty: "Self@_resolved_api_call_timeout has no attribute provider"
|
||||
```
|
||||
|
||||
**fix approach:** this is the single biggest cluster of ty errors in the
|
||||
codebase (186 in run_agent.py alone, plus similar in other files). the cleanest
|
||||
fix would be to declare the attributes as class-level annotations in the
|
||||
`AIAgent` class body so ty knows they exist:
|
||||
|
||||
```python
|
||||
class AIAgent:
|
||||
# Instance attributes — set by init_agent() in agent/agent_init.py
|
||||
model: str
|
||||
provider: str | None
|
||||
session_id: str | None
|
||||
# ... etc
|
||||
```
|
||||
|
||||
this is a one-time declaration that would clear ~186 errors instantly. it's
|
||||
also good documentation — currently there's no single place that lists all
|
||||
instance attributes; they're scattered across the 60-param init_agent() body.
|
||||
|
||||
### category 2: object.function — duck-typed tool_calls access (2 errors)
|
||||
|
||||
line 1968: `tc.function.name` and `tc.function.arguments` on objects typed as
|
||||
`object`. this is because `msg.tool_calls` is checked via `hasattr` +
|
||||
`isinstance(list)` but the list elements are `object` to ty.
|
||||
|
||||
```python
|
||||
if hasattr(msg, "tool_calls") and isinstance(msg.tool_calls, list) and msg.tool_calls:
|
||||
tool_calls_data = [
|
||||
{"name": tc.function.name, "arguments": tc.function.arguments}
|
||||
for tc in msg.tool_calls
|
||||
]
|
||||
```
|
||||
|
||||
**not a bug** — this is duck-typed access to OpenAI SDK objects. the `hasattr`
|
||||
guard makes it safe at runtime. ty just can't infer the element type of a
|
||||
list accessed via `hasattr`. could be fixed with a `type: ignore` or by
|
||||
narrowing `msg` to a proper type.
|
||||
|
||||
### category 3: ~AlwaysFalsy.on_session_end — duck-typed context_compressor (2 errors)
|
||||
|
||||
lines 3416, 3441: `self.context_compressor.on_session_end(...)` after a
|
||||
`hasattr(self, "context_compressor") and self.context_compressor` guard.
|
||||
|
||||
```python
|
||||
if hasattr(self, "context_compressor") and self.context_compressor:
|
||||
self.context_compressor.on_session_end(self.session_id or "", messages or [])
|
||||
```
|
||||
|
||||
ty narrows `self.context_compressor` to `~AlwaysFalsy` (the truthy branch of
|
||||
the `and`) but doesn't know it has `on_session_end`. **not a bug** — the
|
||||
`hasattr` guard makes this safe. again a type-system limitation with
|
||||
duck-typed access.
|
||||
|
||||
### verdict for run_agent.py: 0 real bugs, 190 type-system limitations
|
||||
|
||||
all 190 are ty being unable to track either cross-module attribute init
|
||||
(186) or duck-typed hasattr-guarded access (4). no logic bugs found.
|
||||
|
||||
### remaining 6 unresolved-attribute (post class-level annotation fix)
|
||||
|
||||
1. **line 2162** (`object.function` ×2): duck-typed access to OpenAI SDK
|
||||
`ChatCompletionMessageToolCall` objects. `hasattr` guard makes it safe.
|
||||
ty can't infer the list element type through `hasattr`. not a bug.
|
||||
|
||||
2. **lines 3586-3587** (`iteration_budget.used` / `.max_total`): `iteration_budget`
|
||||
is `Optional[IterationBudget]` but `init_agent` always sets it to a non-None
|
||||
value (`iteration_budget or IterationBudget(max_iterations)`). the annotation
|
||||
says `| None` but the code guarantees non-None at runtime. not a bug — but
|
||||
the annotation could be tightened to just `IterationBudget` (without Optional).
|
||||
|
||||
3. **lines 4677 + 4799** (`_anthropic_client.close()`): `_anthropic_client` is
|
||||
`Any | None` — could be None if the anthropic path was never taken. but both
|
||||
call sites are wrapped in `try/except Exception: pass`, so even if it IS None,
|
||||
the AttributeError is caught. safe.
|
||||
|
||||
---
|
||||
|
||||
## plugins/platforms/discord/adapter.py — unresolved-attribute analysis (269 errors)
|
||||
|
||||
### root cause: ty resolving the wrong Python environment
|
||||
|
||||
the `.venv` directory (created by `uv run` earlier) contained Python 3.13 with
|
||||
NO dependencies installed. ty auto-discovers `.venv` and uses it for module
|
||||
resolution — so it couldn't find `discord.py`, `aiohttp`, etc., producing 254
|
||||
`Module 'discord' has no member X` errors.
|
||||
|
||||
the actual dependencies are installed in the nix develop environment
|
||||
(Python 3.12, all deps in site-packages). removing the empty `.venv` made ty
|
||||
fall back to the system/nix python, resolving all 254 import errors instantly.
|
||||
|
||||
**fix:** `rm -rf .venv` (it was a stray artifact, not a real venv).
|
||||
also set `python-version = "3.12"` in `[tool.ty.environment]` to match the
|
||||
nix env, with a comment explaining why.
|
||||
|
||||
**impact:** 4,422 → 3,385 diagnostics (−1,037) from this single fix.
|
||||
|
||||
### remaining 122 discord errors (post-fix)
|
||||
|
||||
after ty could resolve discord.py:
|
||||
- 17 `not defined on None` — `self._client` (Optional[commands.Bot]) accessed
|
||||
before ty can prove it's non-None. all are in event handlers called after
|
||||
the bot is ready. not bugs, but could benefit from `assert self._client is not None`
|
||||
guards for type narrowing.
|
||||
- ~100 remaining are real type-checking errors from ty now being able to
|
||||
see discord.py's actual types (mismatched args, wrong attribute access, etc.)
|
||||
these need individual triage.
|
||||
@@ -92,12 +92,12 @@ class EvidenceStore:
|
||||
source: str,
|
||||
content: str,
|
||||
evidence_type: str,
|
||||
actor: str = None,
|
||||
url: str = None,
|
||||
timestamp: str = None,
|
||||
ioc_type: str = None,
|
||||
actor: str | None = None,
|
||||
url: str | None = None,
|
||||
timestamp: str | None = None,
|
||||
ioc_type: str | None = None,
|
||||
verification: str = "unverified",
|
||||
notes: str = None,
|
||||
notes: str | None = None,
|
||||
) -> str:
|
||||
evidence_id = self._next_id()
|
||||
entry = {
|
||||
|
||||
@@ -856,75 +856,46 @@ class HonchoClientConfig:
|
||||
|
||||
_honcho_client_slot: SingletonSlot = SingletonSlot()
|
||||
_cached_timeout: float | None = None
|
||||
# Memo for the honcho.json-derived timeout, keyed on the file's mtime_ns so
|
||||
# Memo for the config.yaml-derived timeout, keyed on the file's mtime_ns so
|
||||
# the staleness check on every get_honcho_client() call costs one stat()
|
||||
# instead of a JSON parse. mtime -1 = file absent; (None, None) = not yet
|
||||
# populated. config.yaml needs no such memo: load_config_readonly() is
|
||||
# internally cached on both the user and managed files' signatures, and a
|
||||
# bespoke key here would have to duplicate that invalidation logic.
|
||||
_honcho_json_timeout_memo: tuple[int | None, float | None] = (None, None)
|
||||
# instead of a full YAML load. (None, None) = not yet populated.
|
||||
_config_timeout_memo: tuple[int | None, float | None] = (None, None)
|
||||
|
||||
|
||||
def _config_yaml_timeout() -> float | None:
|
||||
"""Read honcho.timeout / honcho.request_timeout via the cached config loader."""
|
||||
"""Read honcho.timeout / honcho.request_timeout from config.yaml, memoized on mtime."""
|
||||
global _config_timeout_memo
|
||||
try:
|
||||
from hermes_cli.config import load_config_readonly
|
||||
from hermes_constants import get_hermes_home
|
||||
|
||||
honcho_cfg = load_config_readonly().get("honcho", {})
|
||||
cfg_path = get_hermes_home() / "config.yaml"
|
||||
try:
|
||||
mtime_ns: int | None = cfg_path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mtime_ns = None
|
||||
if _config_timeout_memo[0] is not None and _config_timeout_memo[0] == mtime_ns:
|
||||
return _config_timeout_memo[1]
|
||||
|
||||
from hermes_cli.config import load_config
|
||||
|
||||
honcho_cfg = load_config().get("honcho", {})
|
||||
timeout = None
|
||||
if isinstance(honcho_cfg, dict):
|
||||
return _resolve_optional_float(
|
||||
timeout = _resolve_optional_float(
|
||||
honcho_cfg.get("timeout"),
|
||||
honcho_cfg.get("request_timeout"),
|
||||
)
|
||||
return None
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _honcho_json_timeout() -> float | None:
|
||||
"""Read timeout/requestTimeout from honcho.json (host block wins), memoized on mtime."""
|
||||
global _honcho_json_timeout_memo
|
||||
try:
|
||||
path = resolve_config_path()
|
||||
try:
|
||||
mtime_ns: int = path.stat().st_mtime_ns
|
||||
except OSError:
|
||||
mtime_ns = -1
|
||||
if _honcho_json_timeout_memo[0] == mtime_ns:
|
||||
return _honcho_json_timeout_memo[1]
|
||||
|
||||
timeout = None
|
||||
if mtime_ns != -1:
|
||||
raw = json.loads(path.read_text(encoding="utf-8"))
|
||||
host_block = _host_block(raw, resolve_active_host())
|
||||
timeout = _resolve_optional_float(
|
||||
host_block.get("timeout"),
|
||||
host_block.get("requestTimeout"),
|
||||
raw.get("timeout"),
|
||||
raw.get("requestTimeout"),
|
||||
)
|
||||
_honcho_json_timeout_memo = (mtime_ns, timeout)
|
||||
_config_timeout_memo = (mtime_ns, timeout)
|
||||
return timeout
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _resolve_timeout_from_sources(config: HonchoClientConfig | None) -> float:
|
||||
"""Mirror the build path's timeout resolution so the staleness check agrees with it.
|
||||
|
||||
With an explicit config this matches ``_build`` (config.timeout, then
|
||||
config.yaml, then default). With no config it matches what
|
||||
``from_global_config`` + ``_build`` would produce: honcho.json host
|
||||
block/root keys, then HONCHO_TIMEOUT, then config.yaml, then default.
|
||||
Any source skew here makes the check disagree with the built client
|
||||
forever and rebuild it on every call.
|
||||
"""
|
||||
if config is not None:
|
||||
timeout = config.timeout
|
||||
else:
|
||||
timeout = _honcho_json_timeout()
|
||||
if timeout is None:
|
||||
timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT"))
|
||||
"""Resolve the effective timeout from env, config.yaml, and the explicit config."""
|
||||
timeout = config.timeout if config is not None else None
|
||||
if timeout is None:
|
||||
timeout = _resolve_optional_float(os.environ.get("HONCHO_TIMEOUT"))
|
||||
if timeout is None:
|
||||
timeout = _config_yaml_timeout()
|
||||
return timeout if timeout is not None else _DEFAULT_HTTP_TIMEOUT
|
||||
@@ -1106,7 +1077,7 @@ def get_honcho_client(config: HonchoClientConfig | None = None) -> Honcho:
|
||||
|
||||
def reset_honcho_client() -> None:
|
||||
"""Reset the Honcho client singleton (useful for testing)."""
|
||||
global _cached_timeout, _honcho_json_timeout_memo
|
||||
global _cached_timeout, _config_timeout_memo
|
||||
_honcho_client_slot.reset()
|
||||
_cached_timeout = None
|
||||
_honcho_json_timeout_memo = (None, None)
|
||||
_config_timeout_memo = (None, None)
|
||||
|
||||
@@ -160,23 +160,6 @@ class OSSBackend(Mem0Backend):
|
||||
import os
|
||||
from mem0 import Memory
|
||||
|
||||
def _provider_block(name: str) -> dict:
|
||||
block = dict(oss_config[name])
|
||||
provider = str(block.get("provider") or "").strip().lower()
|
||||
provider_config = dict(block.get("config", {}))
|
||||
legacy_base = provider_config.pop("api_base", None)
|
||||
if legacy_base:
|
||||
from ._oss_providers import EMBEDDER_PROVIDERS, LLM_PROVIDERS
|
||||
|
||||
provider_def = (
|
||||
LLM_PROVIDERS if name == "llm" else EMBEDDER_PROVIDERS
|
||||
).get(provider, {})
|
||||
canonical_key = provider_def.get("base_url_key")
|
||||
if canonical_key:
|
||||
provider_config.setdefault(canonical_key, legacy_base)
|
||||
block["config"] = provider_config
|
||||
return block
|
||||
|
||||
vector_store = dict(oss_config["vector_store"])
|
||||
vs_config = dict(vector_store.get("config", {}))
|
||||
|
||||
@@ -199,8 +182,8 @@ class OSSBackend(Mem0Backend):
|
||||
|
||||
config = {
|
||||
"vector_store": vector_store,
|
||||
"llm": _provider_block("llm"),
|
||||
"embedder": _provider_block("embedder"),
|
||||
"llm": oss_config["llm"],
|
||||
"embedder": oss_config["embedder"],
|
||||
"version": "v1.1",
|
||||
}
|
||||
self._memory = Memory.from_config(config)
|
||||
|
||||
@@ -11,14 +11,12 @@ LLM_PROVIDERS: dict[str, dict[str, Any]] = {
|
||||
"needs_key": True,
|
||||
"env_var": "OPENAI_API_KEY",
|
||||
"default_model": "gpt-5-mini",
|
||||
"base_url_key": "openai_base_url",
|
||||
},
|
||||
"ollama": {
|
||||
"label": "Ollama (local)",
|
||||
"needs_key": False,
|
||||
"default_model": "llama3.1:8b",
|
||||
"default_url": "http://localhost:11434",
|
||||
"base_url_key": "ollama_base_url",
|
||||
"pip_dep": "ollama",
|
||||
},
|
||||
}
|
||||
@@ -29,7 +27,6 @@ EMBEDDER_PROVIDERS: dict[str, dict[str, Any]] = {
|
||||
"needs_key": True,
|
||||
"env_var": "OPENAI_API_KEY",
|
||||
"default_model": "text-embedding-3-small",
|
||||
"base_url_key": "openai_base_url",
|
||||
"dims": 1536,
|
||||
},
|
||||
"ollama": {
|
||||
@@ -37,7 +34,6 @@ EMBEDDER_PROVIDERS: dict[str, dict[str, Any]] = {
|
||||
"needs_key": False,
|
||||
"default_model": "nomic-embed-text",
|
||||
"default_url": "http://localhost:11434",
|
||||
"base_url_key": "ollama_base_url",
|
||||
"dims": 768,
|
||||
"pip_dep": "ollama",
|
||||
},
|
||||
|
||||
@@ -135,17 +135,15 @@ def build_oss_config(flags: dict[str, str]) -> tuple[dict, dict[str, str]]:
|
||||
llm_def = LLM_PROVIDERS[llm_id]
|
||||
llm_model = flags.get("oss_llm_model") or llm_def["default_model"]
|
||||
llm_config: dict[str, Any] = {"model": llm_model}
|
||||
llm_url = flags.get("oss_llm_url") or llm_def.get("default_url")
|
||||
if llm_url and llm_def.get("base_url_key"):
|
||||
llm_config[llm_def["base_url_key"]] = llm_url
|
||||
if "default_url" in llm_def:
|
||||
llm_config["ollama_base_url"] = flags.get("oss_llm_url") or llm_def["default_url"]
|
||||
|
||||
embedder_id = flags.get("oss_embedder", "openai")
|
||||
embedder_def = EMBEDDER_PROVIDERS[embedder_id]
|
||||
embedder_model = flags.get("oss_embedder_model") or embedder_def["default_model"]
|
||||
embedder_config: dict[str, Any] = {"model": embedder_model}
|
||||
embedder_url = flags.get("oss_embedder_url") or embedder_def.get("default_url")
|
||||
if embedder_url and embedder_def.get("base_url_key"):
|
||||
embedder_config[embedder_def["base_url_key"]] = embedder_url
|
||||
if "default_url" in embedder_def:
|
||||
embedder_config["ollama_base_url"] = flags.get("oss_embedder_url") or embedder_def["default_url"]
|
||||
dims = KNOWN_DIMS.get(embedder_model)
|
||||
if dims:
|
||||
embedder_config["embedding_dims"] = dims
|
||||
|
||||
+7
-2
@@ -361,18 +361,23 @@ testpaths = ["tests"]
|
||||
markers = [
|
||||
"integration: marks tests requiring external services (API keys, Modal, etc.)",
|
||||
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
|
||||
"real_agent_prewarm: opt out of the autouse stub that disables the tui_gateway deferred agent pre-warm timer",
|
||||
]
|
||||
# integration tests take way too long to run in the normal CI environments
|
||||
addopts = "-m 'not integration'"
|
||||
|
||||
[tool.ty.environment]
|
||||
python-version = "3.13"
|
||||
# Match requires-python floor (>=3.11).
|
||||
python-version = "3.11"
|
||||
|
||||
[tool.ty.rules]
|
||||
unknown-argument = "warn"
|
||||
redundant-cast = "ignore"
|
||||
|
||||
# Exclude tests from type-checking — they're the lowest-value typing target
|
||||
# (~57% of diagnostics) and we want to focus ty on the core codebase.
|
||||
[tool.ty.src]
|
||||
exclude = ["tests/"]
|
||||
|
||||
[tool.ruff]
|
||||
preview = true # required for PLW1514 (unspecified-encoding) — preview rule
|
||||
|
||||
|
||||
+248
-54
@@ -400,6 +400,200 @@ class AIAgent:
|
||||
for AI models that support function calling.
|
||||
"""
|
||||
|
||||
# -----------------------------------------------------------------------
|
||||
# Instance attributes — set by init_agent() in agent/agent_init.py.
|
||||
# Ideally, we could refactor this class into smaller parts so that
|
||||
# we don't need to split its __init__ into a separate file.
|
||||
# Declared here so type checkers know they exist (init_agent receives
|
||||
# the instance as a plain `agent` parameter, not as a method receiver,
|
||||
# so cross-module attribute assignment is invisible to static analysis).
|
||||
# -----------------------------------------------------------------------
|
||||
# scalars
|
||||
_anthropic_api_key: str
|
||||
_anthropic_base_url: str | None
|
||||
_budget_exhausted_injected: bool
|
||||
_budget_grace_call: bool
|
||||
_cache_ttl: str
|
||||
_cached_system_prompt: str | None
|
||||
_chat_id: str | None
|
||||
_chat_name: str | None
|
||||
_chat_type: str | None
|
||||
_client_kwargs: dict
|
||||
_codex_reasoning_replay_enabled: bool
|
||||
_compression_feasibility_checked: bool
|
||||
_credits_latch: dict
|
||||
_current_streamed_assistant_text: str
|
||||
_delegate_depth: int
|
||||
_end_session_on_close: bool
|
||||
_executing_tools: bool
|
||||
_fallback_activated: bool
|
||||
_fallback_chain: list
|
||||
_fallback_index: int
|
||||
_force_ascii_payload: bool
|
||||
_interrupt_requested: bool
|
||||
_interrupt_thread_signal_pending: bool
|
||||
_is_anthropic_oauth: bool
|
||||
_is_user_initiated_turn: bool
|
||||
_iters_since_skill: int
|
||||
_last_flushed_db_idx: int
|
||||
_memory_enabled: bool
|
||||
_memory_nudge_interval: int
|
||||
_memory_write_context: str
|
||||
_memory_write_origin: str
|
||||
_parent_session_id: str | None
|
||||
_persist_disabled: bool
|
||||
_primary_runtime: dict
|
||||
_session_db_created: bool
|
||||
_session_init_model_config: dict
|
||||
_session_json_enabled: bool
|
||||
_skill_nudge_interval: int
|
||||
_skip_mcp_refresh: bool
|
||||
_stream_needs_break: bool
|
||||
_stream_writer_dropped: int
|
||||
_stream_writer_token: int
|
||||
_thread_id: str | None
|
||||
_tool_snapshot_generation: int
|
||||
_turns_since_memory: int
|
||||
_user_name: str | None
|
||||
_user_profile_enabled: bool
|
||||
_user_turn_count: int
|
||||
acp_command: str | None
|
||||
api_key: str | None
|
||||
api_mode: str | None
|
||||
ephemeral_system_prompt: str | None
|
||||
lmstudio_load_mode: str
|
||||
load_soul_identity: bool
|
||||
log_prefix: str
|
||||
log_prefix_chars: int
|
||||
max_iterations: int
|
||||
max_tokens: int | None
|
||||
memory_notifications: str
|
||||
model: str
|
||||
pass_session_id: bool
|
||||
platform: str | None
|
||||
provider: str | None
|
||||
provider_data_collection: str | None
|
||||
provider_require_parameters: bool
|
||||
provider_sort: str | None
|
||||
quiet_mode: bool
|
||||
save_trajectories: bool
|
||||
service_tier: str | None
|
||||
session_api_calls: int
|
||||
session_cache_read_tokens: int
|
||||
session_cache_write_tokens: int
|
||||
session_completion_tokens: int
|
||||
session_cost_source: str
|
||||
session_cost_status: str
|
||||
session_estimated_cost_usd: float
|
||||
session_id: str | None
|
||||
session_input_tokens: int
|
||||
session_output_tokens: int
|
||||
session_prompt_tokens: int
|
||||
session_reasoning_tokens: int
|
||||
session_total_tokens: int
|
||||
show_commentary: bool
|
||||
skip_context_files: bool
|
||||
suppress_status_output: bool
|
||||
tool_delay: float
|
||||
tool_progress_mode: str
|
||||
verbose_logging: bool
|
||||
|
||||
# callbacks
|
||||
clarify_callback: Callable | None
|
||||
event_callback: Optional[Callable[[str, dict], None]]
|
||||
interim_assistant_callback: Callable | None
|
||||
notice_callback: Callable | None
|
||||
notice_clear_callback: Callable | None
|
||||
reaction_callback: Optional[Callable[[str], None]]
|
||||
read_terminal_callback: Callable | None
|
||||
reasoning_callback: Callable | None
|
||||
status_callback: Callable | None
|
||||
step_callback: Callable | None
|
||||
stream_delta_callback: Callable | None
|
||||
thinking_callback: Callable | None
|
||||
tool_complete_callback: Callable | None
|
||||
tool_gen_callback: Callable | None
|
||||
tool_progress_callback: Callable | None
|
||||
tool_start_callback: Callable | None
|
||||
|
||||
# collections
|
||||
acp_args: list[str] | None
|
||||
disabled_toolsets: List[str] | None
|
||||
enabled_toolsets: List[str] | None
|
||||
prefill_messages: List[Dict[str, Any]] | None
|
||||
providers_allowed: List[str] | None
|
||||
providers_ignored: List[str] | None
|
||||
providers_order: List[str] | None
|
||||
reasoning_config: Dict[str, Any] | None
|
||||
request_overrides: Dict[str, Any] | None
|
||||
|
||||
# internal / runtime state
|
||||
_active_children: list
|
||||
_active_children_lock: Any
|
||||
_anthropic_client: Any | None
|
||||
_anthropic_image_fallback_cache: Any
|
||||
_api_max_retries: Any
|
||||
_aux_compression_context_length_config: Any | None
|
||||
_base_url_hostname: Any
|
||||
_bedrock_guardrail_config: Any | None
|
||||
_bedrock_region: Any
|
||||
_checkpoint_mgr: Any
|
||||
_client_lock: Any
|
||||
_compression_threshold_autoraised: Any | None
|
||||
_compression_warning: Any | None
|
||||
_config_context_length: Any
|
||||
_credential_pool: Any
|
||||
_credits_session_start_micros: Any | None
|
||||
_credits_state: Any | None
|
||||
_custom_providers: Any
|
||||
_environment_probe: Any
|
||||
_execution_thread_id: Any
|
||||
_fallback_model: Any
|
||||
_gateway_session_key: Any
|
||||
_intent_ack_continuation: Any
|
||||
_interrupt_message: Any
|
||||
_kanban_worker_guidance: Any
|
||||
_memory_manager: Any | None
|
||||
_memory_store: Any | None
|
||||
_ollama_num_ctx: Any
|
||||
_parallel_tool_call_guidance: Any
|
||||
_pending_cli_user_message: Any | None
|
||||
_pending_steer_lock: Any
|
||||
_persist_user_message_idx: Any | None
|
||||
_persist_user_message_override: Any | None
|
||||
_persist_user_message_timestamp: Any | None
|
||||
_platform_hint_overrides: Any
|
||||
_print_fn: Any | None
|
||||
_session_db: Any
|
||||
_session_persist_lock: Any
|
||||
_stream_callback: Any | None
|
||||
_stream_context_scrubber: Any
|
||||
_stream_think_scrubber: Any
|
||||
_stream_writer_lock: Any
|
||||
_stream_writer_tls: Any
|
||||
_subdirectory_hints: Any
|
||||
_task_completion_guidance: Any
|
||||
_todo_store: Any
|
||||
_tool_guardrails: Any
|
||||
_tool_use_enforcement: Any
|
||||
_tool_worker_threads_lock: Any
|
||||
_user_id: Any
|
||||
_user_id_alt: Any
|
||||
background_review_callback: Any
|
||||
client: Any | None
|
||||
codex_app_server_auto_compaction: Any
|
||||
compression_enabled: Any
|
||||
compression_in_place: Any
|
||||
context_compressor: Any
|
||||
iteration_budget: Optional[IterationBudget]
|
||||
logs_dir: Any
|
||||
openrouter_min_coding_score: Optional[float]
|
||||
session_start: Any
|
||||
tools: Any
|
||||
valid_tool_names: Any
|
||||
_current_tool: Any
|
||||
_api_call_count: int
|
||||
|
||||
_TOOL_CALL_ARGUMENTS_CORRUPTION_MARKER = (
|
||||
"[hermes-agent: tool call arguments were corrupted in this session and "
|
||||
"have been dropped to keep the conversation alive. See issue #15236.]"
|
||||
@@ -417,71 +611,71 @@ class AIAgent:
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
base_url: str = None,
|
||||
api_key: str = None,
|
||||
provider: str = None,
|
||||
api_mode: str = None,
|
||||
acp_command: str = None,
|
||||
base_url: str | None = None,
|
||||
api_key: str | None = None,
|
||||
provider: str | None = None,
|
||||
api_mode: str | None = None,
|
||||
acp_command: str | None = None,
|
||||
acp_args: list[str] | None = None,
|
||||
command: str = None,
|
||||
command: str | None = None,
|
||||
args: list[str] | None = None,
|
||||
model: str = "",
|
||||
max_iterations: int = 90, # Default tool-calling iterations (shared with subagents)
|
||||
tool_delay: float = 1.0,
|
||||
enabled_toolsets: List[str] = None,
|
||||
disabled_toolsets: List[str] = None,
|
||||
enabled_toolsets: List[str] | None = None,
|
||||
disabled_toolsets: List[str] | None = None,
|
||||
save_trajectories: bool = False,
|
||||
verbose_logging: bool = False,
|
||||
quiet_mode: bool = False,
|
||||
tool_progress_mode: str = "all",
|
||||
ephemeral_system_prompt: str = None,
|
||||
ephemeral_system_prompt: str | None = None,
|
||||
log_prefix_chars: int = 100,
|
||||
log_prefix: str = "",
|
||||
providers_allowed: List[str] = None,
|
||||
providers_ignored: List[str] = None,
|
||||
providers_order: List[str] = None,
|
||||
provider_sort: str = None,
|
||||
providers_allowed: List[str] | None = None,
|
||||
providers_ignored: List[str] | None = None,
|
||||
providers_order: List[str] | None = None,
|
||||
provider_sort: str | None = None,
|
||||
provider_require_parameters: bool = False,
|
||||
provider_data_collection: str = None,
|
||||
provider_data_collection: str | None = None,
|
||||
openrouter_min_coding_score: Optional[float] = None,
|
||||
session_id: str = None,
|
||||
tool_progress_callback: callable = None,
|
||||
tool_start_callback: callable = None,
|
||||
tool_complete_callback: callable = None,
|
||||
thinking_callback: callable = None,
|
||||
reasoning_callback: callable = None,
|
||||
clarify_callback: callable = None,
|
||||
read_terminal_callback: callable = None,
|
||||
step_callback: callable = None,
|
||||
stream_delta_callback: callable = None,
|
||||
interim_assistant_callback: callable = None,
|
||||
tool_gen_callback: callable = None,
|
||||
status_callback: callable = None,
|
||||
notice_callback: callable = None,
|
||||
notice_clear_callback: callable = None,
|
||||
session_id: str | None = None,
|
||||
tool_progress_callback: Callable | None = None,
|
||||
tool_start_callback: Callable | None = None,
|
||||
tool_complete_callback: Callable | None = None,
|
||||
thinking_callback: Callable | None = None,
|
||||
reasoning_callback: Callable | None = None,
|
||||
clarify_callback: Callable | None = None,
|
||||
read_terminal_callback: Callable | None = None,
|
||||
step_callback: Callable | None = None,
|
||||
stream_delta_callback: Callable | None = None,
|
||||
interim_assistant_callback: Callable | None = None,
|
||||
tool_gen_callback: Callable | None = None,
|
||||
status_callback: Callable | None = None,
|
||||
notice_callback: Callable | None = None,
|
||||
notice_clear_callback: Callable | None = None,
|
||||
event_callback: Optional[Callable[[str, dict], None]] = None,
|
||||
reaction_callback: Optional[Callable[[str], None]] = None,
|
||||
max_tokens: int = None,
|
||||
reasoning_config: Dict[str, Any] = None,
|
||||
service_tier: str = None,
|
||||
request_overrides: Dict[str, Any] = None,
|
||||
prefill_messages: List[Dict[str, Any]] = None,
|
||||
platform: str = None,
|
||||
user_id: str = None,
|
||||
user_id_alt: str = None,
|
||||
user_name: str = None,
|
||||
chat_id: str = None,
|
||||
chat_name: str = None,
|
||||
chat_type: str = None,
|
||||
thread_id: str = None,
|
||||
gateway_session_key: str = None,
|
||||
max_tokens: int | None = None,
|
||||
reasoning_config: Dict[str, Any] | None = None,
|
||||
service_tier: str | None = None,
|
||||
request_overrides: Dict[str, Any] | None = None,
|
||||
prefill_messages: List[Dict[str, Any]] | None = None,
|
||||
platform: str | None = None,
|
||||
user_id: str | None = None,
|
||||
user_id_alt: str | None = None,
|
||||
user_name: str | None = None,
|
||||
chat_id: str | None = None,
|
||||
chat_name: str | None = None,
|
||||
chat_type: str | None = None,
|
||||
thread_id: str | None = None,
|
||||
gateway_session_key: str | None = None,
|
||||
skip_context_files: bool = False,
|
||||
load_soul_identity: bool = False,
|
||||
skip_memory: bool = False,
|
||||
session_db=None,
|
||||
parent_session_id: str = None,
|
||||
iteration_budget: "IterationBudget" = None,
|
||||
fallback_model: Dict[str, Any] = None,
|
||||
parent_session_id: str | None = None,
|
||||
iteration_budget: Optional["IterationBudget"] = None,
|
||||
fallback_model: Dict[str, Any] | None = None,
|
||||
credential_pool=None,
|
||||
checkpoints_enabled: bool = False,
|
||||
checkpoint_max_snapshots: int = 20,
|
||||
@@ -5932,7 +6126,7 @@ class AIAgent:
|
||||
messages: list,
|
||||
*,
|
||||
logger=None,
|
||||
session_id: str = None,
|
||||
session_id: str | None = None,
|
||||
) -> int:
|
||||
"""Forwarder — see ``agent.agent_runtime_helpers.sanitize_tool_call_arguments``."""
|
||||
from agent.agent_runtime_helpers import sanitize_tool_call_arguments
|
||||
@@ -6171,9 +6365,9 @@ class AIAgent:
|
||||
def run_conversation(
|
||||
self,
|
||||
user_message: Any,
|
||||
system_message: str = None,
|
||||
conversation_history: List[Dict[str, Any]] = None,
|
||||
task_id: str = None,
|
||||
system_message: str | None = None,
|
||||
conversation_history: List[Dict[str, Any]] | None = None,
|
||||
task_id: str | None = None,
|
||||
stream_callback: Optional[callable] = None,
|
||||
persist_user_message: Optional[Any] = None,
|
||||
persist_user_timestamp: Optional[float] = None,
|
||||
@@ -6253,13 +6447,13 @@ class AIAgent:
|
||||
return run_codex_app_server_turn(self, user_message=user_message, original_user_message=original_user_message, messages=messages, effective_task_id=effective_task_id, should_review_memory=should_review_memory)
|
||||
|
||||
def main(
|
||||
query: str = None,
|
||||
query: str | None = None,
|
||||
model: str = "",
|
||||
api_key: str = None,
|
||||
api_key: str | None = None,
|
||||
base_url: str = "",
|
||||
max_turns: int = 10,
|
||||
enabled_toolsets: str = None,
|
||||
disabled_toolsets: str = None,
|
||||
enabled_toolsets: str | None = None,
|
||||
disabled_toolsets: str | None = None,
|
||||
list_tools: bool = False,
|
||||
save_trajectories: bool = False,
|
||||
save_sample: bool = False,
|
||||
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Add a contributor email → GitHub login mapping.
|
||||
|
||||
Writes one file per email under contributors/emails/ (filename = email,
|
||||
content = login). File additions never merge-conflict, unlike the legacy
|
||||
AUTHOR_MAP dict in scripts/release.py, which is frozen — do not append to it.
|
||||
|
||||
Usage (from the repo root):
|
||||
python3 scripts/add_contributor.py <email> <github-login> [comment...]
|
||||
|
||||
# e.g.
|
||||
python3 scripts/add_contributor.py jane@example.com janedoe "PR #12345 salvage"
|
||||
|
||||
Idempotent: if the mapping already exists with the same login, prints
|
||||
"present" and exits 0. If the email maps to a DIFFERENT login (here or in the
|
||||
legacy AUTHOR_MAP), refuses with exit 1 so a typo can't silently reassign
|
||||
someone's commits.
|
||||
"""
|
||||
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
EMAILS_DIR = REPO_ROOT / "contributors" / "emails"
|
||||
|
||||
_EMAIL_RE = re.compile(r"^[^/\\\s]+@[^/\\\s]+$")
|
||||
_LOGIN_RE = re.compile(r"^[A-Za-z0-9](?:[A-Za-z0-9]|-(?=[A-Za-z0-9])){0,38}$")
|
||||
|
||||
|
||||
def read_mapping_file(path: Path) -> str | None:
|
||||
"""Return the login from a mapping file (first non-comment line)."""
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
return line
|
||||
except OSError:
|
||||
pass
|
||||
return None
|
||||
|
||||
|
||||
def _legacy_login(email: str) -> str | None:
|
||||
"""Look the email up in the frozen legacy AUTHOR_MAP in release.py."""
|
||||
try:
|
||||
sys.path.insert(0, str(REPO_ROOT / "scripts"))
|
||||
from release import LEGACY_AUTHOR_MAP # noqa: PLC0415
|
||||
|
||||
return LEGACY_AUTHOR_MAP.get(email)
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def add_contributor(email: str, login: str, comment: str = "") -> int:
|
||||
email = email.strip()
|
||||
login = login.strip().lstrip("@")
|
||||
|
||||
if not _EMAIL_RE.match(email):
|
||||
print(f"error: {email!r} does not look like a commit-author email", file=sys.stderr)
|
||||
return 2
|
||||
if not _LOGIN_RE.match(login):
|
||||
print(f"error: {login!r} is not a valid GitHub login", file=sys.stderr)
|
||||
return 2
|
||||
|
||||
path = EMAILS_DIR / email
|
||||
existing = read_mapping_file(path) if path.is_file() else None
|
||||
if existing is None:
|
||||
existing = _legacy_login(email)
|
||||
if existing is not None:
|
||||
if existing == login:
|
||||
print("present")
|
||||
return 0
|
||||
print(
|
||||
f"error: {email} already maps to {existing!r} (asked for {login!r}) — "
|
||||
"resolve manually",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
EMAILS_DIR.mkdir(parents=True, exist_ok=True)
|
||||
body = login + "\n"
|
||||
if comment:
|
||||
body += f"# {comment}\n"
|
||||
path.write_text(body, encoding="utf-8")
|
||||
print(f"added: contributors/emails/{email} -> {login}")
|
||||
return 0
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if len(sys.argv) < 3:
|
||||
print(__doc__, file=sys.stderr)
|
||||
return 2
|
||||
email, login = sys.argv[1], sys.argv[2]
|
||||
comment = " ".join(sys.argv[3:])
|
||||
return add_contributor(email, login, comment)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -411,10 +411,10 @@ def main():
|
||||
if all_unknowns:
|
||||
print()
|
||||
print(f"=== Unknown Emails ({len(all_unknowns)}) ===")
|
||||
print("These emails have no mapping and should be added via:")
|
||||
print("These emails are not in AUTHOR_MAP and should be added:")
|
||||
print()
|
||||
for email, name in sorted(all_unknowns.items()):
|
||||
print(f" python3 scripts/add_contributor.py {email} <github-username> # {name}")
|
||||
print(f' "{email}": "{name}",')
|
||||
|
||||
# ---- Strict mode: fail CI if new unmapped emails are introduced ----
|
||||
if args.strict and all_unknowns:
|
||||
@@ -439,10 +439,10 @@ def main():
|
||||
if new_unknowns:
|
||||
print()
|
||||
print(f"=== STRICT MODE FAILURE: {len(new_unknowns)} new unmapped email(s) ===")
|
||||
print("Add mapping files before merging (do NOT edit AUTHOR_MAP):")
|
||||
print("Add these to AUTHOR_MAP in scripts/release.py before merging:")
|
||||
print()
|
||||
for email, name in sorted(new_unknowns.items()):
|
||||
print(f" python3 scripts/add_contributor.py {email} <github-username> # {name}")
|
||||
print(f' "{email}": "<github-username>",')
|
||||
print()
|
||||
print("To find the GitHub username:")
|
||||
print(" gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'")
|
||||
|
||||
+1
-47
@@ -15,10 +15,6 @@
|
||||
# scripts/dev-sandbox.sh --persistent hermes desktop
|
||||
# scripts/dev-sandbox.sh --persistent -- npm run dev
|
||||
#
|
||||
# Seed the sandbox HERMES_HOME from an existing directory (e.g. your main
|
||||
# ~/.hermes) so config, sessions, skills, etc. are pre-populated:
|
||||
# scripts/dev-sandbox.sh --from ~/.hermes hermes desktop
|
||||
#
|
||||
# Override the app name (default: HermesSandbox):
|
||||
# HERMES_DEV_SANDBOX_NAME=Staging scripts/dev-sandbox.sh hermes desktop
|
||||
#
|
||||
@@ -31,7 +27,7 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
print_help() {
|
||||
cat <<'EOF'
|
||||
Usage: dev-sandbox.sh [--persistent] [--from DIR] [--] <command...>
|
||||
Usage: dev-sandbox.sh [--persistent] [--] <command...>
|
||||
|
||||
Run a Hermes instance in an isolated sandbox.
|
||||
|
||||
@@ -39,10 +35,6 @@ Options:
|
||||
--persistent Keep the sandbox dir across restarts (under the worktree
|
||||
git root, in .hermes-sandbox/). Without this flag the
|
||||
sandbox is a temp dir that is removed on exit.
|
||||
--from DIR Copy DIR into the sandbox HERMES_HOME as the starting
|
||||
point (config, sessions, skills, etc.).
|
||||
Ignored if the sandbox HERMES_HOME already has content
|
||||
(e.g. reusing a --persistent sandbox) to avoid clobbering.
|
||||
--delete Delete the existing persistent sandbox in .hermes-sandbox.
|
||||
-h, --help Show this help message.
|
||||
|
||||
@@ -53,14 +45,12 @@ Environment:
|
||||
Examples:
|
||||
dev-sandbox.sh hermes desktop
|
||||
dev-sandbox.sh --persistent hermes desktop
|
||||
dev-sandbox.sh --from ~/.hermes hermes desktop
|
||||
dev-sandbox.sh -- npm run dev
|
||||
EOF
|
||||
}
|
||||
|
||||
PERSISTENT=false
|
||||
DELETE=false
|
||||
SEED_DIR=""
|
||||
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
@@ -68,22 +58,6 @@ while [ "$#" -gt 0 ]; do
|
||||
PERSISTENT=true
|
||||
shift
|
||||
;;
|
||||
--from)
|
||||
if [ "$#" -lt 2 ] || [[ "$2" == -* ]]; then
|
||||
echo "error: --from requires a directory argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
SEED_DIR="$2"
|
||||
shift 2
|
||||
;;
|
||||
--from=*)
|
||||
SEED_DIR="${1#--from=}"
|
||||
if [ -z "$SEED_DIR" ]; then
|
||||
echo "error: --from requires a directory argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift
|
||||
;;
|
||||
--delete)
|
||||
DELETE=true
|
||||
shift
|
||||
@@ -102,15 +76,6 @@ while [ "$#" -gt 0 ]; do
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -n "$SEED_DIR" ]; then
|
||||
if [ ! -d "$SEED_DIR" ]; then
|
||||
echo "error: --from dir '$SEED_DIR' does not exist" >&2
|
||||
exit 1
|
||||
fi
|
||||
# Resolve to absolute path so it's valid after we cd later.
|
||||
SEED_DIR="$(cd "$SEED_DIR" && pwd)"
|
||||
fi
|
||||
|
||||
if [ "$#" -eq 0 ]; then
|
||||
print_help >&2
|
||||
exit 1
|
||||
@@ -164,17 +129,6 @@ export HERMES_DESKTOP_APP_NAME="$SANDBOX_NAME"
|
||||
|
||||
mkdir -p "$HERMES_HOME" "$HERMES_DESKTOP_USER_DATA_DIR"
|
||||
|
||||
if [ -n "$SEED_DIR" ]; then
|
||||
# Only seed when the sandbox HERMES_HOME is empty — avoids clobbering an
|
||||
# existing persistent sandbox on re-run.
|
||||
if [ -z "$(ls -A "$HERMES_HOME" 2>/dev/null)" ]; then
|
||||
echo "[sandbox] seeding HERMES_HOME from $SEED_DIR" >&2
|
||||
cp -a "$SEED_DIR/." "$HERMES_HOME/"
|
||||
else
|
||||
echo "[sandbox] --from ignored: $HERMES_HOME already has content" >&2
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "[sandbox] HERMES_HOME=$HERMES_HOME" >&2
|
||||
echo "[sandbox] userData=$HERMES_DESKTOP_USER_DATA_DIR" >&2
|
||||
echo "[sandbox] appName=$HERMES_DESKTOP_APP_NAME" >&2
|
||||
|
||||
+2
-45
@@ -43,12 +43,8 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
# Git email → GitHub username mapping
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
# FROZEN legacy mappings — do NOT add new entries here. New contributor
|
||||
# mappings live as one-file-per-email entries under contributors/emails/
|
||||
# (see contributors/README.md), which merge-conflict-free by construction.
|
||||
# This dict is kept only so existing history keeps resolving; the effective
|
||||
# AUTHOR_MAP below merges it with the directory (directory wins).
|
||||
LEGACY_AUTHOR_MAP = {
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"122438640+ragingbulld@users.noreply.github.com": "ragingbulld", # PR #65606 salvage (non-finite API wait deadlines; #65746)
|
||||
"zzpigpinggai@users.noreply.github.com": "zzpigpinggai", # PR #66017 salvage of #63617 (OpenRouter explicit-provider picker visibility)
|
||||
"sam7894604@gmail.com": "sam7894604", # PR #55803 salvage (discord: /reasoning slash choices)
|
||||
@@ -377,15 +373,11 @@ LEGACY_AUTHOR_MAP = {
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"krowd3v@users.noreply.github.com": "krowd3v",
|
||||
"dfein38347g@users.noreply.github.com": "dfein38347g",
|
||||
"nicktaylor@TheWorldofNick-Lappy.local": "thegoodguysla",
|
||||
"s96919@gmail.com": "s96919",
|
||||
"rasitakyol@hotmail.com": "rasitakyol",
|
||||
"thatgfsj@gmail.com": "Thatgfsj",
|
||||
"141703117+seagpt@users.noreply.github.com": "seagpt",
|
||||
"dr@nevernet.com": "davidrobertson",
|
||||
"59045242+HaiderSultanArc@users.noreply.github.com": "HaiderSultanArc",
|
||||
"jjadeo@gmail.com": "jjadeo-oss",
|
||||
"94815906+juanfradb@users.noreply.github.com": "juanfradb",
|
||||
"eva@100yen.org": "100yenadmin",
|
||||
"yakimenkoleksander228@gmail.com": "doxe0x",
|
||||
"a54983334@163.com": "Code-suphub",
|
||||
@@ -2049,41 +2041,6 @@ LEGACY_AUTHOR_MAP = {
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
# Directory-based mappings: contributors/emails/<email> → login
|
||||
# ──────────────────────────────────────────────────────────────────────
|
||||
CONTRIBUTORS_EMAILS_DIR = REPO_ROOT / "contributors" / "emails"
|
||||
|
||||
|
||||
def _load_contributor_dir(directory: "Path | None" = None) -> dict:
|
||||
"""Load one-file-per-email mappings from contributors/emails/.
|
||||
|
||||
Filename = commit-author email, first non-comment line = GitHub login.
|
||||
Additions never merge-conflict (each mapping is a distinct file), which
|
||||
is why new entries go here instead of the frozen LEGACY_AUTHOR_MAP.
|
||||
"""
|
||||
directory = directory or CONTRIBUTORS_EMAILS_DIR
|
||||
mapping = {}
|
||||
if not directory.is_dir():
|
||||
return mapping
|
||||
for path in sorted(directory.iterdir()):
|
||||
if not path.is_file() or path.name.startswith("."):
|
||||
continue
|
||||
try:
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if line and not line.startswith("#"):
|
||||
mapping[path.name] = line.lstrip("@")
|
||||
break
|
||||
except OSError:
|
||||
continue
|
||||
return mapping
|
||||
|
||||
|
||||
# Effective map: frozen legacy dict + directory entries (directory wins).
|
||||
AUTHOR_MAP = {**LEGACY_AUTHOR_MAP, **_load_contributor_dir()}
|
||||
|
||||
|
||||
def git(*args, cwd=None):
|
||||
"""Run a git command and return stdout."""
|
||||
result = subprocess.run(
|
||||
|
||||
@@ -85,15 +85,6 @@ _SKIP_PARTS = {"integration", "e2e", "docker"}
|
||||
# time while keeping a genuinely hung file bounded.
|
||||
_DEFAULT_FILE_TIMEOUT_SECONDS = 300.0
|
||||
|
||||
# One-shot retry of failing test FILES. A file that exits non-zero is re-run
|
||||
# once in a fresh subprocess; if the re-run passes, the file counts as passed
|
||||
# but is loudly reported as FLAKY so it gets fixed rather than hidden.
|
||||
# Deterministic failures fail both attempts — a real regression can never be
|
||||
# laundered into green by this (it would have to flake in our favor twice in
|
||||
# a row on the same runner, which is exactly the definition of a flake).
|
||||
# Set to 0 to disable (env: HERMES_TEST_FILE_RETRIES).
|
||||
_DEFAULT_FILE_RETRIES = 1
|
||||
|
||||
# Duration cache: maps relative file paths to last-observed subprocess
|
||||
# wall-clock seconds. Used by ``--slice`` to distribute files across
|
||||
# CI jobs by estimated total time, so no one job gets all the slow files.
|
||||
@@ -233,19 +224,11 @@ def _run_one_file(
|
||||
pytest_args: List[str],
|
||||
repo_root: Path,
|
||||
file_timeout: float,
|
||||
retries: int = 0,
|
||||
) -> Tuple[Path, int, str, dict[str, int], float]:
|
||||
"""Run ``python -m pytest <file> <pytest_args>`` in a fresh subprocess.
|
||||
|
||||
Returns (file, returncode, captured_combined_output, summary_counts, subprocess_wall_seconds).
|
||||
|
||||
``retries`` > 0 enables the one-shot flake retry: a non-zero exit is
|
||||
re-run in a fresh subprocess; if the re-run passes, the file counts as
|
||||
passed but the output is prefixed with a FLAKY banner and the file/output
|
||||
are recorded in ``_FLAKY_RESULTS`` so the summary can call it out. A
|
||||
deterministic failure fails every attempt, so real regressions cannot
|
||||
be laundered green.
|
||||
|
||||
``summary_counts`` is the result of ``_parse_pytest_summary(output)`` —
|
||||
|
||||
pytest exit codes (https://docs.pytest.org/en/stable/reference/exit-codes.html):
|
||||
@@ -267,44 +250,6 @@ def _run_one_file(
|
||||
orphan onto PID 1. This outer timeout exists only to
|
||||
bound a pathologically slow or hung file as a whole.
|
||||
"""
|
||||
file, rc, output, summary, subproc_wall = _run_one_file_once(
|
||||
file, pytest_args, repo_root, file_timeout
|
||||
)
|
||||
attempt = 0
|
||||
while rc != 0 and attempt < retries:
|
||||
attempt += 1
|
||||
first_output = output
|
||||
file, rc, output, summary, subproc_wall2 = _run_one_file_once(
|
||||
file, pytest_args, repo_root, file_timeout
|
||||
)
|
||||
subproc_wall += subproc_wall2
|
||||
if rc == 0:
|
||||
output = (
|
||||
f"⚠ FLAKY: failed on attempt 1, passed on retry "
|
||||
f"(attempt {attempt + 1}). Fix the flake — do not ignore this.\n"
|
||||
f"--- first-attempt output ---\n{first_output}\n"
|
||||
f"--- retry output ---\n{output}"
|
||||
)
|
||||
with _flaky_lock:
|
||||
_FLAKY_RESULTS.append((file, output))
|
||||
return file, rc, output, summary, subproc_wall
|
||||
|
||||
|
||||
# Files that failed once and passed on retry, with both attempts' output.
|
||||
# Keeping the traceback is load-bearing: a self-healed flake without its
|
||||
# failing assertion is only a filename, which forces another expensive full
|
||||
# run to rediscover the race.
|
||||
_FLAKY_RESULTS: List[Tuple[Path, str]] = []
|
||||
_flaky_lock = threading.Lock()
|
||||
|
||||
|
||||
def _run_one_file_once(
|
||||
file: Path,
|
||||
pytest_args: List[str],
|
||||
repo_root: Path,
|
||||
file_timeout: float,
|
||||
) -> Tuple[Path, int, str, dict[str, int], float]:
|
||||
"""Single attempt of a per-file pytest subprocess (see _run_one_file)."""
|
||||
cmd = [sys.executable, "-m", "pytest", str(file), *pytest_args]
|
||||
|
||||
subproc_start = time.monotonic()
|
||||
@@ -679,19 +624,6 @@ def main() -> int:
|
||||
f"Default: {_DEFAULT_FILE_TIMEOUT_SECONDS}s ({round(_DEFAULT_FILE_TIMEOUT_SECONDS/60)} min), env: HERMES_TEST_FILE_TIMEOUT."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--file-retries",
|
||||
type=int,
|
||||
default=int(
|
||||
os.environ.get("HERMES_TEST_FILE_RETRIES", _DEFAULT_FILE_RETRIES)
|
||||
),
|
||||
help=(
|
||||
"Re-run a failing test FILE this many times in a fresh subprocess "
|
||||
"before declaring it failed. A pass-on-retry counts as passed but "
|
||||
"is reported as FLAKY in the summary. 0 disables. "
|
||||
f"Default: {_DEFAULT_FILE_RETRIES}, env: HERMES_TEST_FILE_RETRIES."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
"--slice",
|
||||
metavar="I/N",
|
||||
@@ -752,7 +684,7 @@ def main() -> int:
|
||||
# (``-k=expr``, ``--tb=long``) are self-contained and need no lookahead.
|
||||
OUR_FLAGS = {
|
||||
"-j", "--jobs", "--paths", "--include-integration",
|
||||
"--file-timeout", "--file-retries", "--slice", "--generate-slices", "--files",
|
||||
"--file-timeout", "--slice", "--generate-slices", "--files",
|
||||
}
|
||||
# pytest short flags that consume the NEXT token as their value.
|
||||
PYTEST_VALUE_FLAGS = {"-k", "-m", "-p", "-o", "-c", "-r", "-W"}
|
||||
@@ -943,8 +875,7 @@ def main() -> int:
|
||||
for file in files:
|
||||
t0 = time.monotonic()
|
||||
fut = pool.submit(
|
||||
_run_one_file, file, pytest_passthrough, repo_root,
|
||||
args.file_timeout, args.file_retries,
|
||||
_run_one_file, file, pytest_passthrough, repo_root, args.file_timeout
|
||||
)
|
||||
fut.add_done_callback(lambda f, file=file, t0=t0: _on_done(file, t0, f))
|
||||
futures.append(fut)
|
||||
@@ -959,15 +890,6 @@ def main() -> int:
|
||||
pct = min(100, (tests_done / approx_total_tests * 100)) if approx_total_tests else 0
|
||||
print(f"=== Summary: {len(files)} files, {tests_passed} tests passed, {tests_failed} failed ({pct:.0f}% complete) in {elapsed:.1f}s ({args.jobs} workers) ===")
|
||||
|
||||
# Flaky files: failed once, passed on the automatic retry. Green, but
|
||||
# loudly reported so they get fixed instead of silently re-flaking.
|
||||
if _FLAKY_RESULTS:
|
||||
print()
|
||||
print(f"=== ⚠ {len(_FLAKY_RESULTS)} FLAKY file{'s' if len(_FLAKY_RESULTS) != 1 else ''} (failed once, passed on retry — fix these) ===")
|
||||
for f, output in _FLAKY_RESULTS:
|
||||
print(f" {_format_file(f, repo_root)}")
|
||||
print(output.rstrip())
|
||||
|
||||
# Save durations for future --slice runs. Each slice writes its own
|
||||
# partial test_durations.json; a CI merge step joins them later.
|
||||
# Locally, _save_durations merges with any existing cache so entries
|
||||
|
||||
@@ -76,7 +76,7 @@ def test_non_streaming_cancel_does_not_surface_network_error():
|
||||
|
||||
# The forced RemoteProtocolError must NOT surface as the raised error.
|
||||
assert create_calls["n"] == 1
|
||||
assert elapsed < 10.0, f"interrupt took {elapsed:.1f}s — should be near-instant (guarding the 30s+ hang)"
|
||||
assert elapsed < 3.0, f"interrupt took {elapsed:.1f}s — should be near-instant"
|
||||
|
||||
|
||||
def test_normal_transient_error_still_raises_when_not_cancelled():
|
||||
|
||||
@@ -1,182 +0,0 @@
|
||||
"""Regression tests for live Codex app-server events.
|
||||
|
||||
The history projector is completion-only. These tests protect the parallel
|
||||
display bridge (make_codex_app_server_event_bridge) that makes deltas and
|
||||
tool cards visible before resume: it fires both the tool_progress bubbles
|
||||
AND the authoritative stable-ID tool_start/tool_complete callbacks the TUI
|
||||
tool cards depend on.
|
||||
|
||||
Grafted from PR #65412 (@HaiderSultanArc) onto the merged bridge.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
from agent.codex_runtime import (
|
||||
_codex_item_completion_payload,
|
||||
make_codex_app_server_event_bridge,
|
||||
)
|
||||
from agent.transports.codex_event_projector import _deterministic_call_id
|
||||
|
||||
|
||||
def _recording_agent():
|
||||
calls = {
|
||||
"stream": [],
|
||||
"reasoning": [],
|
||||
"tool_progress": [],
|
||||
"tool_start": [],
|
||||
"tool_complete": [],
|
||||
}
|
||||
agent = SimpleNamespace(
|
||||
_fire_stream_delta=lambda text: calls["stream"].append(text),
|
||||
_fire_reasoning_delta=lambda text: calls["reasoning"].append(text),
|
||||
tool_progress_callback=lambda *args, **kwargs: calls["tool_progress"].append((
|
||||
args,
|
||||
kwargs,
|
||||
)),
|
||||
tool_start_callback=lambda call_id, name, args: calls["tool_start"].append((
|
||||
call_id,
|
||||
name,
|
||||
args,
|
||||
)),
|
||||
tool_complete_callback=lambda call_id, name, args, result: calls[
|
||||
"tool_complete"
|
||||
].append((call_id, name, args, result)),
|
||||
_emit_interim_assistant_message=None,
|
||||
show_commentary=True,
|
||||
)
|
||||
return agent, calls
|
||||
|
||||
|
||||
def test_agent_message_and_reasoning_deltas_are_forwarded_live():
|
||||
agent, calls = _recording_agent()
|
||||
bridge = make_codex_app_server_event_bridge(agent)
|
||||
|
||||
bridge({"method": "item/agentMessage/delta", "params": {"delta": "Working"}})
|
||||
bridge({"method": "item/reasoning/delta", "params": {"delta": "Thinking"}})
|
||||
bridge({"method": "item/reasoning/summaryDelta", "params": {"delta": "Summary"}})
|
||||
|
||||
assert calls["stream"] == ["Working"]
|
||||
assert calls["reasoning"] == ["Thinking", "Summary"]
|
||||
|
||||
|
||||
def test_command_start_and_complete_fire_both_callback_contracts():
|
||||
agent, calls = _recording_agent()
|
||||
bridge = make_codex_app_server_event_bridge(agent)
|
||||
started = {
|
||||
"type": "commandExecution",
|
||||
"id": "abc123",
|
||||
"command": "echo hi",
|
||||
"cwd": "/tmp",
|
||||
}
|
||||
completed = dict(
|
||||
started,
|
||||
aggregatedOutput="hi\n",
|
||||
exitCode=0,
|
||||
durationMs=250,
|
||||
)
|
||||
|
||||
bridge({"method": "item/started", "params": {"item": started}})
|
||||
bridge({"method": "item/completed", "params": {"item": completed}})
|
||||
|
||||
expected_args = {"command": "echo hi", "cwd": "/tmp"}
|
||||
expected_id = "codex_exec_abc123"
|
||||
assert calls["tool_start"] == [(expected_id, "exec_command", expected_args)]
|
||||
assert calls["tool_complete"] == [
|
||||
(expected_id, "exec_command", expected_args, "hi\n")
|
||||
]
|
||||
assert calls["tool_progress"][0] == (
|
||||
("tool.started", "exec_command", "echo hi", expected_args),
|
||||
{},
|
||||
)
|
||||
assert calls["tool_progress"][1] == (
|
||||
("tool.completed", "exec_command", None, None),
|
||||
{"duration": 0.25, "is_error": False, "result": "hi\n"},
|
||||
)
|
||||
|
||||
|
||||
def test_stable_ids_match_history_projector():
|
||||
"""The bridge's stable call ids mirror CodexEventProjector so a live
|
||||
TUI tool card correlates with the projected history entry after
|
||||
resume."""
|
||||
agent, calls = _recording_agent()
|
||||
bridge = make_codex_app_server_event_bridge(agent)
|
||||
|
||||
mcp = {
|
||||
"type": "mcpToolCall",
|
||||
"id": "m1",
|
||||
"server": "filesystem",
|
||||
"tool": "read",
|
||||
"arguments": {"path": "a.py"},
|
||||
}
|
||||
bridge({"method": "item/started", "params": {"item": mcp}})
|
||||
call_id, name, args = calls["tool_start"][0]
|
||||
assert call_id == _deterministic_call_id("mcp__filesystem__read", "m1")
|
||||
assert name == "mcp.filesystem.read"
|
||||
assert args == {"path": "a.py"}
|
||||
|
||||
calls["tool_start"].clear()
|
||||
patch = {
|
||||
"type": "fileChange",
|
||||
"id": "p1",
|
||||
"changes": [{"kind": {"type": "add"}, "path": "a.py"}],
|
||||
}
|
||||
bridge({"method": "item/started", "params": {"item": patch}})
|
||||
call_id, name, args = calls["tool_start"][0]
|
||||
assert call_id == _deterministic_call_id("apply_patch", "p1")
|
||||
assert name == "apply_patch"
|
||||
assert args == {"changes": [{"kind": "add", "path": "a.py"}]}
|
||||
|
||||
|
||||
def test_failed_command_result_and_error_flag_are_preserved():
|
||||
agent, calls = _recording_agent()
|
||||
bridge = make_codex_app_server_event_bridge(agent)
|
||||
item = {
|
||||
"type": "commandExecution",
|
||||
"id": "failed",
|
||||
"command": "false",
|
||||
"aggregatedOutput": "boom",
|
||||
"exitCode": 2,
|
||||
}
|
||||
|
||||
bridge({"method": "item/completed", "params": {"item": item}})
|
||||
|
||||
result, is_error = _codex_item_completion_payload(item)
|
||||
assert result == "[exit 2]\nboom"
|
||||
assert is_error is True
|
||||
assert calls["tool_progress"][0][1]["is_error"] is True
|
||||
assert calls["tool_complete"][0][3] == "[exit 2]\nboom"
|
||||
|
||||
|
||||
def test_non_tool_events_and_malformed_payloads_are_ignored():
|
||||
agent, calls = _recording_agent()
|
||||
bridge = make_codex_app_server_event_bridge(agent)
|
||||
for note in (
|
||||
{"method": "item/started", "params": {"item": {"type": "reasoning"}}},
|
||||
{"method": "turn/completed", "params": {}},
|
||||
{"method": "item/started", "params": []},
|
||||
{},
|
||||
None,
|
||||
):
|
||||
bridge(note)
|
||||
|
||||
assert all(not entries for entries in calls.values())
|
||||
|
||||
|
||||
def test_one_broken_callback_does_not_hide_other_live_events():
|
||||
starts = []
|
||||
|
||||
def broken_progress(*_args, **_kwargs):
|
||||
raise RuntimeError("display consumer failed")
|
||||
|
||||
agent = SimpleNamespace(
|
||||
tool_progress_callback=broken_progress,
|
||||
tool_start_callback=lambda call_id, name, args: starts.append(
|
||||
(call_id, name, args)
|
||||
),
|
||||
)
|
||||
bridge = make_codex_app_server_event_bridge(agent)
|
||||
item = {"type": "dynamicToolCall", "id": "d1", "tool": "search"}
|
||||
|
||||
bridge({"method": "item/started", "params": {"item": item}})
|
||||
|
||||
assert starts == [("codex_dyn_search_d1", "search", {})]
|
||||
@@ -252,10 +252,7 @@ def test_lock_refresh_keeps_owner_live_past_initial_ttl(tmp_path: Path, monkeypa
|
||||
db.create_session(parent_sid, source="discord")
|
||||
|
||||
agent_a = _build_agent_with_db(db, parent_sid)
|
||||
# 3s TTL / 0.25s refresh: ~12 refresh opportunities per lease. A 1s TTL
|
||||
# left one missed scheduling quantum between "refreshed" and "expired"
|
||||
# on a loaded runner.
|
||||
agent_a._compression_lock_ttl_seconds = 3.0
|
||||
agent_a._compression_lock_ttl_seconds = 1.0
|
||||
agent_a._compression_lock_refresh_interval = 0.25
|
||||
compression_started = threading.Event()
|
||||
release_compression = threading.Event()
|
||||
@@ -279,9 +276,9 @@ def test_lock_refresh_keeps_owner_live_past_initial_ttl(tmp_path: Path, monkeypa
|
||||
try:
|
||||
assert compression_started.wait(timeout=10), "compression never acquired its lock"
|
||||
assert db.get_compression_lock_holder(parent_sid) is not None
|
||||
time.sleep(3.5)
|
||||
time.sleep(1.2)
|
||||
assert db.try_acquire_compression_lock(
|
||||
parent_sid, "refresh_probe", ttl_seconds=3.0
|
||||
parent_sid, "refresh_probe", ttl_seconds=1.0
|
||||
) is False, "live owner lease expired and was reclaimable before compression finished"
|
||||
finally:
|
||||
release_compression.set()
|
||||
|
||||
@@ -1,79 +0,0 @@
|
||||
"""Regression tests for the best-effort single-writer fence accessors.
|
||||
|
||||
The streaming paths in ``chat_completion_helpers`` and ``codex_runtime`` reach
|
||||
the #65991 single-writer fence through :mod:`agent.stream_single_writer` instead
|
||||
of calling ``agent._claim_stream_writer()`` directly. That indirection exists so
|
||||
an agent object that doesn't expose the fence (a version-skewed checkout, a
|
||||
duck-typed agent, a test double) degrades to "no fence" rather than aborting the
|
||||
whole turn with ``'AIAgent' object has no attribute '_claim_stream_writer'`` —
|
||||
the exact AttributeError that killed a cron job.
|
||||
|
||||
These tests assert the fence's *contract*: it may drop a provably superseded
|
||||
stream, but it must never fence (or crash) the sole legitimate writer.
|
||||
"""
|
||||
|
||||
import run_agent
|
||||
from agent.stream_single_writer import claim_stream_writer, stream_writer_is_current
|
||||
|
||||
|
||||
class _NoFenceAgent:
|
||||
"""An agent-like object that predates / lacks the single-writer fence."""
|
||||
|
||||
|
||||
class _RaisingFenceAgent:
|
||||
"""An agent whose fence methods exist but blow up when called."""
|
||||
|
||||
def _claim_stream_writer(self):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def _stream_writer_is_current(self, token):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
|
||||
def _real_agent():
|
||||
"""A real AIAgent without running the heavy __init__ (fields self-heal)."""
|
||||
return object.__new__(run_agent.AIAgent)
|
||||
|
||||
|
||||
def test_claim_on_fenceless_agent_does_not_raise():
|
||||
# Regression: this is the cron crash path — the streaming helper must not
|
||||
# explode when the agent lacks _claim_stream_writer.
|
||||
assert claim_stream_writer(_NoFenceAgent()) == 0
|
||||
|
||||
|
||||
def test_is_current_on_fenceless_agent_is_always_current():
|
||||
agent = _NoFenceAgent()
|
||||
# A no-op claim (token 0) must never report as superseded, regardless of
|
||||
# what token value a caller threads through.
|
||||
assert stream_writer_is_current(agent, 0) is True
|
||||
assert stream_writer_is_current(agent, 7) is True
|
||||
|
||||
|
||||
def test_zero_token_is_never_fenced_even_with_a_real_fence():
|
||||
# Invariant: a claim that no-oped (token 0) is not a writer and can never be
|
||||
# fenced, even against an agent that does implement the fence.
|
||||
assert stream_writer_is_current(_real_agent(), 0) is True
|
||||
|
||||
|
||||
def test_claim_swallows_fence_exceptions():
|
||||
assert claim_stream_writer(_RaisingFenceAgent()) == 0
|
||||
|
||||
|
||||
def test_is_current_swallows_fence_exceptions_as_current():
|
||||
assert stream_writer_is_current(_RaisingFenceAgent(), 123) is True
|
||||
|
||||
|
||||
def test_real_agent_fence_still_supersedes_and_preserves_sole_writer():
|
||||
agent = _real_agent()
|
||||
|
||||
first = claim_stream_writer(agent)
|
||||
assert first > 0
|
||||
# Sole writer so far — still current.
|
||||
assert stream_writer_is_current(agent, first) is True
|
||||
|
||||
# A newer attempt claims the sink: the older token is now superseded, the
|
||||
# newer one is current. The fence drops only the provably stale writer.
|
||||
second = claim_stream_writer(agent)
|
||||
assert second > first
|
||||
assert stream_writer_is_current(agent, first) is False
|
||||
assert stream_writer_is_current(agent, second) is True
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user