Compare commits

..
Author SHA1 Message Date
ethernet 7acd6c902c fix(types): fix ty python env resolution + triage discord adapter
The .venv directory (created by uv run) contained Python 3.13 with no
deps installed. ty auto-discovers .venv for module resolution, so it
could not find discord.py, aiohttp, etc., producing ~1000 false
"Module has no member" errors.

Removing the stray .venv makes ty fall back to the nix env (Python 3.12
with all deps installed). Also set python-version = "3.12" in
[tool.ty.environment] with a comment explaining why.

ty diagnostics: 4,422 -> 3,385 (-1,037)
Tests: 496 passed, 0 failed

fix(nix): use python311 in dev shell (matches requires-python floor)

Production venv still uses python312 (nixos-unstable default). The dev
editable venv now uses python311 — the requires-python floor (>=3.11) —
so ty type checking and local dev catch 3.12+-only syntax that wouldnt
2026-07-17 15:35:38 -04:00
ethernet 0cb42c10a4 fix(types): declare AIAgent instance attributes as class-level annotations
init_agent() in agent/agent_init.py sets ~176 instance attributes on the
AIAgent instance, but ty cannot track cross-module attribute assignment
through a function that receives the instance as a plain `agent` parameter.
This caused 186 unresolved-attribute errors in run_agent.py alone.

Declaring the attributes as class-level annotations (PEP 526) tells ty
they exist, clearing 182 of 190 errors (190→8). The remaining 8 are:
- 2 duck-typed object.function accesses (hasattr-guarded, safe)
- 2 missing attrs (_current_tool, _api_call_count — added)
- 2 None-narrowing on iteration_budget (.used, .max_total)
- 2 None-narrowing on client (.close)

Overall ty diagnostics: 4,648 → 4,422 (−226)
Tests: 496 passed, 0 failed
2026-07-17 15:05:55 -04:00
ethernet 169bfe20e4 fix(types): sweep invalid-parameter-default across core modules
Add `| None` to ~250 params across 30 files that were annotated as bare
`str`, `list`, `dict`, `int`, `Callable`, etc. but defaulted to None.

This is a big typechecking fix. each one cascades, making ty stop
narrowing those vars to None and clearing downstream
unresolved-attribute / not-subscriptable / invalid-argument-type errors.

Two type bugs surfaced and fixed during the sweep:

1. Lowercase `callable` (builtin function) used as type annotation in
   28 callback params in agent_init.py + run_agent.py. `callable | None`
   isn't valid. Fixed to `Callable` (typing).

2. String forward-ref with `| None` (`"IterationBudget" | None`) is
    wrong because `|` can't OR a str with NoneType. Fixed to
   `Optional["IterationBudget"]`.

Also:
- Configure ty to exclude tests/ via [tool.ty.src] in pyproject.toml
  (tests are ~57% of diagnostics, lowest-value typing target)

ty diagnostics: 13,290 -> 4,648 (core only, tests excluded)
Tests: 496 passed, 0 failed
2026-07-17 15:04:17 -04:00
159 changed files with 1601 additions and 4263 deletions
+4 -20
View File
@@ -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:"
-80
View File
@@ -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
-5
View File
@@ -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:
+7 -13
View File
@@ -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
+4 -10
View File
@@ -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
+13 -22
View File
@@ -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:
-1
View File
@@ -9,7 +9,6 @@ permissions:
jobs:
docs-site-checks:
runs-on: ubuntu-latest
timeout-minutes: 20
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
-1
View File
@@ -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:
-2
View File
@@ -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) }}
+5 -9
View File
@@ -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
+1 -2
View File
@@ -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
+1 -5
View File
@@ -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:
+3 -13
View File
@@ -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
-11
View File
@@ -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
+2 -19
View File
@@ -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
+1 -14
View File
@@ -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
-8
View File
@@ -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
View File
@@ -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
View File
@@ -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,
+1 -1
View File
@@ -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__)
+4 -4
View File
@@ -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
View File
@@ -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.
+5 -6
View File
@@ -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
View File
@@ -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 "
-8
View File
@@ -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:
-4
View File
@@ -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."""
-70
View File
@@ -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
+2 -46
View File
@@ -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"
);
}
-5
View File
@@ -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}
+2 -10
View File
@@ -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)')
})
})
-36
View File
@@ -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>
)
}
+2 -3
View File
@@ -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)
+1 -2
View File
@@ -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')
})
-2
View File
@@ -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}`
}
-2
View File
@@ -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}`
}
-2
View File
@@ -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
}
-2
View File
@@ -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}`
}
-2
View File
@@ -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}`
}
+7 -8
View File
@@ -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()
-21
View File
@@ -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> {
+2 -40
View File
@@ -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')])
+1 -34
View File
@@ -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
-453
View File
@@ -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')
})
})
+2 -154
View File
@@ -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.
// ---------------------------------------------------------------------------
+22 -249
View File
@@ -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([])
})
})
+67 -97
View File
@@ -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([])
})
})
+176 -11
View File
@@ -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
View File
@@ -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.
+18 -18
View File
@@ -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,
-38
View File
@@ -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.
-2
View File
@@ -1,2 +0,0 @@
_placeholder
# keeps the directory present in git; not a real mapping
+2 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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():
+5 -5
View File
@@ -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:
+3 -3
View File
@@ -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
View File
@@ -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,
+1 -7
View File
@@ -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)
+4 -4
View File
@@ -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 = "",
+5 -5
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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 [
+312
View File
@@ -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 = {
+26 -55
View File
@@ -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)
+2 -19
View File
@@ -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)
-4
View File
@@ -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",
},
+4 -6
View File
@@ -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
View File
@@ -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
View File
@@ -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,
-99
View File
@@ -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())
+4 -4
View File
@@ -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
View File
@@ -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
View File
@@ -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(
+2 -80
View File
@@ -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
+1 -1
View File
@@ -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