Compare commits

..
Author SHA1 Message Date
Teknium 6f5608bed3 test(plugins): cover plugin catalog, version gate, and installer
- catalog loader validation, search, removed matching, capability summary
- _version_satisfies operators and permissive fallbacks
- requires_hermes load gate (skip vs normal load)
- config spec parsing + ctx.plugin_config merge
- _install_plugin_core ref checkout and removed-block via local file:// repos
2026-07-22 07:30:55 -07:00
Teknium dc4d991373 feat(plugins): install-time ref checkout and removed-blocklist check
- _install_plugin_core accepts ref= (full-depth clone + git checkout,
  PluginOperationError on failure) and skip_removed_check=
- installs are refused when the identifier or resolved repo URL matches
  plugin-catalog/removed.yaml, with the recorded reason and date
2026-07-22 07:30:55 -07:00
Teknium dcdb9b25e4 feat(plugins): requires_hermes gate, config spec, ctx.plugin_config
- plugin.yaml gains requires_hermes (version spec) and config: (list of
  {key, prompt, type, default, secret}) parsed onto PluginManifest
- PluginManager skips loading (clean error, no traceback) when the
  running Hermes version does not satisfy requires_hermes; local
  _version_satisfies helper supports >=,>,<=,<,==,!= and comma specs
- PluginContext.plugin_config merges config-spec defaults under
  plugins.entries.<plugin_id> values from config.yaml
2026-07-22 07:30:55 -07:00
Teknium a22d2918d6 feat(plugins): add curated plugin catalog module and in-tree catalog dir
- hermes_cli/plugin_catalog.py: loader/search/removed-blocklist for the
  new plugin-catalog/ directory (exact 40-hex SHA pins, https-only repos,
  official/community tiers; invalid entries skipped with a warning)
- plugin-catalog/: admission-policy README, example-plugin seed entry,
  and removed.yaml blocklist
2026-07-22 07:30:55 -07:00
323 changed files with 5309 additions and 37071 deletions
+3
View File
@@ -97,6 +97,9 @@ packaging/
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc
@@ -39,9 +39,6 @@ outputs:
ci_review:
description: Require CI-sensitive file review label.
value: ${{ steps.classify.outputs.ci_review }}
ci_review_files:
description: JSON list of CI-sensitive files changed by the pull request.
value: ${{ steps.classify.outputs.ci_review_files }}
runs:
using: composite
+1 -3
View File
@@ -46,7 +46,6 @@ jobs:
docker_meta: ${{ steps.classify.outputs.docker_meta }}
mcp_catalog: ${{ steps.classify.outputs.mcp_catalog }}
ci_review: ${{ steps.classify.outputs.ci_review }}
ci_review_files: ${{ steps.classify.outputs.ci_review_files }}
event_name: ${{ github.event_name }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -164,7 +163,6 @@ jobs:
uses: ./.github/workflows/review-labels.yml
with:
ci_review: ${{ needs.detect.outputs.ci_review == 'true' }}
ci_review_files: ${{ needs.detect.outputs.ci_review_files }}
mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }}
supply_chain: ${{ needs.supply-chain.outputs.critical_findings == 'true' }}
secrets: inherit
@@ -188,7 +186,7 @@ jobs:
# ─────────────────────────────────────────────────────────────────────
comment-live:
name: CI review comment (live)
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check, e2e-desktop]
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check]
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
runs-on: ubuntu-latest
timeout-minutes: 40
+24 -54
View File
@@ -2,10 +2,6 @@ name: E2E Desktop
on:
workflow_call:
outputs:
review_status:
description: Screenshot and visual-diff status for the CI review comment.
value: ${{ jobs.e2e.outputs.review_status }}
permissions:
contents: read
@@ -19,8 +15,6 @@ jobs:
name: Playwright E2E (Linux)
runs-on: ubuntu-latest
timeout-minutes: 20
outputs:
review_status: ${{ steps.review-status.outputs.review_status }}
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
@@ -64,8 +58,7 @@ jobs:
command: uv sync --locked --python 3.11 --extra all --extra dev
# ── Build desktop app ─────────────────────────────────────────────
# The Playwright step below runs `npm run build` before testing so
# dist/ is always fresh — no separate build step needed here.
- run: npm run --prefix apps/desktop build
# ── Restore visual baseline screenshots from main ──────────────────
# Baselines are generated on main (via --update-snapshots) and cached.
@@ -86,18 +79,16 @@ jobs:
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
# window always has a consistent viewport for screenshot comparison.
# On main, we run with --update-snapshots to generate baselines.
# `npm run test:e2e` builds dist/ as a pretest hook so the renderer
# is always fresh — no separate build step needed.
- name: Run Playwright E2E tests
working-directory: apps/desktop
run: |
if [ "${{ github.ref_name }}" = "main" ]; then
echo "On main — generating/updating baseline screenshots"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list --update-snapshots
else
echo "On PR — comparing against cached baselines"
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
npx playwright test --reporter=list
fi
env:
@@ -152,25 +143,6 @@ jobs:
overwrite: true
if-no-files-found: ignore
- name: Build screenshot review status
id: review-status
if: always()
working-directory: apps/desktop
env:
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
run: |
python3 ../../scripts/ci/e2e_screenshot_status.py \
--results-dir test-results \
--manifest-output /tmp/e2e-screenshot-manifest.json \
--evidence-dir /tmp/e2e-evidence \
--artifact-url "$RESULTS_URL" \
--output /tmp/e2e-review-status.json
{
echo 'review_status<<__E2E_REVIEW_STATUS__'
cat /tmp/e2e-review-status.json
echo '__E2E_REVIEW_STATUS__'
} >> "$GITHUB_OUTPUT"
# ── Generate step summary with visual diff info ───────────────────
# Parse the JSON report + scan for diff images, then post a summary
# to the GitHub Actions step output so reviewers can see what changed
@@ -184,50 +156,49 @@ jobs:
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
run: |
{
echo "## Desktop E2E — Visual Diff Report"
echo ""
echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
# Count diff images (playwright writes *-diff.png on mismatch)
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
if [ "$DIFF_COUNT" -eq 0 ]; then
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)."
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY"
else
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**"
echo ""
echo "| Test | Diff | Actual | Expected |"
echo "|------|------|--------|----------|"
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY"
echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY"
# List each diff image with a link to the artifact
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
base=${diff%-diff.png}
base=$(echo "$diff" | sed 's/-diff\.png$//')
test_name=$(basename "$base")
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |"
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
done
fi
echo ""
echo "📥 **Artifacts:**"
echo ""
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
if [ -n "$RESULTS_URL" ]; then
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces"
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$REPORT_URL" ]; then
echo "- [playwright-report]($REPORT_URL) — interactive HTML report"
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
fi
if [ -n "$DIFFS_URL" ]; then
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)"
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
fi
echo ""
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally."
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY"
# Also parse the JSON report for pass/fail counts
if [ -f playwright-report/results.json ]; then
echo ""
echo "### Test Results"
echo ""
echo "" >> "$GITHUB_STEP_SUMMARY"
echo "### Test Results" >> "$GITHUB_STEP_SUMMARY"
echo "" >> "$GITHUB_STEP_SUMMARY"
node -e "
const r = require('./playwright-report/results.json');
const stats = r.stats || {};
@@ -237,6 +208,5 @@ jobs:
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
" 2>/dev/null || true
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
fi
} >> "$GITHUB_STEP_SUMMARY"
+1 -4
View File
@@ -48,9 +48,6 @@ jobs:
--lockfile=uv.lock
--lockfile=package-lock.json
--lockfile=website/package-lock.json
# The upstream reusable workflow uploads this exact file under its
# fixed artifact name, which the wrapper downloads below.
results-file-name: osv-results.sarif
fail-on-vuln: false
emit-status:
@@ -67,7 +64,7 @@ jobs:
- name: Download SARIF result
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: OSV Scanner SARIF file
name: osv-results
path: /tmp/osv-results
continue-on-error: true
+1 -12
View File
@@ -23,10 +23,6 @@ on:
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
type: boolean
default: false
ci_review_files:
description: JSON list of CI-sensitive files changed by the pull request.
type: string
default: '[]'
mcp_catalog:
description: Whether the MCP catalog / installer changed.
type: boolean
@@ -82,25 +78,18 @@ jobs:
id: build-status
env:
CI_REVIEW: ${{ inputs.ci_review }}
CI_REVIEW_FILES: ${{ inputs.ci_review_files }}
MCP_CATALOG: ${{ inputs.mcp_catalog }}
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
BASE_SHA: ${{ github.event.pull_request.base.sha }}
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
run: |
set -euo pipefail
args=()
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
args+=(--ci-review-files "$CI_REVIEW_FILES")
if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
python3 scripts/ci/emit_review_status.py "${args[@]}" \
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
--output "$GITHUB_OUTPUT"
python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT"
- name: Fail on missing label
if: steps.label-check.outputs.ci_reviewed != 'true'
+3 -7
View File
@@ -76,7 +76,6 @@ jobs:
id: scan
env:
GH_TOKEN: ${{ steps.app-token.outputs.token }}
CI_REVIEWED: ${{ contains(github.event.pull_request.labels.*.name, 'ci-reviewed') }}
run: |
set -euo pipefail
@@ -94,7 +93,7 @@ jobs:
# --- .pth files (auto-execute on Python startup) ---
# The exact mechanism used in the litellm supply chain attack:
# https://github.com/BerriAI/litellm/issues/24512
PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
if [ -n "$PTH_FILES" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: .pth file added or modified
@@ -142,11 +141,8 @@ jobs:
# auto-loaded by the interpreter via site.py. Any nested file with the
# same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated
# and produced false positives that trained reviewers to ignore the scanner.
SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
# A maintainer-applied ci-reviewed label records the manual review
# required for intentional changes to an install hook. The scanner
# still blocks every unreviewed addition or modification.
if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
if [ -n "$SETUP_HITS" ]; then
FINDINGS="${FINDINGS}
### 🚨 CRITICAL: Install-hook file added or modified
These files can execute code during package installation or interpreter startup.
+5
View File
@@ -215,6 +215,11 @@ jobs:
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
- name: Packaged-wheel i18n smoke test
run: |
source .venv/bin/activate
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
- name: Run e2e tests
run: |
source .venv/bin/activate
+188
View File
@@ -0,0 +1,188 @@
name: Publish to PyPI
# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15)
# Can also be triggered manually from the Actions tab as an escape hatch.
on:
push:
tags:
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
workflow_dispatch:
inputs:
confirm_tag:
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
required: true
type: string
# Restrict default token to read-only; each job escalates as needed.
permissions:
contents: read
# Prevent overlapping publishes (e.g. two same-day tags pushed quickly).
concurrency:
group: pypi-publish
cancel-in-progress: false
jobs:
build:
name: Build distribution 📦
runs-on: ubuntu-latest
timeout-minutes: 30
steps:
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
persist-credentials: false
# On workflow_dispatch, check out the confirmed tag.
ref: ${{ inputs.confirm_tag || github.ref }}
fetch-tags: true
- name: Validate tag exists
if: github.event_name == 'workflow_dispatch'
run: |
if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then
echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo"
exit 1
fi
- name: Set up Python
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
with:
python-version: "3.13"
- name: Install uv
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
- name: Set up Node.js
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
with:
node-version: "22"
- name: Build web dashboard
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: web
- name: Compile web dashboard
run: npm run build
working-directory: web
- name: Build TUI bundle
uses: ./.github/actions/retry
with:
command: npm ci
working-directory: ui-tui
- name: Compile TUI bundle
run: npm run build
working-directory: ui-tui
- name: Bundle TUI into hermes_cli
run: |
mkdir -p hermes_cli/tui_dist
cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js
- name: Verify frontend assets exist
run: |
test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; }
test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; }
- name: Bundle install scripts into wheel
run: |
mkdir -p hermes_cli/scripts
cp scripts/install.sh hermes_cli/scripts/install.sh
cp scripts/install.ps1 hermes_cli/scripts/install.ps1
- name: Build wheel and sdist
run: uv build --sdist --wheel
- name: Upload distribution artifacts
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
with:
name: python-package-distributions
path: dist/
publish:
name: Publish to PyPI
needs: build
runs-on: ubuntu-latest
timeout-minutes: 30
environment:
name: pypi
url: https://pypi.org/p/hermes-agent
permissions:
id-token: write # OIDC trusted publishing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
with:
skip-existing: true
sign:
name: Sign and attach to GitHub Release
# Only runs on tag pushes — release.py creates the GitHub Release,
# and workflow_dispatch won't have a matching release to attach to.
if: startsWith(github.ref, 'refs/tags/')
needs: publish
runs-on: ubuntu-latest
timeout-minutes: 30
permissions:
contents: write # attach assets to the existing release
id-token: write # sigstore signing
steps:
- name: Download distribution artifacts
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
with:
name: python-package-distributions
path: dist/
- name: Get GitHub App token
id: app-token
uses: ./.github/actions/get-app-token
with:
client-id: ${{ secrets.APP_CLIENT_ID }}
private-key: ${{ secrets.APP_PRIVATE_KEY }}
- name: Wait for GitHub Release to exist
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# release.py creates the GitHub Release after pushing the tag,
# but this workflow starts from the tag push — wait for it.
run: |
for i in $(seq 1 30); do
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
echo "Release $GITHUB_REF_NAME found"
exit 0
fi
echo "Waiting for release... ($i/30)"
sleep 10
done
echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload"
echo "skip_sign=true" >> "$GITHUB_ENV"
- name: Sign with Sigstore
if: env.skip_sign != 'true'
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
with:
inputs: >-
./dist/*.tar.gz
./dist/*.whl
- name: Attach signed artifacts to GitHub Release
if: env.skip_sign != 'true'
env:
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
# release.py already created the GitHub Release — just upload
# the Sigstore signatures alongside the existing assets.
run: >-
gh release upload
"$GITHUB_REF_NAME" dist/*.sigstore.json
--repo "$GITHUB_REPOSITORY"
--clobber
-6
View File
@@ -150,11 +150,6 @@ docs/superpowers/*
.update-incomplete
.update-incomplete.lock
# Installer-written method stamp in the managed checkout root (scripts/install.sh).
# Runtime metadata only — never a code change. Ignore so `git status` stays clean
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
/.install_method
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
@@ -174,4 +169,3 @@ apps/desktop/demo/
# PR body is the archive. See the hermes-agent-dev skill's
# pr-infographic-workflow reference (storage rule + lapse #8 / #COMMIT-1).
infographic/
native/fts5_cjk/*.so
+14
View File
@@ -0,0 +1,14 @@
graft skills
graft optional-skills
graft optional-mcps
graft hermes_cli/web_dist
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
# below covers the wheel; this covers the sdist. See #34034 / #28149.
recursive-include plugins plugin.yaml plugin.yml
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
recursive-include gateway/assets *
global-exclude __pycache__
global-exclude *.py[cod]
+1 -1
View File
@@ -190,7 +190,7 @@ def _run_setup_browser(assume_yes: bool = False) -> int:
"""Bootstrap agent-browser + Chromium.
Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code
with the runtime lazy installer.
with ``hermes postinstall`` and the runtime lazy installer.
Returns 0 on success, 1 on failure.
"""
+25 -140
View File
@@ -74,10 +74,6 @@ from acp_adapter.permissions import make_approval_callback
from acp_adapter.provenance import session_provenance_meta
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
from acp_adapter.tools import build_tool_complete, build_tool_start
from agent.context_compressor import (
COMPRESSED_SUMMARY_METADATA_KEY,
ContextCompressor,
)
from tools.approval import (
reset_hermes_interactive_context,
set_hermes_interactive_context,
@@ -973,49 +969,11 @@ class HermesACPAgent(acp.Agent):
return text
return ""
@staticmethod
def _history_summary_meta(message: dict[str, Any], text: str) -> dict[str, Any] | None:
"""Build the ``_meta`` payload for a replayed compaction summary.
Compaction summaries are persisted as ordinary history messages —
standalone handoffs under ``role="user"`` OR ``role="assistant"``
(the compressor picks whichever role keeps alternation valid), and
merge-into-tail messages where the summary is appended after the
first preserved tail message's real content. Without a wire flag,
ACP frontends render all of these as ordinary turns.
Two distinct keys under ``_meta.hermes`` (ACP's extensibility
channel), so clients cannot accidentally hide real content:
* ``compactionSummary: true`` — the entire chunk is the handoff
summary. Safe to restyle or collapse wholesale.
* ``containsCompactionSummary: true`` — a merged-tail message: real
preserved turn content followed by the summary. Clients may style
it, but collapsing the whole chunk would hide the preserved
content, hence the separate key.
Detection honors the in-process ``_compressed_summary`` flag and
falls back to content classification, so it also works for a
DB-reloaded session that lost the in-memory flag.
"""
kind = ContextCompressor.classify_summary_content(text)
if kind is None and message.get(COMPRESSED_SUMMARY_METADATA_KEY):
# Flagged in-process but content didn't classify (e.g. future
# prefix drift): treat as a standalone summary — the flag is only
# ever set on summary-bearing messages.
kind = "standalone"
if kind == "standalone":
return {"hermes": {"compactionSummary": True}}
if kind == "merged":
return {"hermes": {"containsCompactionSummary": True}}
return None
@staticmethod
def _history_message_update(
*,
role: str,
text: str,
field_meta: dict[str, Any] | None = None,
) -> UserMessageChunk | AgentMessageChunk | None:
"""Build an ACP history replay update for a user/assistant message."""
block = TextContentBlock(type="text", text=text)
@@ -1023,13 +981,11 @@ class HermesACPAgent(acp.Agent):
return UserMessageChunk(
session_update="user_message_chunk",
content=block,
field_meta=field_meta,
)
if role == "assistant":
return AgentMessageChunk(
session_update="agent_message_chunk",
content=block,
field_meta=field_meta,
)
return None
@@ -1100,11 +1056,7 @@ class HermesACPAgent(acp.Agent):
if role == "user":
text = self._history_message_text(message)
if text:
update = self._history_message_update(
role=role,
text=text,
field_meta=self._history_summary_meta(message, text),
)
update = self._history_message_update(role=role, text=text)
if update is not None and not await _send(update):
return
continue
@@ -1116,11 +1068,7 @@ class HermesACPAgent(acp.Agent):
text = self._history_message_text(message)
if text:
update = self._history_message_update(
role=role,
text=text,
field_meta=self._history_summary_meta(message, text),
)
update = self._history_message_update(role=role, text=text)
if update is not None and not await _send(update):
return
@@ -1270,19 +1218,12 @@ class HermesACPAgent(acp.Agent):
with state.runtime_lock:
if state.is_running and state.current_prompt_text:
state.interrupted_prompt_text = state.current_prompt_text
# Publish cancellation and hard-stop the agent before another
# prompt can acquire this lock and mistake the turn for
# redirectable work.
state.cancel_event.set()
try:
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
state.agent.interrupt()
except Exception:
logger.debug(
"Failed to interrupt ACP session %s",
session_id,
exc_info=True,
)
state.cancel_event.set()
try:
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
state.agent.interrupt()
except Exception:
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
logger.info("Cancelled session %s", session_id)
async def fork_session(
@@ -1411,26 +1352,6 @@ class HermesACPAgent(acp.Agent):
elif rewrite_idle:
user_text = steer_text
user_content = steer_text
elif (
text_only_prompt
and isinstance(user_content, str)
and not user_text.startswith("/")
):
# Some ACP clients implement "stop and send" as two protocol calls:
# cancel the active prompt, then submit plain correction text. Keep
# the cancelled request attached so deictic follow-ups ("not that
# file") still have an explicit target.
interrupted_prompt = ""
with state.runtime_lock:
if not state.is_running and state.interrupted_prompt_text:
interrupted_prompt = state.interrupted_prompt_text
state.interrupted_prompt_text = ""
if interrupted_prompt:
user_text = (
f"{interrupted_prompt}\n\n"
f"User correction/guidance after interrupt: {user_text}"
)
user_content = user_text
# Intercept slash commands — handle locally without calling the LLM.
# Slash commands are text-only; if the client included images/resources,
@@ -1445,54 +1366,23 @@ class HermesACPAgent(acp.Agent):
await self._send_usage_update(state)
return PromptResponse(stop_reason="end_turn")
# If the client sends another regular text prompt while this ACP session
# is running, route it through the core active-turn redirect. Rich media
# and older runtimes retain the proven next-turn queue fallback.
redirected = False
queued_depth: int | None = None
# If Zed sends another regular prompt while the same ACP session is
# still running, queue it instead of racing two AIAgent loops against
# the same state.history. /steer and /queue are handled above and can
# land immediately.
with state.runtime_lock:
if state.is_running:
if (
text_only_prompt
and isinstance(user_content, str)
and getattr(
state.agent,
"_supports_active_turn_redirect",
False,
queued_text = user_text or "[Image attachment]"
state.queued_prompts.append(queued_text)
depth = len(state.queued_prompts)
if self._conn:
update = acp.update_agent_message_text(
f"Queued for the next turn. ({depth} queued)"
)
is True
and hasattr(state.agent, "redirect")
):
try:
redirected = bool(state.agent.redirect(user_content))
except Exception:
logger.debug(
"ACP active-turn redirect failed for %s",
session_id,
exc_info=True,
)
if not redirected:
queued_text = user_text or "[Image attachment]"
state.queued_prompts.append(queued_text)
queued_depth = len(state.queued_prompts)
else:
state.is_running = True
state.current_prompt_text = user_text or "[Image attachment]"
if redirected:
if self._conn:
update = acp.update_agent_message_text(
"Redirected the active turn with your correction."
)
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
if queued_depth is not None:
if self._conn:
update = acp.update_agent_message_text(
f"Queued for the next turn. ({queued_depth} queued)"
)
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
await self._conn.session_update(session_id, update)
return PromptResponse(stop_reason="end_turn")
state.is_running = True
state.current_prompt_text = user_text or "[Image attachment]"
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
@@ -2021,10 +1911,7 @@ class HermesACPAgent(acp.Agent):
lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens")
if getattr(agent, "compression_enabled", True) is False:
lines.append(
"Auto-compaction is disabled (compression.enabled: false); "
"/compress still compresses manually."
)
lines.append("Compression is disabled for this agent.")
else:
lines.append("Tip: run /compress to compress manually before the threshold.")
@@ -2051,9 +1938,8 @@ class HermesACPAgent(acp.Agent):
return "Nothing to compress — conversation is empty."
try:
agent = state.agent
# No compression_enabled gate: the flag disables *automatic*
# compaction only; manual /compress must keep working (matches
# the CLI /compress and gateway handlers).
if not getattr(agent, "compression_enabled", True):
return "Context compression is disabled for this agent."
if not hasattr(agent, "_compress_context"):
return "Context compression not available for this agent."
@@ -2078,7 +1964,6 @@ class HermesACPAgent(acp.Agent):
getattr(agent, "_cached_system_prompt", "") or "",
approx_tokens=approx_tokens,
task_id=state.session_id,
force=True,
)
finally:
agent._session_db = original_session_db
+16
View File
@@ -0,0 +1,16 @@
{
"id": "hermes-agent",
"name": "Hermes Agent",
"version": "0.19.0",
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
"repository": "https://github.com/NousResearch/hermes-agent",
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
"authors": ["Nous Research"],
"license": "MIT",
"distribution": {
"uvx": {
"package": "hermes-agent[acp]==0.19.0",
"args": ["hermes-acp"]
}
}
}
+8
View File
@@ -0,0 +1,8 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 882 B

-12
View File
@@ -701,18 +701,6 @@ def redeem_codex_reset_credit(
remaining = max(0, available - 1)
plural = "s" if remaining != 1 else ""
if code == "reset":
# The redeemed reset restores the account's quota upstream — lift any
# persisted pool cooldowns so Hermes doesn't keep the credential
# frozen behind the now-stale ``last_error_reset_at`` (issue #43747).
try:
from hermes_cli.auth import clear_codex_pool_quota_cooldowns
clear_codex_pool_quota_cooldowns()
except Exception:
logger.debug(
"Failed to clear Codex pool cooldowns after reset redemption",
exc_info=True,
)
return CodexResetRedeemResult(
status="reset",
message=(
+1 -42
View File
@@ -720,8 +720,6 @@ def init_agent(
agent._execution_thread_id: int | None = None # Set at run_conversation() start
agent._interrupt_thread_signal_pending = False
agent._client_lock = threading.RLock()
agent._model_request_active = threading.Event()
agent._supports_active_turn_redirect = True
# /steer mechanism — inject a user note into the next tool result
# without interrupting the agent. Unlike interrupt(), steer() does
@@ -733,13 +731,6 @@ def init_agent(
agent._pending_steer: Optional[str] = None
agent._pending_steer_lock = threading.Lock()
# Active-turn redirect mechanism. A regular follow-up sent while the model
# is generating is different from a hard /stop: preserve the valid turn
# prefix, cancel only the in-flight model request, and rebuild its tail with
# the correction. The loop drains this slot at a role-safe boundary.
agent._pending_redirect: Optional[str] = None
agent._pending_redirect_lock = threading.Lock()
# Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent`
# runs each tool on its own ThreadPoolExecutor worker — those worker
# threads have tids distinct from `_execution_thread_id`, so
@@ -906,12 +897,6 @@ def init_agent(
agent._stream_writer_tls = threading.local()
agent._stream_writer_dropped = 0
# Displayed reasoning text streamed during the current model response,
# captured only when a surface consumed it via a reasoning callback. Used
# by active-turn redirect to checkpoint what the user actually saw without
# ever persisting hidden provider reasoning.
agent._current_streamed_reasoning_text = ""
# Optional current-turn user-message override used when the API-facing
# user message intentionally differs from the persisted transcript
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
@@ -1854,18 +1839,6 @@ def init_agent(
}
else:
compression_model_thresholds = {}
# Absolute token cap: when set, compression triggers at the lower of
# the ratio-based threshold and this absolute count. Clamped to the
# model's context length at apply-time so a cap above the window is
# a no-op (ratio-based threshold wins).
compression_threshold_tokens = _compression_cfg.get("threshold_tokens")
if compression_threshold_tokens is not None:
try:
compression_threshold_tokens = int(compression_threshold_tokens)
if compression_threshold_tokens <= 0:
compression_threshold_tokens = None
except (TypeError, ValueError):
compression_threshold_tokens = None
# In-place compaction: when True, compress_context() rewrites the message
# list + rebuilds the system prompt WITHOUT rotating the session id (no
# parent_session_id chain, no `name #N` renumber). See #38763 and
@@ -1884,12 +1857,6 @@ def init_agent(
codex_app_server_auto_compaction,
)
codex_app_server_auto_compaction = "native"
# Opt-in idle compaction: compact a session up front when it resumes after
# this many seconds of inactivity (0 = disabled). Time-based, so it
# complements the size-based threshold above. Consumed by build_turn_context().
compression_idle_compact_after_seconds = max(
0, int(_compression_cfg.get("idle_compact_after_seconds", 0))
)
# Read optional explicit context_length override for the auxiliary
# compression model. Custom endpoints often cannot report this via
@@ -2304,7 +2271,6 @@ def init_agent(
abort_on_summary_failure=compression_abort_on_summary_failure,
max_tokens=agent.max_tokens,
model_thresholds=compression_model_thresholds,
threshold_tokens_cap=compression_threshold_tokens,
)
_bind_session_state = getattr(agent.context_compressor, "bind_session_state", None)
if callable(_bind_session_state):
@@ -2316,9 +2282,6 @@ def init_agent(
agent.compression_in_place = compression_in_place
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
agent.max_compression_attempts = compression_max_attempts
agent.compression_idle_compact_after_seconds = (
compression_idle_compact_after_seconds
)
# Reject models whose context window is below the minimum required
# for reliable tool-calling workflows (64K tokens).
@@ -2519,11 +2482,7 @@ def init_agent(
_active_threshold_pct = getattr(
agent.context_compressor, "threshold_percent", compression_threshold
)
_cap_note = ""
_cap = getattr(agent.context_compressor, "threshold_tokens_cap", None)
if _cap and _cap > 0:
_cap_note = f" (capped at {_cap:,} tokens)"
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,}{_cap_note})")
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (compress at {int(_active_threshold_pct*100)}% = {agent.context_compressor.threshold_tokens:,})")
else:
print(f"📊 Context limit: {agent.context_compressor.context_length:,} tokens (auto-compression disabled)")
# Notice with the exact opt-back-out command. Printed inline at startup
-34
View File
@@ -702,16 +702,6 @@ def run_codex_app_server_turn(
except Exception:
pass
agent._codex_session = None
_user_interrupted = bool(
getattr(agent, "_interrupt_requested", False)
)
_interrupt_message = (
getattr(agent, "_interrupt_message", None)
if _user_interrupted
else None
)
if _user_interrupted:
agent.clear_interrupt()
return {
"final_response": (
f"Codex app-server turn failed: {exc}. "
@@ -721,27 +711,9 @@ def run_codex_app_server_turn(
"api_calls": 0,
"completed": False,
"partial": True,
"interrupted": _user_interrupted,
**(
{"interrupt_message": _interrupt_message}
if _interrupt_message
else {}
),
"error": str(exc),
}
# This runtime bypasses the normal conversation-loop finalizer. Mirror its
# interrupt handoff/cleanup so a hard stop cannot poison the next turn and a
# message-bearing compatibility interrupt can still be replayed by callers.
_user_interrupted = bool(
turn.interrupted and getattr(agent, "_interrupt_requested", False)
)
_interrupt_message = (
getattr(agent, "_interrupt_message", None) if _user_interrupted else None
)
if _user_interrupted:
agent.clear_interrupt()
# If the turn signalled the underlying client is wedged (deadline
# blown, post-tool watchdog tripped, OAuth refresh died, subprocess
# exited), retire the session so the next turn respawns codex
@@ -847,12 +819,6 @@ def run_codex_app_server_turn(
"api_calls": api_calls,
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"interrupted": _user_interrupted,
**(
{"interrupt_message": _interrupt_message}
if _interrupt_message
else {}
),
"error": turn.error,
# The codex app-server runtime IS an early-return path that bypasses
# conversation_loop, but we flush the projected assistant/tool messages
File diff suppressed because it is too large Load Diff
+1 -194
View File
@@ -30,12 +30,10 @@ from __future__ import annotations
import copy
import inspect
import json
import logging
import math
import os
import tempfile
import time
import uuid
import threading
from datetime import datetime
@@ -57,71 +55,6 @@ COMPACTION_STATUS = (
f"🗜️ {COMPACTION_STATUS_MARKER} — summarizing earlier conversation so I can continue..."
)
COMPACTION_DONE_STATUS = "✓ Context compaction complete — continuing turn..."
def _emit_compaction_done(agent: Any) -> None:
"""Emit the structured terminal edge for a started compaction."""
status_callback = getattr(agent, "status_callback", None)
if not status_callback:
return
try:
status_callback("compacted", COMPACTION_DONE_STATUS)
except Exception:
logger.debug("status_callback error in compaction completion", exc_info=True)
# ── Routine compression status templates ────────────────────────────────────
# Every ROUTINE (non-failure, non-manual-/compress) compression status line the
# agent emits lives here so the gateway noise filter and its tests can couple
# to the real emitted wording instead of hand-copied literals. These are
# suppressed on human-facing chat platforms by _TELEGRAM_NOISY_STATUS_RE
# (gateway/run.py) — when rewording ANY of them, update that regex and the
# pinned data in tests/gateway/test_telegram_noise_filter.py in the same PR.
# Failure notices (⚠ Compression aborted / empty transcript / codex compaction
# failed) and manual /compress feedback (manual_compression_feedback.py) are
# deliberate carve-outs from silence and must NOT be added here.
PRE_API_COMPRESSION_STATUS_TEMPLATE = (
"📦 Pre-API compression: ~{tokens:,} tokens "
"near the context/output limit. Compacting before the next model call."
)
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE = (
"📦 Preflight compression: ~{tokens:,} tokens "
">= {threshold:,} threshold. This may take a moment."
)
IDLE_COMPACTION_STATUS_TEMPLATE = (
"💤 Resumed after {idle_seconds}s idle — compacting "
"~{tokens:,} tokens before continuing."
)
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE = (
"🗜️ Context too large (~{tokens:,} tokens) — compressing ({attempt}/{cap})..."
)
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE = (
"🗜️ Compressed {before} → {after} messages, retrying..."
)
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE = (
"🗜️ Compressed ~{before:,} → ~{after:,} tokens, retrying..."
)
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE = (
"🗜️ Context reduced to {new_ctx:,} tokens (was {old_ctx:,}), retrying..."
)
# Sample-formatted instances of every routine compression status line, for
# behavioral tests that iterate the ACTUAL emitted wording (formatted from the
# same constants the emission sites use) through the gateway noise filter.
ROUTINE_COMPRESSION_STATUS_SAMPLES = (
COMPACTION_STATUS,
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(tokens=123456),
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(tokens=120000, threshold=100000),
IDLE_COMPACTION_STATUS_TEMPLATE.format(idle_seconds=3600, tokens=120000),
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=250000, attempt=1, cap=3),
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=30, after=12),
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=250000, after=120000),
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format(
new_ctx=120000, old_ctx=250000
),
)
def _builtin_memory_prompt_snapshot(agent: Any) -> Optional[Tuple[str, str]]:
"""Return the built-in memory text that can affect a system prompt.
@@ -240,43 +173,6 @@ def _session_was_rotated_by_compression(session_db: Any, session_id: str) -> boo
)
def _emit_compression_attempt_telemetry(
agent: Any,
*,
started_at: float,
commit_status: str,
split_status: str,
failure_class: str | None = None,
) -> None:
"""Emit one content-free JSON log line for a compression attempt."""
try:
telemetry = getattr(agent.context_compressor, "_last_compression_telemetry", None)
if not isinstance(telemetry, dict):
telemetry = {}
payload = dict(telemetry)
payload.setdefault("event", "compression_attempt")
payload.setdefault("attempt_id", getattr(agent, "_compression_attempt_id", "") or uuid.uuid4().hex)
payload.setdefault("session_id", getattr(agent, "session_id", "") or "")
payload["total_duration_ms"] = int((time.monotonic() - started_at) * 1000)
payload["commit_status"] = commit_status
payload["split_status"] = split_status
if failure_class:
payload["failure_class"] = failure_class
payload.setdefault("chunking", False)
payload.setdefault("chunk_count", 0)
payload["fallback_used"] = bool(
payload.get("fallback_used")
or getattr(agent.context_compressor, "_last_summary_fallback_used", False)
or getattr(agent.context_compressor, "_last_aux_model_failure_model", None)
)
logger.info(
"context compression attempt telemetry: %s",
json.dumps(payload, sort_keys=True, separators=(",", ":")),
)
except Exception as exc:
logger.debug("failed to emit compression attempt telemetry: %s", exc)
def _compression_lock_holder(agent: Any) -> str:
"""Build a unique holder id for the lock: pid:tid:agent-instance:uuid.
@@ -1008,19 +904,6 @@ def compress_context(
):
raise RuntimeError("a compression notification is already pending")
_attempt_started_at = time.monotonic()
_attempt_id = uuid.uuid4().hex
_trigger_source = "manual" if force else "auto"
try:
agent._compression_attempt_id = _attempt_id
setattr(agent.context_compressor, "_compression_telemetry_seed", {
"attempt_id": _attempt_id,
"session_id": agent.session_id or "",
"trigger_source": _trigger_source,
})
except Exception:
pass
# Codex app-server sessions: the codex agent owns the real thread context;
# Hermes' summarizer would only rewrite a local mirror without shrinking
# the actual thread (#36801). Route compaction to the app server's own
@@ -1090,14 +973,6 @@ def compress_context(
focus_topic,
)
agent._emit_status(COMPACTION_STATUS)
_compaction_done_emitted = False
def _complete_compaction_lifecycle() -> None:
nonlocal _compaction_done_emitted
if _compaction_done_emitted:
return
_compaction_done_emitted = True
_emit_compaction_done(agent)
# ── Compression lock ────────────────────────────────────────────────
# Atomic, state.db-backed lock per session_id. Without this, two
@@ -1235,26 +1110,12 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
try:
if hasattr(agent.context_compressor, "_begin_compression_telemetry"):
agent.context_compressor._begin_compression_telemetry(current_tokens=approx_tokens)
except Exception:
pass
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class="lock_contended",
)
_complete_compaction_lifecycle()
return messages, _existing_sp
_lock_released = False
def _release_lock() -> None:
"""Release the lock keyed on the OLD session_id (before rotation)."""
nonlocal _lock_released
_complete_compaction_lifecycle()
if _lock_released:
return
_lock_released = True
@@ -1374,7 +1235,7 @@ def compress_context(
messages_before_compression = copy.deepcopy(messages)
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
compressed = compress_fn(messages, **compress_kwargs)
except BaseException as _compress_exc:
except BaseException:
# ANY exception after lock acquisition — memory hook, capability
# inspection, engine lookup, or compress() — must release the lock so
# the session isn't permanently blocked from future compression.
@@ -1382,13 +1243,6 @@ def compress_context(
_activity_heartbeat.stop("context compression failed")
_activity_heartbeat = None
_release_lock()
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class=f"exception:{type(_compress_exc).__name__}",
)
raise
finally:
if _activity_heartbeat is not None:
@@ -1424,16 +1278,6 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class=(
getattr(agent.context_compressor, "_last_summary_error", None)
and "summary_generation_aborted"
),
)
return messages, _existing_sp
finally:
_release_lock()
@@ -1452,13 +1296,6 @@ def compress_context(
_existing_sp = getattr(agent, "_cached_system_prompt", None)
if not _existing_sp:
_existing_sp = agent._build_system_prompt(system_message)
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status="aborted",
split_status="aborted",
failure_class="no_progress",
)
_release_lock()
return messages, _existing_sp
@@ -1540,9 +1377,7 @@ def compress_context(
agent._cached_system_prompt = new_system_prompt
_session_commit_succeeded = False
split_status = "not_applicable"
if agent._session_db:
split_status = "pending"
try:
# Trigger memory extraction on the current session before the
# transcript is rewritten (runs in BOTH modes — the logical
@@ -1570,7 +1405,6 @@ def compress_context(
# WITHOUT destroying history, unlike a hard replace_messages).
# See #38763.
agent._session_db.archive_and_compact(agent.session_id, compressed)
split_status = "in_place_committed"
# Reset the flush identity set so the next turn's appends are
# diffed against the COMPACTED transcript: the compacted dicts
# are passed as conversation_history next turn and skipped by
@@ -1687,7 +1521,6 @@ def compress_context(
agent._session_db_created = True
raise
agent._session_db_created = True
split_status = "rotated_committed"
# Carry a persistent /goal onto the continuation session.
# Compression mints a fresh child id; load_goal does a flat
# per-session lookup with no parent walk, so without this an
@@ -1726,7 +1559,6 @@ def compress_context(
}
_session_commit_succeeded = True
except Exception as e:
split_status = "aborted" if locals().get("old_session_id") is None and not in_place else "failed_not_indexed"
# If the rotation rolled back to the parent (orphan-avoidance
# above), agent.session_id is the still-indexed parent and
# old_session_id was cleared — so this is recovery, not an
@@ -1869,18 +1701,6 @@ def compress_context(
agent.session_id or "none", _pre_msg_count, len(compressed),
f"{_compressed_est:,}",
)
_commit_status = "committed" if split_status in {"not_applicable", "in_place_committed", "rotated_committed"} else "aborted"
_emit_compression_attempt_telemetry(
agent,
started_at=_attempt_started_at,
commit_status=_commit_status,
split_status=split_status,
failure_class=(
"session_split_failed"
if split_status in {"failed_not_indexed", "aborted"}
else None
),
)
return compressed, new_system_prompt
finally:
# Release the lock on the OLD session_id only AFTER rotation completed
@@ -1951,15 +1771,6 @@ def _compress_context_via_codex_app_server(
except Exception:
pass
_compaction_done_emitted = False
def _complete_compaction_lifecycle() -> None:
nonlocal _compaction_done_emitted
if _compaction_done_emitted:
return
_compaction_done_emitted = True
_emit_compaction_done(agent)
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
try:
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
@@ -1967,7 +1778,6 @@ def _compress_context_via_codex_app_server(
except BaseException:
if _activity_heartbeat is not None:
_activity_heartbeat.stop("context compression failed")
_complete_compaction_lifecycle()
raise
if getattr(result, "interrupted", False) or getattr(result, "error", None):
@@ -1992,7 +1802,6 @@ def _compress_context_via_codex_app_server(
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
_complete_compaction_lifecycle()
return messages, existing_prompt
try:
@@ -2032,7 +1841,6 @@ def _compress_context_via_codex_app_server(
existing_prompt = getattr(agent, "_cached_system_prompt", None)
if not existing_prompt:
existing_prompt = agent._build_system_prompt(system_message)
_complete_compaction_lifecycle()
return messages, existing_prompt
@@ -2293,7 +2101,6 @@ def try_shrink_image_parts_in_messages(
__all__ = [
"COMPACTION_STATUS",
"COMPACTION_DONE_STATUS",
"COMPACTION_STATUS_MARKER",
"check_compression_model_feasibility",
"replay_compression_warning",
+26 -147
View File
@@ -28,14 +28,7 @@ import uuid
from typing import Any, Dict, List, Optional
from agent.codex_responses_adapter import _summarize_user_message_for_log
from agent.conversation_compression import (
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE,
COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE,
COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE,
PRE_API_COMPRESSION_STATUS_TEMPLATE,
conversation_history_after_compression,
)
from agent.conversation_compression import conversation_history_after_compression
from agent.display import KawaiiSpinner
from agent.error_classifier import FailoverReason, classify_api_error
from agent.iteration_budget import IterationBudget
@@ -110,53 +103,6 @@ _API_CALL_MODULES = frozenset({
})
def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None:
"""Append a provider-safe checkpoint and correction to the live turn.
Incomplete provider reasoning blocks are not valid replay items (Anthropic
signs them; Responses reasoning items require their following output).
Preserve only what Hermes actually displayed, demoted to ordinary text,
then add the correction as a real user message. This keeps role alternation
valid and leaves every previously cached message byte-for-byte unchanged.
"""
reasoning = str(
getattr(agent, "_current_streamed_reasoning_text", "") or ""
).strip()
visible = agent._strip_think_blocks(
getattr(agent, "_current_streamed_assistant_text", "") or ""
).strip()
checkpoint_parts = ["[This response was interrupted by a user correction.]"]
if reasoning:
checkpoint_parts.extend(
["Reasoning shown before the interruption:", reasoning]
)
if visible:
checkpoint_parts.extend(
["Visible response before the interruption:", visible]
)
checkpoint = "\n\n".join(checkpoint_parts)
# The normal live tail is user or tool, so an assistant checkpoint followed
# by the correction preserves strict alternation. If a transport already
# committed an assistant item, attribute the checkpoint inside the user
# correction instead of creating assistant→assistant.
if messages and messages[-1].get("role") == "assistant":
correction = (
"[Context from the interrupted assistant response]\n"
f"{checkpoint}\n\n"
f"{text}"
)
messages.append({"role": "user", "content": correction})
else:
messages.append({"role": "assistant", "content": checkpoint})
messages.append({"role": "user", "content": text})
agent._current_streamed_assistant_text = ""
agent._current_streamed_reasoning_text = ""
agent._stream_needs_break = True
def _image_error_max_dimension(error: Exception) -> Optional[int]:
"""Extract a provider-reported image dimension ceiling, if present."""
parts = []
@@ -787,16 +733,6 @@ def run_conversation(
)
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
_redirect_text = agent._drain_pending_redirect()
if _redirect_text:
_apply_active_turn_redirect(agent, messages, _redirect_text)
if isinstance(original_user_message, str):
original_user_message = (
f"{original_user_message}\n\n"
f"User correction during the turn: {_redirect_text}"
)
agent._persist_session(messages, conversation_history)
# Reset per-turn checkpoint dedup so each iteration can take one snapshot
agent._checkpoint_mgr.new_turn()
@@ -1286,9 +1222,8 @@ def run_conversation(
max_compression_attempts,
)
agent._emit_status(
PRE_API_COMPRESSION_STATUS_TEMPLATE.format(
tokens=request_pressure_tokens
)
f"📦 Pre-API compression: ~{request_pressure_tokens:,} tokens "
f"near the context/output limit. Compacting before the next model call."
)
_last_preflight_pressure = request_pressure_tokens
messages, active_system_prompt = agent._compress_context(
@@ -1597,59 +1532,22 @@ def run_conversation(
from hermes_cli.middleware import run_llm_execution_middleware
_model_request_active = getattr(agent, "_model_request_active", None)
_redirect_lock = getattr(agent, "_pending_redirect_lock", None)
if _redirect_lock is not None:
with _redirect_lock:
if _model_request_active is not None:
_model_request_active.set()
elif _model_request_active is not None:
_model_request_active.set()
_redirect_crossed_response = False
try:
response = run_llm_execution_middleware(
api_kwargs,
_perform_api_call,
original_request=_original_api_kwargs,
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
middleware_trace=list(_llm_middleware_trace),
)
finally:
if _redirect_lock is not None:
with _redirect_lock:
if _model_request_active is not None:
_model_request_active.clear()
_redirect_crossed_response = bool(
agent._pending_redirect
)
else:
if _model_request_active is not None:
_model_request_active.clear()
_redirect_crossed_response = agent._has_pending_redirect()
if _redirect_crossed_response:
# The response and redirect can cross on different threads:
# redirect() observed the request as active just before this
# call returned. Discard that now-stale response and rebuild
# from the correction rather than silently losing it.
if thinking_spinner:
thinking_spinner.stop("")
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
else:
interrupted = True
break
response = run_llm_execution_middleware(
api_kwargs,
_perform_api_call,
original_request=_original_api_kwargs,
task_id=effective_task_id,
turn_id=turn_id,
api_request_id=api_request_id,
session_id=agent.session_id or "",
platform=agent.platform or "",
model=agent.model,
provider=agent.provider,
base_url=agent.base_url,
api_mode=agent.api_mode,
api_call_count=api_call_count,
middleware_trace=list(_llm_middleware_trace),
)
api_duration = time.time() - api_start_time
@@ -2611,15 +2509,6 @@ def run_conversation(
thinking_spinner = None
if agent.thinking_callback:
agent.thinking_callback("")
if agent._has_pending_redirect():
# redirect() deliberately used the interrupt machinery to
# cancel only this provider request. Keep its correction
# queued, clear the cancellation bit, and let the outer
# loop rebuild a clean request tail. Never materialize
# incomplete signed/encrypted reasoning items.
if agent.clear_interrupt(preserve_redirect=True):
_retry.restart_with_redirected_messages = True
break
api_elapsed = time.time() - api_start_time
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
interrupted = True
@@ -3502,9 +3391,8 @@ def run_conversation(
)
if len(messages) < original_len or old_ctx > _reduced_ctx:
agent._buffer_status(
COMPRESSION_RETRY_CONTEXT_REDUCED_STATUS_TEMPLATE.format(
new_ctx=_reduced_ctx, old_ctx=old_ctx
)
f"🗜️ Context reduced to {_reduced_ctx:,} tokens "
f"(was {old_ctx:,}), retrying..."
)
time.sleep(2)
_retry.restart_with_compressed_messages = True
@@ -3765,9 +3653,9 @@ def run_conversation(
if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95):
if len(messages) < original_len:
agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
else:
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
time.sleep(2) # Brief pause between compression retries
_retry.restart_with_compressed_messages = True
break
@@ -3985,7 +3873,7 @@ def run_conversation(
"failed": True,
"compression_exhausted": True,
}
agent._buffer_status(COMPRESSION_RETRY_TOO_LARGE_STATUS_TEMPLATE.format(tokens=approx_tokens, attempt=compression_attempts, cap=max_compression_attempts))
agent._buffer_status(f"🗜️ Context too large (~{approx_tokens:,} tokens) — compressing ({compression_attempts}/{max_compression_attempts})...")
original_len = len(messages)
original_tokens = estimate_messages_tokens_rough(messages)
@@ -4006,9 +3894,9 @@ def run_conversation(
if len(messages) < original_len or (new_tokens > 0 and new_tokens < original_tokens * 0.95) or (new_ctx and new_ctx < old_ctx):
if len(messages) < original_len:
agent._buffer_status(COMPRESSION_RETRY_MESSAGES_STATUS_TEMPLATE.format(before=original_len, after=len(messages)))
agent._buffer_status(f"🗜️ Compressed {original_len} → {len(messages)} messages, retrying...")
elif new_tokens > 0 and new_tokens < original_tokens * 0.95:
agent._buffer_status(COMPRESSION_RETRY_TOKENS_STATUS_TEMPLATE.format(before=original_tokens, after=new_tokens))
agent._buffer_status(f"🗜️ Compressed ~{original_tokens:,} → ~{new_tokens:,} tokens, retrying...")
time.sleep(2) # Brief pause between compression retries
_retry.restart_with_compressed_messages = True
break
@@ -4569,15 +4457,6 @@ def run_conversation(
f"{int(sleep_end - time.time())}s remaining"
)
if _retry.restart_with_redirected_messages:
# The cancelled request produced no valid assistant item. Reuse the
# same logical iteration after the outer loop appends the displayed
# partial context and correction to ``messages``.
api_call_count -= 1
agent.iteration_budget.refund()
_retry.restart_with_redirected_messages = False
continue
# If the API call was interrupted, skip response processing
if interrupted:
_turn_exit_reason = "interrupted_during_api_call"
+1 -49
View File
@@ -1484,43 +1484,6 @@ class CredentialPool:
self._sync_device_code_entry_to_auth_store(updated)
return updated
def _codex_quota_restored_upstream(self, entry: PooledCredential) -> bool:
"""Live-check whether an exhausted Codex entry's quota reset early.
A Codex 429 persists a ``last_error_reset_at`` that can be days in
the future (weekly windows), but the upstream window can reopen
before then — the user redeems a banked rate-limit reset via the
Codex CLI / ChatGPT UI, upgrades their plan, or OpenAI resets the
window. Without this check the pool keeps the credential frozen
until the stale timestamp elapses even though the account is
usable (issue #43747).
Only fires for openai-codex entries frozen by a 429/quota-shaped
error. The underlying probe is throttled per token (5 min) so this
is safe on the hot selection path.
"""
if self.provider != "openai-codex" or entry.last_status != STATUS_EXHAUSTED:
return False
if not auth_mod._is_codex_rate_limit_shaped(
entry.last_error_code,
entry.last_error_reason,
entry.last_error_message,
):
return False
token = entry.access_token or ""
if not token:
return False
try:
return bool(
auth_mod._probe_codex_quota_restored(
token,
base_url=entry.base_url,
)
)
except Exception:
logger.debug("Codex quota-restored probe failed", exc_info=True)
return False
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
if entry.auth_type != AUTH_TYPE_OAUTH:
return False
@@ -1642,18 +1605,7 @@ class CredentialPool:
if entry.last_status == STATUS_EXHAUSTED:
exhausted_until = _exhausted_until(entry)
if exhausted_until is not None and now < exhausted_until:
# Codex quota windows can reopen EARLY: the user redeems a
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
# their plan, or OpenAI resets the window. The persisted
# ``last_error_reset_at`` can then be days in the future
# while the account is already usable again — a throttled
# live probe of the Codex usage endpoint detects that and
# lifts the stale cooldown (issue #43747).
if not (
clear_expired
and self._codex_quota_restored_upstream(entry)
):
continue
continue
if clear_expired:
cleared = replace(
entry,
+25 -1
View File
@@ -32,6 +32,7 @@ from __future__ import annotations
import logging
import os
import sysconfig
import threading
from functools import lru_cache
from pathlib import Path
@@ -91,8 +92,12 @@ def _locales_dir() -> Path:
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
sealed-packaging system) to point at the installed catalog directory.
2. ``<repo-root>/locales`` -- source checkouts and editable installs,
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
where the working tree sits next to ``agent/``.
3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.
setuptools ``data-files`` extracts ``locales/*.yaml`` under the
interpreter's ``data`` scheme; the other schemes are checked as a
safety net for nonstandard layouts.
Falling through to the source-style path (even when missing) keeps
``_load_catalog`` error messages informative -- it logs the path it
@@ -111,6 +116,25 @@ def _locales_dir() -> Path:
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
source_dir = Path(__file__).resolve().parent.parent / "locales"
if source_dir.is_dir():
return source_dir
# pip wheel install: data-files lands under the interpreter data scheme.
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
# both are out of scope; see the plan header.
for scheme in ("data", "purelib", "platlib"):
raw = sysconfig.get_path(scheme)
if not raw:
continue
candidate = Path(raw) / "locales"
if candidate.is_dir():
return candidate
# Last resort: return the source-style path so _load_catalog's catalog-missing
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
return source_dir
-13
View File
@@ -52,13 +52,6 @@ def busy_input_hint_gateway(mode: str) -> str:
"Send `/busy interrupt` or `/busy queue` to change this, or "
"`/busy status` to check. This notice won't appear again."
)
if mode == "redirect":
return (
"💡 First-time tip — I redirected the current run using your message. "
"Completed work stays in context, and `/stop` still cancels the task. "
"Send `/busy queue` to wait for a separate turn, or `/busy status` "
"to check. This notice won't appear again."
)
return (
"💡 First-time tip — I just interrupted my current task to answer you. "
"Send `/busy queue` to queue follow-ups for after the current task instead, "
@@ -81,12 +74,6 @@ def busy_input_hint_cli(mode: str) -> str:
"after the next tool call. Use /busy interrupt or /busy queue to "
"change this. This tip only shows once."
)
if mode == "redirect":
return (
"(tip) Your correction redirected the current run without discarding "
"completed work. Use /stop to cancel or /busy queue to wait for a "
"separate turn. This tip only shows once."
)
return (
"(tip) Your message interrupted the current run. "
"Use /busy queue to queue messages for the next turn instead, "
+1 -46
View File
@@ -300,8 +300,6 @@ class CodexAppServerSession:
self._client: Optional[CodexAppServerClient] = None
self._thread_id: Optional[str] = None
self._interrupt_event = threading.Event()
self._active_turn_id: Optional[str] = None
self._active_turn_lock = threading.Lock()
# Pending file-change items, keyed by item id. Populated on
# item/started for fileChange items; consumed by the approval
# bridge when codex sends item/fileChange/requestApproval. The
@@ -376,8 +374,6 @@ class CodexAppServerSession:
if self._closed:
return
self._closed = True
with self._active_turn_lock:
self._active_turn_id = None
if self._client is not None:
try:
self._client.close()
@@ -399,33 +395,6 @@ class CodexAppServerSession:
and unwind. Called by AIAgent's _interrupt_requested path."""
self._interrupt_event.set()
def request_steer(self, text: str) -> bool:
"""Append user guidance to the active Codex turn via ``turn/steer``."""
cleaned = str(text or "").strip()
if not cleaned:
return False
with self._active_turn_lock:
turn_id = self._active_turn_id
thread_id = self._thread_id
client = self._client
if not turn_id or not thread_id or client is None:
return False
try:
response = client.request(
"turn/steer",
{
"threadId": thread_id,
"input": [{"type": "text", "text": cleaned}],
"expectedTurnId": turn_id,
},
timeout=10,
)
except (CodexAppServerError, TimeoutError):
logger.debug("turn/steer rejected for active Codex turn", exc_info=True)
return False
accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None
return accepted_turn_id in {None, turn_id}
# ---------- diagnostics ----------
def _format_error_with_stderr(
@@ -500,18 +469,11 @@ class CodexAppServerSession:
# Subprocess almost certainly unhealthy — retire so the next
# turn re-spawns cleanly.
result.should_retire = True
self._interrupt_event.clear()
return result
assert self._client is not None and self._thread_id is not None
result.thread_id = self._thread_id
# Do not clear here: a hard stop can arrive while ensure_started() is
# spawning/initializing the subprocess. Honor it before launching a
# Codex turn instead of erasing the signal.
if self._interrupt_event.is_set():
result.interrupted = True
self._interrupt_event.clear()
return result
self._interrupt_event.clear()
projector = CodexEventProjector()
user_input_text = _coerce_turn_input_text(user_input)
@@ -543,7 +505,6 @@ class CodexAppServerSession:
result.error = self._format_error_with_stderr(
"turn/start failed", exc
)
self._interrupt_event.clear()
return result
except TimeoutError as exc:
# turn/start hanging is a strong signal the subprocess is wedged.
@@ -553,12 +514,9 @@ class CodexAppServerSession:
"turn/start timed out", exc
)
result.should_retire = True
self._interrupt_event.clear()
return result
result.turn_id = (ts.get("turn") or {}).get("id")
with self._active_turn_lock:
self._active_turn_id = result.turn_id
deadline = time.monotonic() + turn_timeout
turn_complete = False
# Post-tool watchdog state. last_tool_completion_at is set whenever
@@ -783,9 +741,6 @@ class CodexAppServerSession:
)
result.should_retire = True
with self._active_turn_lock:
self._active_turn_id = None
self._interrupt_event.clear()
return result
def compact_thread(
+4 -116
View File
@@ -26,16 +26,11 @@ from __future__ import annotations
import logging
import threading
import time
import uuid
from dataclasses import dataclass
from typing import Any, Dict, List, Mapping, Optional
from agent.conversation_compression import (
IDLE_COMPACTION_STATUS_TEMPLATE,
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE,
conversation_history_after_compression,
)
from agent.conversation_compression import conversation_history_after_compression
from agent.iteration_budget import IterationBudget
from agent.memory_manager import build_memory_context_block
from agent.model_metadata import (
@@ -260,40 +255,6 @@ def _should_run_preflight_estimate(
return estimate_messages_tokens_rough(messages) >= threshold_tokens
def _should_idle_compact(
*,
enabled: bool,
idle_after_seconds: int,
idle_gap_seconds: float,
tokens: int,
floor_tokens: int,
cooldown_active: bool,
) -> bool:
"""Decide whether an idle-triggered compaction should run this turn.
Idle compaction is opt-in (``idle_after_seconds <= 0`` disables it). It
fires when a session resumes after a wall-clock gap of at least
``idle_after_seconds`` since its last activity, so a long-lived thread
that is paused and later resumed compacts its accumulated history up
front instead of re-reading it on every subsequent turn.
It is orthogonal to the token-threshold trigger: it does NOT require the
context to exceed ``threshold_tokens``. It still skips work when the
context is at or below ``floor_tokens`` (the size compaction would reduce
*to*), so a small idle thread never pays for a summarisation that saves
nothing, and it defers to an active compression-failure cooldown.
Pure predicate so the policy is unit-testable without a live agent.
"""
if not enabled or idle_after_seconds <= 0:
return False
if idle_gap_seconds < idle_after_seconds:
return False
if cooldown_active:
return False
return tokens > floor_tokens
@dataclass
class TurnContext:
"""Values produced by the turn prologue and consumed by the turn loop."""
@@ -618,78 +579,6 @@ def build_turn_context(
if not isinstance(pending_cli_message, dict) or pending_cli_message.get("_db_persisted"):
agent._pending_cli_user_message = None
# ── Idle-triggered compaction (opt-in; ``idle_compact_after_seconds``) ──
# When a session resumes after a long idle gap, compact the accumulated
# history up front so the rest of the conversation does not keep re-reading
# a large stale context on every turn. This fires on elapsed wall-clock time
# rather than size, so it complements (does not replace) the token-threshold
# preflight below. ``_last_activity_ts`` is the last time this turn loop did
# work; nothing has touched it yet this turn, so it measures the gap since
# the previous turn finished. The cheap gap pre-check gates the (more
# expensive) token estimate, mirroring ``_should_run_preflight_estimate``.
_idle_after = getattr(agent, "compression_idle_compact_after_seconds", 0)
if agent.compression_enabled and _idle_after > 0 and messages:
_idle_gap = time.time() - getattr(agent, "_last_activity_ts", time.time())
if _idle_gap >= _idle_after:
_compressor = agent.context_compressor
_idle_tokens = estimate_request_tokens_rough(
messages,
system_prompt=active_system_prompt or "",
tools=agent.tools or None,
)
# Post-compression target size: don't summarise a thread already
# below what compaction would reduce it to.
_idle_floor = int(
_compressor.threshold_tokens * _compressor.summary_target_ratio
)
_idle_cooldown = getattr(
_compressor, "get_active_compression_failure_cooldown", lambda: None
)()
if _should_idle_compact(
enabled=agent.compression_enabled,
idle_after_seconds=_idle_after,
idle_gap_seconds=_idle_gap,
tokens=_idle_tokens,
floor_tokens=_idle_floor,
cooldown_active=bool(_idle_cooldown),
):
logger.info(
"Idle compaction: %ss idle >= %ss, ~%s tokens > %s floor "
"(session %s)",
int(_idle_gap),
_idle_after,
f"{_idle_tokens:,}",
f"{_idle_floor:,}",
agent.session_id or "none",
)
agent._emit_status(
IDLE_COMPACTION_STATUS_TEMPLATE.format(
idle_seconds=int(_idle_gap), tokens=_idle_tokens
)
)
_idle_input = messages
messages, active_system_prompt = agent._compress_context(
messages, system_message, approx_tokens=_idle_tokens,
task_id=effective_task_id,
)
# ``_compress_context`` returns the INPUT list object when it
# skips (per-session lock held by another path, failure
# cooldown, anti-thrash breaker, codex-native routing). Only
# re-baseline + re-anchor after a real compaction — a skip
# must leave the turn's flush baseline and user-message index
# untouched.
if messages is not _idle_input:
conversation_history = conversation_history_after_compression(
agent, messages
)
# Compaction rebuilt the list, so the index of this turn's
# just-appended user message is stale — re-anchor it the
# same way the preflight path does below.
current_turn_user_idx = reanchor_current_turn_user_idx(
messages, user_message
)
agent._persist_user_message_idx = current_turn_user_idx
# ── Preflight context compression ──
# Gate the (expensive) full token estimate behind a cheap pre-check.
# See ``_should_run_preflight_estimate`` for the OR semantics that fix
@@ -772,10 +661,9 @@ def build_turn_context(
f"{_compressor.context_length:,}",
)
agent._emit_status(
PREFLIGHT_COMPRESSION_STATUS_TEMPLATE.format(
tokens=_preflight_tokens,
threshold=_compressor.threshold_tokens,
)
f"📦 Preflight compression: ~{_preflight_tokens:,} tokens "
f">= {_compressor.threshold_tokens:,} threshold. "
"This may take a moment."
)
# Preflight passes honor the same configured per-turn cap
# (compression.max_attempts) as the loop's compression sites;
-4
View File
@@ -73,10 +73,6 @@ class TurnRetryState:
# was rolled back off ``messages`` and the loop should re-issue the API
# call against the newly-activated provider (#32421).
restart_with_rebuilt_messages: bool = False
# A user correction cancelled the in-flight provider request. The outer
# loop must append a role-safe checkpoint + user message, rebuild the API
# payload, and retry the same logical iteration.
restart_with_redirected_messages: bool = False
def __iter__(self):
# Convenience for debugging / tests: iterate (name, value) pairs.
+8 -55
View File
@@ -8,10 +8,13 @@
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from './test'
import { test } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { expectVisualSnapshot } from './visual-snapshot'
let fixture: MockBackendFixture | null = null
@@ -58,7 +61,7 @@ test.describe('chat interaction with mock backend', () => {
return (body.textContent ?? '').includes('Hello, can you hear me?')
},
undefined,
{ timeout: 15_000 }
{ timeout: 15_000 },
)
// Wait for the mock response to appear. The canned reply is:
@@ -78,61 +81,11 @@ test.describe('chat interaction with mock backend', () => {
return text.includes('mock inference server') || text.includes('boot chain is working')
},
undefined,
{ timeout: 60_000 }
{ timeout: 60_000 },
)
})
test('screenshot of chat with messages', async () => {
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
})
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
const page = fixture!.page
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]')
const speakReplies = page.locator(
'[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]'
)
await composer.click()
await composer.type(BLOCKING_CLARIFY_TRIGGER)
await page.keyboard.press('Enter')
await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 })
await expect(primary).toHaveAttribute('aria-label', 'Stop')
await expect(primary.locator('span')).toHaveClass(/bg-current/)
await composer.click()
await composer.type('please answer tersely')
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await expect(dictation).toBeVisible()
await expect(speakReplies).toBeVisible()
await expect(queue).toBeVisible()
await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible()
const controlLabels = await page
.locator('[data-slot="composer-root"] button')
.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))
const speakRepliesIndex = controlLabels.findIndex(
label => label === 'Read replies aloud' || label === 'Stop reading replies aloud'
)
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
expect(controlLabels.indexOf('Queue message')).toBeLessThan(
controlLabels.findIndex(label => label?.startsWith('Steer'))
)
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible()
await queue.click()
await expect(primary).toHaveAttribute('aria-label', 'Stop')
await expect(queue).toHaveCount(0)
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') })
await expect(page.getByText('1 Queued')).toBeVisible()
await primary.click()
await expect(page.getByText('1 Queued — paused')).toBeVisible()
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') })
})
})
@@ -1,173 +0,0 @@
/**
* Regression coverage for a correction sent during a live response, then a
* warm session switch away and back. The correction is an accepted user turn,
* not an optimistic duplicate of the original prompt, and its relative place
* in the transcript must survive the resume reconciliation.
*/
import { type TestInfo } from '@playwright/test'
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { CORRECTION_SWITCH_TRIGGER, MOCK_REPLY } from './mock-server'
const OTHER_SESSION_PROMPT = 'E2E persisted session used for a warm resume.'
const ORIGINAL_PROMPT = `${CORRECTION_SWITCH_TRIGGER}: original prompt must remain singular after a correction.`
const CORRECTION = 'E2E correction must stay after the original prompt.'
const TOOL_STARTED = 'Checking the long-running task before I continue.'
const CORRECTED_REPLY = 'The corrected task finished.'
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Enter')
}
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await primary.click()
}
async function waitForTranscriptText(page: Page, text: string): Promise<void> {
await page.waitForFunction(
(expected: string) => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
text,
{ timeout: 30_000 },
)
}
async function textNodeOccurrences(page: Page, text: string): Promise<number> {
return page.evaluate((expected: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return 0
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
let count = 0
while (walker.nextNode()) {
if (walker.currentNode.textContent?.includes(expected)) {
count += 1
}
}
return count
}, text)
}
async function transcriptTextOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="message"], [data-message-id]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}
async function openFreshDraft(page: Page, priorSessionText: string): Promise<void> {
await page.locator('[data-slot="sidebar"] button[aria-label="New session"]').first().click()
await page.waitForFunction(
(priorText: string) => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(priorText),
priorSessionText,
{ timeout: 15_000 },
)
}
async function openSidebarSession(page: Page, sidebarText: string, expectedTranscriptText: string): Promise<void> {
const row = page.locator('[data-slot="sidebar"] button').filter({ hasText: sidebarText }).first()
await row.waitFor({ state: 'visible', timeout: 30_000 })
await row.click()
await waitForTranscriptText(page, expectedTranscriptText)
}
async function reopenOriginalSession(page: Page): Promise<void> {
// A still-running tool has not generated a final title yet, so the sidebar
// retains the source prompt as its provisional session title.
await openSidebarSession(page, ORIGINAL_PROMPT, ORIGINAL_PROMPT)
}
function relevantOrder(messages: string[]): string[] {
return messages.filter(message => message.includes(ORIGINAL_PROMPT) || message.includes(CORRECTION))
}
function steerTurnOrder(messages: string[]): string[] {
return messages.flatMap(message => {
if (message.includes(ORIGINAL_PROMPT)) return [ORIGINAL_PROMPT]
if (message.includes(CORRECTION)) return [CORRECTION]
if (message.includes(CORRECTED_REPLY)) return [CORRECTED_REPLY]
return []
})
}
test.describe('correction session switch', () => {
let fixture: MockBackendFixture | null = null
test.beforeEach(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('keeps a live correction in place and does not duplicate its original prompt after switching sessions', async ({}, testInfo: TestInfo) => {
const { page } = fixture!
// A blank draft does not exercise session hydration. Seed a real second
// session first, matching the observed switch between two saved chats.
await send(page, OTHER_SESSION_PROMPT)
await waitForTranscriptText(page, MOCK_REPLY)
await openFreshDraft(page, OTHER_SESSION_PROMPT)
await send(page, ORIGINAL_PROMPT)
await waitForTranscriptText(page, TOOL_STARTED)
await waitForTranscriptText(page, ORIGINAL_PROMPT)
// The historical session redirects while a foreground terminal task is
// running. Use the visible Steer action to cover the real composer path.
await steer(page, CORRECTION)
await waitForTranscriptText(page, CORRECTION)
const orderBeforeSwitch = relevantOrder(await transcriptTextOrder(page))
expect(orderBeforeSwitch).toEqual([ORIGINAL_PROMPT, CORRECTION])
expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
await page.screenshot({ path: testInfo.outputPath('correction-before-session-switch.png') })
// Reproduce the observed race: switch to another persisted session while
// the foreground tool is live, then return before its redirect settles.
await openSidebarSession(page, MOCK_REPLY, OTHER_SESSION_PROMPT)
await reopenOriginalSession(page)
await page.waitForTimeout(500)
await page.screenshot({ path: testInfo.outputPath('correction-after-warm-resume.png') })
expect(relevantOrder(await transcriptTextOrder(page))).toEqual(orderBeforeSwitch)
expect(await textNodeOccurrences(page, ORIGINAL_PROMPT)).toBe(1)
expect(await textNodeOccurrences(page, CORRECTION)).toBe(1)
await waitForTranscriptText(page, CORRECTED_REPLY)
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ORIGINAL_PROMPT, CORRECTION, CORRECTED_REPLY])
})
})
+6 -29
View File
@@ -27,7 +27,7 @@ import * as path from 'node:path'
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
import { startMockServer, type MockServerOptions } from './mock-server'
import { startMockServer } from './mock-server'
import { installErrorBannerGuard } from './test'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
@@ -140,18 +140,10 @@ export function createSandbox(prefix: string): Sandbox {
* Write a config.yaml that pre-configures a mock provider pointing at the
* mock inference server. The provider is set as the active model provider so
* the desktop app skips onboarding and boots straight to the chat UI.
*
* @param extraConfig optional YAML lines appended to the `display:` section,
* used by the interim-message e2e test to toggle
* `display.interim_assistant_messages`.
*/
export function writeMockProviderConfig(hermesHome: string, mockUrl: string, extraConfig?: string): void {
export function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
const configPath = path.join(hermesHome, 'config.yaml')
const displaySection = extraConfig
? `\ndisplay:\n${extraConfig}\n`
: ''
const config = `# Auto-generated by E2E test fixtures
model:
default: mock-model
@@ -165,7 +157,7 @@ providers:
models:
mock-model: {}
context_length: 4096
${displaySection}`
`
fs.writeFileSync(configPath, config, 'utf8')
}
@@ -326,21 +318,11 @@ export async function launchDesktop(
export interface MockBackendFixture {
app: ElectronApplication
page: Page
mock: Awaited<ReturnType<typeof startMockServer>>
mockUrl: string
sandbox: Sandbox
cleanup: () => Promise<void>
}
export interface MockBackendOptions {
/**
* Optional YAML lines to inject under the `display:` section of the
* generated config.yaml. Used by the interim-message e2e test to toggle
* `display.interim_assistant_messages`.
*/
extraDisplayConfig?: string
}
/**
* Set up a full mock-backend E2E environment:
* 1. Start the mock inference server
@@ -348,17 +330,13 @@ export interface MockBackendOptions {
* 3. Launch the desktop app
* 4. Return handles for test interaction
*/
export interface MockBackendOptions {
mockServer?: MockServerOptions
}
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
export async function setupMockBackend(): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer(options.mockServer)
const mock = await startMockServer()
// 2. Create sandbox + write config
const sandbox = createSandbox('mock')
writeMockProviderConfig(sandbox.hermesHome, mock.url, options.extraDisplayConfig)
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
// 3. Build env + launch
@@ -368,7 +346,6 @@ export async function setupMockBackend(options: MockBackendOptions = {}): Promis
return {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
-215
View File
@@ -1,215 +0,0 @@
/**
* E2E test for the interim-assistant-message preservation fix (#65919).
*
* Reproduces the bug across all three layers (agent core → tui_gateway →
* desktop renderer): when the agent emits assistant text alongside a tool
* call, then completes the turn with a *different* final answer, the
* interim text must survive in the transcript — not be wiped when
* message.complete replaces the streaming bubble.
*
* The mock server walks through a multi-turn script when it sees the
* trigger keyword:
*
* Turn 1: "Let me start by planning the approach." + todo tool_call
* Turn 2: "Now checking the details before answering." + todo tool_call
* Turn 3: (no text) + todo tool_call → NO interim (no visible text)
* Turn 4: "Found something interesting worth noting." + todo tool_call
* Turn 5: "All done! Here is the complete summary..." (final, stop)
*
* Two describe blocks exercise the config flag both ways:
*
* display.interim_assistant_messages: true (default)
* → ALL interim texts AND the final text must be visible in the
* transcript.
*
* display.interim_assistant_messages: false
* → only the final text is visible (no message.interim events emitted,
* so all streamed interim text is replaced at message.complete).
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { INTERIM_TEXTS, restartMockServer } from './mock-server'
// ─── Helpers ──────────────────────────────────────────────────────────
/** Unique trigger keyword the mock server detects to switch to the script. */
const TRIGGER = 'E2E_INTERIM_TRIGGER'
/**
* Send a message and wait for BOTH the user's message and the agent's
* final response to appear in the transcript. Returns when the final text
* is visible, which means message.complete has fired and the transcript
* has settled.
*/
async function sendInterimMessage(page: Page): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type(TRIGGER, { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the user's trigger message to appear.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'),
undefined,
{ timeout: 15_000 },
)
// Wait for the agent's FINAL response (last turn). This means
// message.complete has fired and the transcript is settled.
await page.waitForFunction(
(finalText) => (document.body.textContent ?? '').includes(finalText),
INTERIM_TEXTS.finalText,
{ timeout: 90_000 },
)
// Give the renderer a moment to settle any final state updates
// (hydration, session refresh) before asserting.
await page.waitForTimeout(2000)
}
/**
* Count how many times `text` appears as distinct text in the chat transcript
* (excluding the session sidebar, whose session-preview label shows the
* first streamed text as a title).
*
* The desktop app renders the transcript inside a
* `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react).
* The session sidebar's preview labels live outside that container, so
* scoping the DOM walk to the viewport cleanly excludes them.
*/
async function countTranscriptMessagesContaining(page: Page, text: string): Promise<number> {
return page.evaluate(
(search) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) {
return 0
}
let count = 0
const walker = document.createTreeWalker(
viewport,
NodeFilter.SHOW_ELEMENT,
{
acceptNode: (node) => {
const el = node as HTMLElement
const directText = el.textContent ?? ''
if (!directText.includes(search)) {
return NodeFilter.FILTER_SKIP
}
// Only count leaf-ish elements to avoid double-counting.
const hasChildWithText = Array.from(el.children).some(
(child) => (child.textContent ?? '').includes(search),
)
if (hasChildWithText) {
return NodeFilter.FILTER_SKIP
}
return NodeFilter.FILTER_ACCEPT
},
},
)
while (walker.nextNode()) {
count++
}
return count
},
text,
)
}
// ─── Flag ON: interim_assistant_messages = true (default) ─────────────
test.describe('interim assistant messages — flag ON (default)', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('all interim texts survive alongside the final response', async () => {
const page = fixture.page
await sendInterimMessage(page)
// Every interim text (turns with visible text + tool calls) must be
// present in the transcript as its own sealed message — NOT wiped by
// message.complete.
for (const interimText of INTERIM_TEXTS.interims) {
await expect
.poll(
() => countTranscriptMessagesContaining(page, interimText),
{ timeout: 15_000, message: `interim text "${interimText}" should be visible` },
)
.toBeGreaterThanOrEqual(1)
}
// The final text must also be visible.
await expect
.poll(
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
{ timeout: 15_000, message: 'final text should be visible' },
)
.toBeGreaterThanOrEqual(1)
})
})
// ─── Flag OFF: interim_assistant_messages = false ────────────────────
test.describe('interim assistant messages — flag OFF', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend({
extraDisplayConfig: ' interim_assistant_messages: false',
})
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('only the final response is visible; all interim texts are wiped', async () => {
const page = fixture.page
await sendInterimMessage(page)
// The final text must be visible.
await expect
.poll(
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
{ timeout: 15_000, message: 'final text should be visible' },
)
.toBeGreaterThanOrEqual(1)
// NONE of the interim texts should be visible — with the flag off,
// the tui_gateway never installs interim_assistant_callback, so no
// message.interim events are emitted. All streamed interim text is
// accumulated into the streaming bubble and replaced by
// message.complete.
for (const interimText of INTERIM_TEXTS.interims) {
const count = await countTranscriptMessagesContaining(page, interimText)
expect(
count,
`interim text "${interimText}" should NOT be visible when flag is off`,
).toBe(0)
}
})
})
@@ -1,235 +0,0 @@
import { spawnSync } from 'node:child_process'
import * as path from 'node:path'
import { type TestInfo } from '@playwright/test'
import { expect, test, type ElectronApplication, type Page } from './test'
import {
buildAppEnv,
createSandbox,
launchDesktop,
type Sandbox,
waitForAppReady,
writeEnvFile,
writeMockProviderConfig,
} from './fixtures'
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py')
const SESSION_TITLE = 'E2E large persisted session'
const EXPECTED_TEXT = 'E2E persisted user message 52'
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
interface SeededFixture {
app: ElectronApplication
mock: MockServer
mockUrl: string
page: Page
sandbox: Sandbox
cleanup: () => Promise<void>
}
interface PaintState {
bursts: number
timeline: Array<{ mutations: number; time: number }>
}
async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<SeededFixture> {
const mock = await startMockServer(mockServer)
const sandbox = createSandbox('large-session')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
const seeded = spawnSync('python3', [SEED_SCRIPT, path.join(sandbox.hermesHome, 'state.db')], {
cwd: REPO_ROOT,
encoding: 'utf8',
env: { ...process.env, PYTHONPATH: REPO_ROOT },
})
if (seeded.status !== 0) {
throw new Error(`large-session seed failed:\n${seeded.stdout}\n${seeded.stderr}`)
}
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
return {
app,
mock,
mockUrl: mock.url,
page,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
function sessionRow(page: Page) {
return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
}
async function openSeededSession(page: Page): Promise<void> {
const row = sessionRow(page)
await row.waitFor({ state: 'visible', timeout: 60_000 })
await row.click()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
EXPECTED_TEXT,
{ timeout: 30_000 },
)
}
async function openNewSession(page: Page): Promise<void> {
const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first()
await button.waitFor({ state: 'visible', timeout: 10_000 })
await button.click()
await page.waitForFunction(
expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
EXPECTED_TEXT,
{ timeout: 15_000 },
)
}
async function submitPrompt(page: Page, prompt: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(prompt, { delay: 2 })
await page.keyboard.press('Enter')
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
prompt,
{ timeout: 15_000 },
)
}
async function startPaintObserver(page: Page): Promise<void> {
await page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> }
;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state
if (!viewport) return
let additions = 0
let flushTimer: ReturnType<typeof setTimeout> | undefined
new MutationObserver(records => {
additions += records.reduce(
(count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0),
0,
)
if (additions === 0) return
if (flushTimer) clearTimeout(flushTimer)
flushTimer = setTimeout(() => {
state.bursts += 1
state.timeline.push({ mutations: additions, time: Date.now() })
additions = 0
}, 30)
}).observe(viewport, { childList: true, subtree: true })
})
}
async function paintState(page: Page): Promise<PaintState> {
const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints)
expect(state, 'paint observer should attach to the thread viewport').toBeDefined()
return state!
}
async function textNodeOccurrences(page: Page, expected: string): Promise<number> {
return page.evaluate(text => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return 0
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
let count = 0
while (walker.nextNode()) {
if (walker.currentNode.textContent?.includes(text)) {
count += 1
}
}
return count
}, expected)
}
async function reloadIntoColdRenderer(fixture: SeededFixture): Promise<void> {
await fixture.page.reload()
await waitForAppReady(fixture, 120_000)
await openNewSession(fixture.page)
}
async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise<void> {
await openSeededSession(page)
await page.waitForTimeout(1_000)
await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false })
const paints = await paintState(page)
expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1)
// A warm session first restores its retained view, then reconciles it with the
// authoritative transcript. That is bounded at two builds; a third paint was
// the old eager-prefetch + runtime-rebuild regression. A cold restore has one.
expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2)
}
test.describe('large session resume', () => {
let fixture: SeededFixture | null = null
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
fixture = await setupSeededDesktop()
await waitForAppReady(fixture, 120_000)
await startPaintObserver(fixture.page)
await assertUnchangedResume(fixture.page, testInfo)
})
test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
// Known RED: a rapid warm resume rebuilds the transcript three times
// (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the
// regression visible without making unrelated desktop work fail CI.
test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild')
fixture = await setupSeededDesktop()
await waitForAppReady(fixture, 120_000)
await openSeededSession(fixture.page)
await openNewSession(fixture.page)
await startPaintObserver(fixture.page)
await assertUnchangedResume(fixture.page, testInfo)
})
for (const resumeKind of ['fast', 'cold'] as const) {
test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => {
fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT })
await waitForAppReady(fixture, 120_000)
await openSeededSession(fixture.page)
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
await fixture.mock.waitForHeldStream()
await openNewSession(fixture.page)
if (resumeKind === 'cold') {
await reloadIntoColdRenderer(fixture)
}
await openSeededSession(fixture.page)
fixture.mock.releaseHeldStream()
await fixture.page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 },
)
await fixture.page.waitForTimeout(300)
await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
expect(await textNodeOccurrences(fixture.page, MOCK_REPLY), 'the completed assistant reply should appear once').toBe(1)
})
}
})
+80 -572
View File
@@ -15,245 +15,17 @@
*/
import http from 'node:http'
import type { ServerResponse } from 'node:http'
/** A canned assistant reply used for every chat completion request. */
export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
export interface MockServerOptions {
/** Pause the matching stream after its first token for session-switch E2E coverage. */
holdFirstStreamForPrompt?: string
}
export interface MockServer {
port: number
url: string
receivedPrompts: string[]
waitForHeldStream: () => Promise<void>
releaseHeldStream: () => void
close: () => Promise<void>
}
// ─── Multi-turn interim script ─────────────────────────────────────────
//
// When the user's message contains the trigger keyword, the mock server
// walks through a scripted sequence of responses that exercise the
// interim-assistant-message fix (#65919) across several patterns:
//
// 1. text + single tool_call → should produce an interim message
// 2. text + single tool_call → another interim message
// 3. no text + tool_call → NO interim (no visible text alongside tools)
// 4. text + single tool_call → another interim message
// 5. final answer (stop) → message.complete, different from all interims
//
// Each "turn" is one API call. The agent executes the tool after each
// tool_calls response, then re-calls the API, advancing to the next turn.
export interface ScriptedTurn {
/** Assistant text content to stream. Empty string = no visible text. */
text: string
/** Tool calls to emit. Empty array = final turn (finish_reason: stop). */
toolCalls?: Array<{
name: string
args: Record<string, unknown>
}>
}
const INTERIM_SCRIPT: ScriptedTurn[] = [
{
text: 'Let me start by planning the approach.',
toolCalls: [{ name: 'todo', args: { todos: [{ id: '1', content: 'Plan', status: 'in_progress' }] } }],
},
{
text: 'Now checking the details before answering.',
toolCalls: [{ name: 'todo', args: { todos: [{ id: '2', content: 'Check details', status: 'in_progress' }] } }],
},
{
// No visible text alongside this tool call — should NOT produce an
// interim message. The agent fires _emit_interim_assistant_message
// but _interim_assistant_visible_text returns "" so it's a no-op.
text: '',
toolCalls: [{ name: 'todo', args: { todos: [{ id: '3', content: 'Silent step', status: 'completed' }] } }],
},
{
text: 'Found something interesting worth noting.',
toolCalls: [{ name: 'todo', args: { todos: [{ id: '4', content: 'Note finding', status: 'completed' }] } }],
},
{
// Final answer — different from all interim texts.
text: 'All done! Here is the complete summary of what I found.',
},
]
/** Per-server request counter so we can walk through the script turns. */
let _scriptIndex = 0
/** Per-server counter for the sidebar-states script (independent from _scriptIndex). */
let _sidebarScriptIndex = 0
/** Per-server counter for the cross-session sidebar script. */
let _sidebarCrossIndex = 0
/** Per-server counter for the queue-stop script. */
let _queueStopIndex = 0
/** Per-server counter for the correction/session-switch script. */
let _correctionSwitchIndex = 0
/** User messages received by the mock, for E2E assertions on real submits. */
const _receivedUserTexts: string[] = []
/** Reset the script indices (called between tests via restartMockServer). */
function resetScriptIndex(): void {
_scriptIndex = 0
_sidebarScriptIndex = 0
_sidebarCrossIndex = 0
_queueStopIndex = 0
_correctionSwitchIndex = 0
_receivedUserTexts.length = 0
}
/** Return the user prompts the real backend submitted to this mock server. */
export function receivedUserTexts(): readonly string[] {
return _receivedUserTexts
}
// ─── Sidebar-states script ─────────────────────────────────────────────
//
// A separate trigger (E2E_SIDEBAR_TRIGGER) exercises the desktop sidebar's
// background-process and subagent states. The mock returns tool_calls that
// the agent executes for real — `terminal(background=true)` spawns a real
// (but trivial) background process, and `delegate_task` spawns a real
// subagent that calls the mock server and gets the canned reply.
//
// Turn 1: text + terminal(bg=true) + delegate_task → tools execute
// Turn 2: final answer → message.complete, dot transitions
const SIDEBAR_SCRIPT: ScriptedTurn[] = [
{
text: 'Let me run a background task and delegate some work.',
toolCalls: [
{
name: 'terminal',
args: {
command: 'echo "background process output" && sleep 1 && echo "done"',
background: true,
notify_on_complete: true,
},
},
{
name: 'delegate_task',
args: {
goal: 'Summarize the test results',
context: 'This is a test subagent for the sidebar states E2E test.',
},
},
],
},
{
text: 'All tasks complete. The background process finished and the subagent returned its summary.',
},
]
// ─── Sidebar cross-session script ──────────────────────────────────────
//
// E2E_SIDEBAR_CROSS trigger uses a longer background process (sleep 5) so
// the "background running" dot is visible long enough for the test to:
// 1. See the background dot while the subagent runs.
// 2. Open a different session and see session A's dot transition to
// "finished unread" when the background process completes.
const SIDEBAR_CROSS_SCRIPT: ScriptedTurn[] = [
{
text: 'Starting a long background task and delegating work.',
toolCalls: [
{
name: 'terminal',
args: {
command: 'echo "long bg output" && sleep 5 && echo "finished"',
background: true,
notify_on_complete: true,
},
},
{
name: 'delegate_task',
args: {
goal: 'Analyze cross-session state',
context: 'Testing that the background dot updates across sessions.',
},
},
],
},
{
text: 'Both tasks are running in the background now.',
},
]
const QUEUE_STOP_SCRIPT: ScriptedTurn[] = [
{
text: 'Starting a task that will keep this turn active.',
toolCalls: [{ name: 'clarify', args: { question: 'Keep working?', choices: ['Yes', 'No'] } }],
},
{ text: 'The paused task completed.' },
]
// The reported correction arrived while a foreground tool was still running.
// Keep that boundary open long enough for the renderer to redirect the turn,
// then let the next model request complete normally.
const CORRECTION_SWITCH_SCRIPT: ScriptedTurn[] = [
{
text: 'Checking the long-running task before I continue.',
toolCalls: [{ name: 'terminal', args: { command: 'sleep 5' } }],
},
{ text: 'The corrected task finished.' },
]
export const CORRECTION_SWITCH_TRIGGER = 'E2E_CORRECTION_SWITCH_TRIGGER'
/**
* A marker that makes the mock emit a real blocking clarify tool call. Tests
* use it to hold a turn open while exercising busy-composer interactions.
*/
export const BLOCKING_CLARIFY_TRIGGER = 'E2E_BLOCKING_CLARIFY_TRIGGER'
export const BLOCKING_CLARIFY_QUESTION = 'Keep this test turn running?'
const BLOCKING_CLARIFY_TURN: ScriptedTurn = {
text: '',
toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }],
}
function includesBlockingClarifyTrigger(value: unknown): boolean {
if (typeof value === 'string') {
return value.includes(BLOCKING_CLARIFY_TRIGGER)
}
if (Array.isArray(value)) {
return value.some(includesBlockingClarifyTrigger)
}
if (value && typeof value === 'object') {
return Object.values(value).some(includesBlockingClarifyTrigger)
}
return false
}
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
/**
* Start the mock server on an ephemeral port.
*
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
* @returns a handle with `port`, `url`, and `close()`.
*/
export function startMockServer(options: MockServerOptions = {}): Promise<MockServer> {
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
return new Promise((resolve, reject) => {
const receivedPrompts: string[] = []
let resolveHeldStreamStarted: (() => void) | null = null
let releaseHeldStream: (() => void) | null = null
const heldStreamStarted = new Promise<void>(resolveHeld => {
resolveHeldStreamStarted = resolveHeld
})
const heldStreamReleased = new Promise<void>(resolveRelease => {
releaseHeldStream = resolveRelease
})
const server = http.createServer((req, res) => {
// CORS headers — the Electron renderer doesn't need them, but they
// don't hurt and make the server usable from a browser context too.
@@ -303,114 +75,88 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
// malformed JSON — treat as non-streaming with defaults
}
const lastUserMessage = [...(parsed.messages ?? [])]
.reverse()
.find((message: { role?: unknown }) => message?.role === 'user')
if (typeof lastUserMessage?.content === 'string') {
receivedPrompts.push(lastUserMessage.content)
}
const stream = parsed.stream === true
const model = parsed.model || 'mock-model'
// Detect the interim-message test trigger: the user's message
// contains a specific keyword. The mock walks through the
// INTERIM_SCRIPT turns in sequence.
//
// The trigger keyword is chosen so normal chat tests (which send
// "Hello, can you hear me?" etc.) never hit this path.
const messages: any[] = Array.isArray(parsed.messages) ? parsed.messages : []
const lastUserMsg = [...messages].reverse().find(m => m?.role === 'user')
const userText = typeof lastUserMsg?.content === 'string' ? lastUserMsg.content : ''
if (userText) {
_receivedUserTexts.push(userText)
}
const isInterimTrigger = userText.includes('E2E_INTERIM_TRIGGER')
const isSidebarTrigger = userText.includes('E2E_SIDEBAR_TRIGGER')
const isSidebarCrossTrigger = userText.includes('E2E_SIDEBAR_CROSS')
const isQueueStopTrigger = userText.includes('E2E_QUEUE_STOP_TRIGGER')
const isCorrectionSwitchTrigger = messages.some(
message => typeof message?.content === 'string' && message.content.includes(CORRECTION_SWITCH_TRIGGER),
)
if (includesBlockingClarifyTrigger(parsed.messages)) {
if (stream) {
streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
} else {
nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
}
return
}
if (isQueueStopTrigger) {
const turn = QUEUE_STOP_SCRIPT[_queueStopIndex] ?? QUEUE_STOP_SCRIPT[QUEUE_STOP_SCRIPT.length - 1]
_queueStopIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isCorrectionSwitchTrigger) {
const turn = CORRECTION_SWITCH_SCRIPT[_correctionSwitchIndex] ?? CORRECTION_SWITCH_SCRIPT[CORRECTION_SWITCH_SCRIPT.length - 1]
_correctionSwitchIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isSidebarCrossTrigger) {
const turn = SIDEBAR_CROSS_SCRIPT[_sidebarCrossIndex] ?? SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1]
_sidebarCrossIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isSidebarTrigger) {
const turn = SIDEBAR_SCRIPT[_sidebarScriptIndex] ?? SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1]
_sidebarScriptIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (isInterimTrigger) {
const turn = INTERIM_SCRIPT[_scriptIndex] ?? INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1]
_scriptIndex++
if (stream) {
streamScriptedTurn(res, model, turn)
} else {
nonStreamingScriptedTurn(res, model, turn)
}
return
}
if (stream) {
const holdThisStream = Boolean(
options.holdFirstStreamForPrompt && typeof lastUserMessage?.content === 'string' &&
lastUserMessage.content.includes(options.holdFirstStreamForPrompt),
)
streamTextResponse(res, model, MOCK_REPLY, holdThisStream ? () => {
resolveHeldStreamStarted?.()
return heldStreamReleased
} : undefined)
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
// Send the content in a few chunks to simulate streaming.
const words = CANNED_REPLY.split(' ')
let i = 0
const sendChunk = () => {
if (i >= words.length) {
// Final chunk with finish_reason
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: {},
finish_reason: 'stop',
},
],
})}\n\n`,
)
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(
`data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [
{
index: 0,
delta: { content: word },
finish_reason: null,
},
],
})}\n\n`,
)
i++
// Small delay between chunks to simulate real streaming.
setTimeout(sendChunk, 20)
}
sendChunk()
} else {
nonStreamingTextResponse(res, model, MOCK_REPLY)
// Non-streaming response
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [
{
index: 0,
message: { role: 'assistant', content: CANNED_REPLY },
finish_reason: 'stop',
},
],
usage: {
prompt_tokens: 10,
completion_tokens: 20,
total_tokens: 30,
},
}),
)
}
})
@@ -441,9 +187,6 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
resolve({
port,
url,
receivedPrompts,
waitForHeldStream: () => heldStreamStarted,
releaseHeldStream: () => releaseHeldStream?.(),
close: () =>
new Promise((resolveClose, rejectClose) => {
server.close((err) => {
@@ -458,238 +201,3 @@ export function startMockServer(options: MockServerOptions = {}): Promise<MockSe
})
})
}
// ─── Response helpers ──────────────────────────────────────────────────
/** SSE chunk shape for a streaming chat completion. */
function sseChunk(model: string, delta: Record<string, unknown>, finishReason: string | null = null): string {
return `data: ${JSON.stringify({
id: 'mock-completion',
object: 'chat.completion.chunk',
created: 0,
model,
choices: [{ index: 0, delta, finish_reason: finishReason }],
})}\n\n`
}
/**
* Stream a plain text response (no tool calls) as SSE, finishing with
* `finish_reason: "stop"`. This is the default canned-reply path.
*/
function streamTextResponse(
res: ServerResponse,
model: string,
text: string,
waitForRelease?: () => Promise<void>,
): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const words = text.split(' ')
let i = 0
const sendChunk = (): void => {
if (i >= words.length) {
res.write(sseChunk(model, {}, 'stop'))
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(sseChunk(model, { content: word }))
i++
if (waitForRelease && i === 1) {
waitForRelease().then(() => setTimeout(sendChunk, 20))
return
}
setTimeout(sendChunk, 20)
}
sendChunk()
}
/** Non-streaming plain text response. */
function nonStreamingTextResponse(res: ServerResponse, model: string, text: string): void {
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [
{
index: 0,
message: { role: 'assistant', content: text },
finish_reason: 'stop',
},
],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
)
}
/**
* Stream a single scripted turn: first the text content (word by word),
* then a chunk carrying the tool_calls (if any), with the appropriate
* finish_reason.
*
* If the turn has no text and no tool calls, it's an empty final response.
* If it has text but no tool calls, it's a final answer (finish_reason: stop).
* If it has tool calls (with or without text), finish_reason is "tool_calls".
*/
function streamScriptedTurn(
res: ServerResponse,
model: string,
turn: ScriptedTurn,
): void {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
})
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
// If there's no text to stream, go straight to the tool_calls / finish.
if (!turn.text) {
if (hasToolCalls) {
res.write(
sseChunk(model, {
tool_calls: turn.toolCalls!.map((tc, idx) => ({
index: idx,
id: `call_e2e_${_scriptIndex}_${idx}`,
type: 'function',
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
})),
}, finishReason),
)
} else {
res.write(sseChunk(model, {}, finishReason))
}
res.write('data: [DONE]\n\n')
res.end()
return
}
// Stream the text word by word, then emit tool_calls if present.
const words = turn.text.split(' ')
let i = 0
const sendChunk = (): void => {
if (i >= words.length) {
// All text streamed — emit tool_calls if present, then finish.
if (hasToolCalls) {
res.write(
sseChunk(model, {
tool_calls: turn.toolCalls!.map((tc, idx) => ({
index: idx,
id: `call_e2e_${_scriptIndex}_${idx}`,
type: 'function',
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
})),
}, finishReason),
)
} else {
res.write(sseChunk(model, {}, finishReason))
}
res.write('data: [DONE]\n\n')
res.end()
return
}
const word = i === 0 ? words[i] : ' ' + words[i]
res.write(sseChunk(model, { content: word }))
i++
setTimeout(sendChunk, 20)
}
sendChunk()
}
/** Non-streaming version of a scripted turn. */
function nonStreamingScriptedTurn(
res: ServerResponse,
model: string,
turn: ScriptedTurn,
): void {
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
const message: Record<string, unknown> = { role: 'assistant' }
if (turn.text) {
message.content = turn.text
}
if (hasToolCalls) {
message.tool_calls = turn.toolCalls!.map((tc, idx) => ({
id: `call_e2e_${_scriptIndex}_${idx}`,
type: 'function',
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
}))
}
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(
JSON.stringify({
id: 'mock-completion',
object: 'chat.completion',
created: 0,
model,
choices: [{ index: 0, message, finish_reason: finishReason }],
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
}),
)
}
/**
* Restart the mock server's script index so each test starts from turn 0.
* Call this between tests that use the interim trigger.
*/
export function restartMockServer(): void {
resetScriptIndex()
}
/**
* The interim script's text constants, exported for test assertions.
* Each entry is the visible text of one turn. Turns with empty text
* produce no interim message and are excluded from this list.
*/
export const INTERIM_TEXTS = {
/** All interim texts that should appear as sealed messages when the flag is ON. */
interims: INTERIM_SCRIPT
.filter((t) => t.text && t.toolCalls)
.map((t) => t.text),
/** The final answer text. */
finalText: INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1].text,
/** Text that should NOT produce an interim (empty-text tool turn). */
silentTurnIndex: INTERIM_SCRIPT.findIndex((t) => !t.text && t.toolCalls),
} as const
/** The sidebar-states script's text constants, exported for test assertions. */
export const SIDEBAR_TEXTS = {
/** The interim text from turn 1 (alongside tool calls). */
interimText: SIDEBAR_SCRIPT[0].text,
/** The final answer text. */
finalText: SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1].text,
/** The background process command (for asserting process.list entries). */
bgCommand: 'echo "background process output" && sleep 1 && echo "done"',
/** The subagent's goal (for asserting subagent panel state). */
subagentGoal: 'Summarize the test results',
} as const
/** The cross-session sidebar script's text constants. */
export const SIDEBAR_CROSS_TEXTS = {
/** The interim text from turn 1. */
interimText: SIDEBAR_CROSS_SCRIPT[0].text,
/** The final answer text. */
finalText: SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1].text,
/** The longer background process command (sleep 5). */
bgCommand: 'echo "long bg output" && sleep 5 && echo "finished"',
/** The subagent's goal. */
subagentGoal: 'Analyze cross-session state',
} as const
@@ -1,118 +0,0 @@
/**
* A queued prompt must remain local until the current inference turn settles.
*
* Hold the first streamed reply open after its first token. This gives the
* composer a live, busy turn while the user queues a follow-up, then lets us
* assert against the mock provider's real request log before and after the
* held turn completes.
*/
import { expect, test, type Page } from './test'
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
import { MOCK_REPLY } from './mock-server'
const ACTIVE_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_ACTIVE'
const QUEUED_PROMPT = 'E2E_QUEUE_TURN_BOUNDARY_QUEUED'
const STEER_PROMPT = 'E2E_STEER_TURN_BOUNDARY_CORRECTION'
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 15_000 })
await composer.click()
await composer.type(text, { delay: 5 })
await page.keyboard.press('Enter')
}
async function steer(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
await composer.click()
await composer.type(text, { delay: 5 })
await expect(primary).toHaveAttribute('aria-label', /Steer/)
await primary.click()
}
async function transcriptMessageOrder(page: Page): Promise<string[]> {
return page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return []
return Array.from(viewport.querySelectorAll<HTMLElement>('[data-role="user"], [data-role="assistant"]'))
.map(message => message.textContent?.trim() ?? '')
.filter(Boolean)
})
}
function steerTurnOrder(messages: string[]): string[] {
return messages.flatMap(message => {
if (message.includes(ACTIVE_PROMPT)) return [ACTIVE_PROMPT]
if (message.includes(STEER_PROMPT)) return [STEER_PROMPT]
if (message.includes(MOCK_REPLY)) return [MOCK_REPLY]
return []
})
}
test.describe('queued prompt turn boundary', () => {
let fixture: MockBackendFixture | null = null
test.beforeEach(async () => {
fixture = await setupMockBackend({
mockServer: { holdFirstStreamForPrompt: ACTIVE_PROMPT }
})
await waitForAppReady(fixture, 120_000)
})
test.afterEach(async () => {
await fixture?.cleanup()
fixture = null
})
test('submits a queued prompt only after the active turn completes', async () => {
const { mock, page } = fixture!
const composer = page.locator('[contenteditable="true"]').first()
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await composer.click()
await composer.type(QUEUED_PROMPT, { delay: 5 })
await queue.click()
await expect(page.getByText('1 Queued')).toBeVisible()
// The mock keeps the active SSE stream open, so a queued prompt has no
// completed-turn boundary that could legitimately drain it. Wait past the
// queue retry interval and assert the provider saw only the active turn.
await page.waitForTimeout(1_000)
expect(mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(0)
await expect(page.locator('[data-slot="aui_thread-viewport"]')).not.toContainText(QUEUED_PROMPT)
mock.releaseHeldStream()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 }
)
await expect.poll(() => mock.receivedPrompts.filter(prompt => prompt === QUEUED_PROMPT)).toHaveLength(1)
})
test('places a steer prompt before the reply it redirects', async () => {
const { mock, page } = fixture!
await send(page, ACTIVE_PROMPT)
await mock.waitForHeldStream()
await steer(page, STEER_PROMPT)
mock.releaseHeldStream()
await page.waitForFunction(
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
MOCK_REPLY,
{ timeout: 60_000 }
)
expect(steerTurnOrder(await transcriptMessageOrder(page))).toEqual([ACTIVE_PROMPT, STEER_PROMPT, MOCK_REPLY])
})
})
@@ -1,52 +0,0 @@
#!/usr/bin/env python3
"""Seed a deterministic, tool-free large session into an isolated state.db."""
import sys
from pathlib import Path
repo_root = Path(__file__).resolve().parents[3]
sys.path.insert(0, str(repo_root))
from hermes_state import SessionDB # noqa: E402
SESSION_ID = "e2e-large-session"
SESSION_TITLE = "E2E large persisted session"
def main() -> None:
if len(sys.argv) != 2:
raise SystemExit(f"usage: {sys.argv[0]} <state.db>")
messages = []
for index in range(53):
role = "user" if index % 2 == 0 else "assistant"
content = (
f"E2E persisted user message {index}: audit the compatibility matrix"
if role == "user"
else f"E2E persisted assistant reply {index}: recorded the audit result"
)
messages.append({"role": role, "content": content, "timestamp": 1_700_000_000 + index})
database = SessionDB(db_path=Path(sys.argv[1]))
result = database.import_sessions(
[
{
"id": SESSION_ID,
"source": "desktop",
"model": "mock-model",
"started_at": 1_700_000_000,
"title": SESSION_TITLE,
"cwd": str(repo_root),
"system_prompt": "",
"messages": messages,
}
]
)
database.close()
if not result.get("ok") or result.get("imported") != 1:
raise SystemExit(f"failed to seed large session: {result}")
if __name__ == "__main__":
main()
@@ -1,83 +0,0 @@
/**
* E2E coverage for session compression, which rotates a live backend session.
*/
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { receivedUserTexts, restartMockServer } from './mock-server'
async function send(page: Page, text: string): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(text, { delay: 15 })
await page.keyboard.press('Enter')
}
async function waitForTranscript(page: Page, text: string, timeout = 90_000): Promise<void> {
await page.waitForFunction(
expected => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(expected) ?? false,
text,
{ timeout },
)
}
test.describe('session compression', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('compresses an existing session and accepts a follow-up turn on its continuation', async () => {
const { page } = fixture
const reply = 'Hello from the mock inference server! The full boot chain is working.'
// Three completed exchanges leave a compressible middle after the
// compressor's protected head/tail boundaries.
await send(page, 'E2E_COMPRESSION_FIRST')
await waitForTranscript(page, reply)
await send(page, 'E2E_COMPRESSION_SECOND')
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_SECOND').length).toBe(1)
await send(page, 'E2E_COMPRESSION_THIRD')
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_THIRD').length).toBe(1)
// Commit the command before typing its argument. This waits for the async
// completion request on cold CI workers, then uses the composer's own
// keyboard accept path to replace the `/compress` trigger with a command
// chip. Clicking a later completion after typing the argument can insert a
// second command token (for example `//compress ...`) as plain text.
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type('/compress', { delay: 15 })
await page.getByText('/compress').first().waitFor({ state: 'visible' })
await page.keyboard.press('Enter')
await composer.type(' preserve the three test turns', { delay: 15 })
await page.keyboard.press('Enter')
await expect
.poll(
() => page.locator('[data-slot="aui_thread-viewport"]').textContent(),
{ timeout: 90_000 },
)
.toMatch(/Compressed|No changes from compression/)
// Compression rotates the agent's live session id. A post-compression
// ordinary turn proves the desktop's runtime binding followed that child.
await send(page, 'E2E_COMPRESSION_FOLLOW_UP')
await expect.poll(() => receivedUserTexts().filter(text => text === 'E2E_COMPRESSION_FOLLOW_UP').length).toBe(1)
await waitForTranscript(page, reply)
await page.screenshot({ path: 'test-results/session-compression-continuation.png' })
})
})
-252
View File
@@ -1,252 +0,0 @@
/**
* E2E tests for desktop sidebar states — background processes, subagents,
* and session dot transitions.
*
* The mock server returns scripted tool_calls that the agent executes for
* real (trivial commands + real subagent delegations). The tests assert the
* sidebar states driven by real gateway events.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test, type Page } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { SIDEBAR_CROSS_TEXTS, SIDEBAR_TEXTS, restartMockServer } from './mock-server'
/** Background-running dot aria-label (from i18n en.ts). */
const BG_DOT_LABEL = 'Background task running'
/** Finished-unread dot aria-label. */
const UNREAD_DOT_LABEL = 'Finished — unread'
/** Send a message and wait for the final response to appear. */
async function sendMessageAndWait(
page: Page,
trigger: string,
finalText: string,
timeout = 90_000,
): Promise<void> {
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type(trigger, { delay: 20 })
await page.keyboard.press('Enter')
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_'),
undefined,
{ timeout: 15_000 },
)
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
finalText,
{ timeout },
)
}
// ────────────────────────────────────────────────────────────────────────
// Test 1: background process + subagent appear in sidebar during turn
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — background process and subagent', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('background process dot appears and disappears, subagent runs, final answer visible', async () => {
const page = fixture.page
await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText)
// The background process (sleep 1) should have shown a "Background task
// running" dot at some point during the turn. We try to catch it; if
// the process was too fast, that's OK — the real assertion is that the
// final answer appeared and the dot is gone afterward.
try {
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 15_000, message: 'background dot should appear' },
)
.toBeGreaterThan(0)
} catch {
// sleep 1 may have finished before we polled — not a failure.
}
// After the turn completes and auto-dismiss fires, the background dot
// should be gone.
await page.waitForTimeout(8000)
const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0)
// Evidence: capture the final state — no background dot, final answer visible.
await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' })
// The final answer text must be in the transcript.
const viewportText = await page
.locator('[data-slot="aui_thread-viewport"]')
.textContent()
expect(viewportText).toContain(SIDEBAR_TEXTS.finalText)
})
})
// ────────────────────────────────────────────────────────────────────────
// Test 2: subagent running shows background dot too (longer bg process)
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — subagent and background dot coexist', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('background dot visible while subagent runs', async () => {
const page = fixture.page
// Start the turn but DON'T wait for the final answer yet — we want
// to assert the background dot is visible WHILE the subagent runs.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the user's message to appear.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
undefined,
{ timeout: 15_000 },
)
// The background process (sleep 5) should show a "Background task
// running" dot while the subagent is also running.
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should appear while subagent runs' },
)
.toBeGreaterThan(0)
// Evidence: the background dot is visible while the subagent runs.
await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' })
// Now wait for the final answer to appear.
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
SIDEBAR_CROSS_TEXTS.finalText,
{ timeout: 90_000 },
)
// After the turn + auto-dismiss, the background dot should be gone.
await page.waitForTimeout(8000)
const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgCount, 'background dot should be gone after process exits').toBe(0)
})
})
// ────────────────────────────────────────────────────────────────────────
// Test 3: cross-session — dot updates when viewing a different session
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — cross-session dot transition', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('background dot transitions to finished when viewing another session', async () => {
const page = fixture.page
// Start a turn with a long background process (sleep 5).
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the background dot to appear.
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should appear' },
)
.toBeGreaterThan(0)
// Wait for the final answer (turn completes, but bg process still running).
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
SIDEBAR_CROSS_TEXTS.finalText,
{ timeout: 90_000 },
)
// The background dot should still be visible (sleep 5 hasn't finished yet,
// or auto-dismiss hasn't fired).
const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0)
// Evidence: bg dot visible on session A while its turn is done but the
// background process hasn't exited yet.
await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' })
// Create a new session (click "New session" button).
await page.locator('button:has-text("New session")').first().click()
await page.waitForTimeout(2000)
// Now wait for the background process to finish (sleep 5 + auto-dismiss).
// The session A dot should transition away from "background running".
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
)
.toBe(0)
// The original session should show a "finished unread" indicator (green dot)
// since its turn completed while we were in a different session. This is an
// event-driven transition, so wait for it instead of sampling the DOM right
// after the running dot disappears.
await expect
.poll(
() => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'original session should show finished-unread dot' },
)
.toBeGreaterThan(0)
// Evidence: the green "finished unread" dot on the original session after
// switching to a new session — the cross-session dot transition.
await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' })
})
})
-75
View File
@@ -1,75 +0,0 @@
/**
* Regression coverage for #69578: harmless route-token churn during a send
* must not make the desktop silently drop the prompt before prompt.submit.
*/
import { test, expect } from './test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
const PROMPT = 'E2E route token drift must still submit this prompt.'
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => {
const { page, mock } = fixture!
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(PROMPT, { delay: 10 })
// The submit pipeline snapshots the route synchronously, then awaits session
// creation. Keep changing only the query string of whichever chat route is
// current. Before #69578, comparing the raw route token treated this as a
// user chat switch and aborted before prompt.submit.
await page.evaluate(() => {
let revision = 0
const interval = window.setInterval(() => {
const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new'
window.location.hash = `${pathname}?e2e-route-churn=${revision++}`
}, 1)
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => {
window.clearInterval(interval)
}
})
try {
await page.keyboard.press('Enter')
await expect
.poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 })
.toBe(true)
await page.waitForFunction(
prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false,
PROMPT,
{ timeout: 15_000 },
)
await page.waitForFunction(
() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false,
undefined,
{ timeout: 60_000 },
)
} finally {
await page.evaluate(() => {
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.()
})
}
await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') })
})
-210
View File
@@ -1,210 +0,0 @@
/**
* E2E tests for the tile-unread bug — two scenarios:
*
* 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is
* NOT visible on screen. When it finishes, the green "unread" dot IS
* correct — the user isn't looking at it. This test PASSES.
*
* 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the
* workspace zone opens as a split tile, visible on screen at the same time
* as the main session. When it finishes, it should NOT get the green
* "unread" dot — the user is looking right at it. This test FAILS until
* the fix in session-states.ts:174 lands (the unread check only compares
* against $selectedStoredSessionId and ignores $sessionTiles).
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { expect, test } from '@playwright/test'
import {
type MockBackendFixture,
setupMockBackend,
waitForAppReady,
} from './fixtures'
import { SIDEBAR_CROSS_TEXTS, restartMockServer } from './mock-server'
/** Finished-unread dot aria-label. */
const UNREAD_DOT_LABEL = 'Finished — unread'
/** Background-running dot aria-label. */
const BG_DOT_LABEL = 'Background task running'
/** Locate a session's sidebar row by its preview text. */
function sessionRow(page: import('@playwright/test').Page, text: string) {
return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first()
}
/** Common setup: start a turn with a sleep 5 bg process + subagent, wait for
* the turn to complete, then switch to a new session so the first session is
* no longer $selectedStoredSessionId (required before opening a tile). */
async function startTurnAndSwitchAway(page: import('@playwright/test').Page) {
// Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent.
const composer = page.locator('[contenteditable="true"]').first()
await composer.waitFor({ state: 'visible', timeout: 10_000 })
await composer.click()
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
await page.keyboard.press('Enter')
// Wait for the user's message to appear.
await page.waitForFunction(
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
undefined,
{ timeout: 15_000 },
)
// Wait for the background dot — confirms the turn is running.
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should appear' },
)
.toBeGreaterThan(0)
// Wait for the turn to complete (final answer visible).
await page.waitForFunction(
(text) => (document.body.textContent ?? '').includes(text),
SIDEBAR_CROSS_TEXTS.finalText,
{ timeout: 90_000 },
)
// The background dot should still be visible (sleep 5 hasn't finished).
const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0)
// Switch to a new session — session A is no longer $selectedStoredSessionId.
// This is required: openSessionTile bails if the session is already selected.
await page.locator('button:has-text("New session")').first().click()
await page.waitForTimeout(2000)
}
/** Wait for the background process to finish (sleep 5 + auto-dismiss). */
async function waitForBgProcessToFinish(page: import('@playwright/test').Page) {
await expect
.poll(
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
)
.toBe(0)
}
// ────────────────────────────────────────────────────────────────────────
// Test 1: TAB (not visible) — unread dot IS correct (PASSES)
// ────────────────────────────────────────────────────────────────────────
test.describe('sidebar states — tab (hidden) unread is correct', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('session opened as a tab (not visible) correctly gets unread dot', async () => {
const page = fixture.page
await startTurnAndSwitchAway(page)
// Evidence: session A is in the background (bg dot in sidebar).
await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' })
// ⌃-click opens the session as a TAB (center dock = stacked, not visible
// unless it's the active tab). The session is NOT on screen.
const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText)
await row.click({ modifiers: ['Control'] })
await page.waitForTimeout(2000)
// Evidence: the tab is open but the session is not visible on screen.
await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' })
await waitForBgProcessToFinish(page)
// A tab that's not the active tab IS hidden — the unread dot is correct.
// The user is NOT looking at it, so marking it "unread" is right.
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
expect(unreadCount, 'hidden tab should be marked unread').toBeGreaterThan(0)
await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' })
})
})
// ────────────────────────────────────────────────────────────────────────
// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix)
// ────────────────────────────────────────────────────────────────────────
test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => {
test.describe.configure({ mode: 'serial' })
let fixture: MockBackendFixture
test.beforeAll(async () => {
restartMockServer()
fixture = await setupMockBackend()
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
})
test('session visible in a split tile does NOT get unread dot when it finishes', async () => {
const page = fixture.page
await startTurnAndSwitchAway(page)
// Evidence: session A is in the background (bg dot in sidebar).
await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' })
// Drag the session row from the sidebar to the right edge of the workspace
// zone to create a SPLIT (side-by-side) tile. This triggers the real
// startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path.
const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText)
const rowBox = await row.boundingBox()
expect(rowBox, 'session row must be visible').not.toBeNull()
// Find the workspace zone — the main chat area. We drop on its right edge.
const workspace = page.locator('[data-session-anchor="workspace"]')
const wsBox = await workspace.boundingBox()
expect(wsBox, 'workspace zone must be visible').not.toBeNull()
// Drag from the session row to the right edge of the workspace.
// The drag-session's subZonePosition resolves a right-edge drop as 'right'
// (a split dock), not 'center' (which would be a composer link).
await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2)
await page.mouse.down()
// Move in steps so the drag-session's pointermove handler tracks the
// position and resolves the drop zone (a single jump can miss the
// threshold/engage logic).
const targetX = wsBox!.x + wsBox!.width - 20
const targetY = wsBox!.y + wsBox!.height / 2
const steps = 10
for (let i = 1; i <= steps; i++) {
const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps)
const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps)
await page.mouse.move(x, y)
await page.waitForTimeout(30)
}
await page.mouse.up()
await page.waitForTimeout(2000)
// Evidence: the split tile is now open side-by-side — both sessions visible.
await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' })
await waitForBgProcessToFinish(page)
// THE BUG: the session visible in the split tile should NOT have the green
// "finished unread" dot — the user is looking right at it. This assertion
// FAILS until the fix in session-states.ts:174 lands.
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0)
// Evidence: the green dot should NOT be here — this screenshot shows the bug.
await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' })
})
})
-484
View File
@@ -1,484 +0,0 @@
/**
* E2E regression: warm-route resume must not re-render the transcript more
* than once.
*
* When a session is already in the runtime-id cache (the "warm" path in
* `resumeSession()`), clicking its sidebar row should paint the transcript
* exactly once. Before the fix, the warm cache painted via
* `syncSessionStateToView`, then the `session.activate` RPC returned a
* reconciled message list with different message object references, causing
* `syncSessionStateToView` to fire a second `setMessages` — a visual
* flicker as the transcript DOM was updated.
*
* This test pre-seeds a 32-message session into state.db, boots the app,
* clicks the session (cold resume — populates the warm cache), navigates
* away to a new chat, then clicks back (warm resume). Two detectors run:
*
* 1. A MutationObserver counts additive DOM mutation bursts (childList
* additions). More than 1 burst = the transcript was repainted.
*
* 2. A 2ms innerHTML-length poll counts "reconciles" — DOM content changes
* that happen AFTER the initial paint, while messages are already on
* screen. This catches the case where React reconciles by key without
* adding/removing nodes (same keys → in-place prop update → no
* MutationObserver burst), but `$messages` was still set twice.
*
* The test passes when bursts === 1 AND reconciles === 0.
*
* Prerequisite: `npm run build` must have been run so dist/ exists.
*/
import { spawnSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as os from 'node:os'
import * as path from 'node:path'
import { expect, test } from './test'
import {
type MockBackendFixture,
waitForAppReady,
createSandbox,
writeMockProviderConfig,
writeEnvFile,
buildAppEnv,
launchDesktop,
} from './fixtures'
import { startMockServer } from './mock-server'
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
const SEED_SCRIPT = path.join(DESKTOP_ROOT, 'e2e', 'scripts', 'seed_session_db.py')
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
const SESSION_ID = 'e2e-warm-resume-session'
/** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */
const MESSAGE_COUNT = 32
/** Seeded PRNG so the generated content is deterministic across runs. */
const RNG_SEED = 42
/** Mulberry32 — tiny deterministic PRNG. */
function mulberry32(seed: number): () => number {
let a = seed
return () => {
a |= 0
a = (a + 0x6d2b79f5) | 0
let t = Math.imul(a ^ (a >>> 15), 1 | a)
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
}
}
/** Generate ~40 chars of gibberish from a seeded PRNG. */
function gibberish(rng: () => number): string {
const len = 30 + Math.floor(rng() * 20)
let s = ''
for (let i = 0; i < len; i++) {
s += String.fromCharCode(97 + Math.floor(rng() * 26))
}
return s
}
/** First user message — used as a wait target in the test. */
const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
/**
* Generate a session fixture with MESSAGE_COUNT messages (user/assistant
* pairs) of seeded gibberish — just role + content, enough for SessionDB
* to import and the transcript to render. Written to a temp file for the
* seed script.
*/
function generateSessionFixture(fixturePath: string): void {
const rng = mulberry32(RNG_SEED)
const messages: Array<{ role: string; content: string }> = []
for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
messages.push({ role: 'user', content: gibberish(rng) })
messages.push({ role: 'assistant', content: gibberish(rng) })
}
const session = {
id: SESSION_ID,
source: 'cli',
model: 'mock-model',
system_prompt: '',
started_at: 1721692800.0,
message_count: MESSAGE_COUNT,
title: SESSION_TITLE,
cwd: '/tmp',
archived: 0,
rewind_count: 0,
compression_fallback_streak: 0,
messages,
}
fs.writeFileSync(fixturePath, JSON.stringify(session), 'utf8')
}
/** Resolve the python binary from the nix devshell (falls back to python3). */
function findPython(): string {
const result = spawnSync('which', ['python'], { encoding: 'utf8' })
if (result.status === 0 && result.stdout.trim()) {
return result.stdout.trim()
}
return 'python3'
}
/**
* Set up a mock-backend sandbox with a pre-seeded session in state.db.
*
* Unlike the shared `setupMockBackend()`, this variant seeds the DB
* BEFORE launching the app so the session appears in the sidebar on first
* load — exercising the real `resumeSession()` cold path without needing
* to send a message first.
*/
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
// 1. Start mock server
const mock = await startMockServer()
// 2. Create sandbox + write config
const sandbox = createSandbox('warm-seed')
writeMockProviderConfig(sandbox.hermesHome, mock.url)
writeEnvFile(sandbox.hermesHome)
// 3. Pre-seed state.db: generate a fixture JSON to a temp file, then
// run the seed script to import it into state.db BEFORE launching.
const stateDbPath = path.join(sandbox.hermesHome, 'state.db')
const fixturePath = path.join(os.tmpdir(), `hermes-e2e-warm-resume-${Date.now()}.json`)
generateSessionFixture(fixturePath)
const python = findPython()
const seedResult = spawnSync(
python,
[SEED_SCRIPT, stateDbPath, fixturePath],
{
cwd: REPO_ROOT,
env: { ...process.env, PYTHONPATH: REPO_ROOT },
encoding: 'utf8',
timeout: 30_000,
},
)
fs.unlinkSync(fixturePath)
if (seedResult.status !== 0) {
throw new Error(
`Failed to seed state.db:\nstdout: ${seedResult.stdout}\nstderr: ${seedResult.stderr}`,
)
}
// 4. Build env + launch
const env = buildAppEnv(sandbox)
const { app, page } = await launchDesktop(env)
return {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
}
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
fixture = await setupSeededMockBackend()
await waitForAppReady(fixture!, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
/**
* Install a MutationObserver + text-content poll on the thread viewport
* to detect re-renders after the initial paint. Returns nothing — call
* `readRenderCount` to stop and collect results.
*
* - MutationObserver: counts additive childList bursts (5ms coalescing).
* - Text-content poll: counts "reconciles" — first-message text changes
* after the initial paint, catching key-based reconciles that don't
* add/remove nodes.
*/
async function installRenderCounter(page: import('@playwright/test').Page): Promise<void> {
await page.evaluate(() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) {
throw new Error('Thread viewport not found before warm resume')
}
const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 }
;(window as unknown as { __RENDER_COUNT__: typeof state }).__RENDER_COUNT__ = state
let currentBatch = 0
let flushTimer: ReturnType<typeof setTimeout> | null = null
const flush = () => {
flushTimer = null
if (currentBatch > 0 && !state.stopped) {
state.bursts += 1
state.timeline.push(currentBatch)
currentBatch = 0
}
}
const observer = new MutationObserver(records => {
if (state.stopped) return
let batchAdded = 0
for (const record of records) {
state.mutations += 1
if (record.type === 'childList' && record.addedNodes.length > 0) {
batchAdded += 1
}
}
if (batchAdded > 0) {
currentBatch += batchAdded
if (flushTimer) clearTimeout(flushTimer)
flushTimer = setTimeout(flush, 5)
}
})
observer.observe(viewport, {
childList: true,
subtree: true,
attributes: false,
characterData: false,
})
// Poll the first message's text content every 2ms. The MutationObserver
// only catches childList additions; React may reconcile by key without
// adding/removing nodes (same keys → in-place prop update → no childList
// mutation). The poll catches this by detecting text content changes in
// the first message after the initial paint. Metadata-only changes (model
// name, busy indicator) don't affect message text, so they don't produce
// false positives.
const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport
let lastFirstMsgText = ''
let hasMessages = false
const pollInterval = setInterval(() => {
if (state.stopped) {
clearInterval(pollInterval)
return
}
const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]')
const firstMsgText = firstMsg?.textContent ?? ''
if (firstMsgText && firstMsgText !== lastFirstMsgText) {
if (hasMessages) {
state.reconciles = (state.reconciles ?? 0) + 1
}
lastFirstMsgText = firstMsgText
hasMessages = true
}
}, 2)
})
}
/** Stop the render counter and return the recorded burst/reconcile counts. */
async function readRenderCount(page: import('@playwright/test').Page): Promise<{
bursts: number
mutations: number
timeline: number[]
reconciles: number
} | null> {
return page.evaluate(() => {
type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number }
const w = window as unknown as { __RENDER_COUNT__?: RenderCount }
const rc = w.__RENDER_COUNT__
if (rc) {
rc.stopped = true
}
return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null
})
}
/** Assert the render counter shows exactly one paint with no re-renders. */
function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
expect(
result!.bursts,
`Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` +
`Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
).toBe(1)
expect(
result!.reconciles,
`Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` +
`This means the warm-route resume re-rendered the transcript after the initial paint ` +
`— the "warm resume jitter" bug is present.`,
).toBe(0)
}
test('warm-route resume paints transcript exactly once (no jitter)', async ({}, testInfo) => {
const page = fixture!.page
// Wait for the sidebar to populate with our seeded session.
const sessionRow = page
.locator('[data-slot="sidebar"] button')
.filter({ hasText: SESSION_TITLE })
.first()
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
// Step 1: Cold resume — click the session row to load it.
// This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId).
await sessionRow.click()
// Wait for the transcript to appear — the first user message text confirms
// the cold-path prefetch painted.
await page.waitForFunction(
(text: string) =>
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
false,
FIRST_USER_MSG,
{ timeout: 30_000 },
)
// Wait for the session to fully settle (cold-path RPC + reconciliation).
await page.waitForTimeout(2_000)
// Step 2: Navigate away to a new chat — this does NOT evict the warm cache.
const newSessionButton = page
.locator('[data-slot="sidebar"] button[aria-label="New session"]')
.first()
await newSessionButton.click()
// Wait for the new-chat empty state.
await page.waitForFunction(
(firstMsg: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return false
const text = viewport.textContent ?? ''
return !text.includes(firstMsg)
},
FIRST_USER_MSG,
{ timeout: 15_000 },
)
await page.waitForTimeout(500)
// Step 3: Install render counter, click back (warm resume), wait, assert.
await installRenderCounter(page)
await sessionRow.click()
await page.waitForFunction(
(text: string) =>
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
false,
FIRST_USER_MSG,
{ timeout: 30_000 },
)
// Wait for at least 1 burst, then settle.
await page.waitForFunction(
() => {
const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
},
undefined,
{ timeout: 10_000 },
)
await page.waitForTimeout(2_000)
const result = await readRenderCount(page)
await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') })
assertNoJitter(result)
})
test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => {
test.fixme(
true,
'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).',
)
const page = fixture!.page
const { mock } = fixture!
// Wait for the sidebar to populate with our seeded session.
const sessionRow = page
.locator('[data-slot="sidebar"] button')
.filter({ hasText: SESSION_TITLE })
.first()
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
// Step 1: Cold resume — populate the warm cache.
await sessionRow.click()
await page.waitForFunction(
(text: string) =>
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
false,
FIRST_USER_MSG,
{ timeout: 30_000 },
)
await page.waitForTimeout(2_000)
// Step 2: Send a message — triggers inference via the mock server.
const PROMPT = 'E2E post-inference warm resume test prompt'
const composer = page.locator('[contenteditable="true"]').first()
await composer.click()
await composer.type(PROMPT, { delay: 10 })
await page.keyboard.press('Enter')
// Wait for the mock response to appear in the transcript, confirming
// the turn completed and message.complete fired (which updates the warm
// cache via updateSessionState).
await page.waitForFunction(
() => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
return viewport?.textContent?.includes('mock inference server') ?? false
},
undefined,
{ timeout: 60_000 },
)
// Extra settle for message.complete → updateSessionState → cache write.
await page.waitForTimeout(2_000)
// Verify the prompt was received by the mock server.
expect(mock.receivedPrompts).toContain(PROMPT)
// Step 3: Navigate away — the warm cache retains the updated messages.
const newSessionButton = page
.locator('[data-slot="sidebar"] button[aria-label="New session"]')
.first()
await newSessionButton.click()
await page.waitForFunction(
(prompt: string) => {
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
if (!viewport) return false
return !(viewport.textContent ?? '').includes(prompt)
},
PROMPT,
{ timeout: 15_000 },
)
await page.waitForTimeout(500)
// Step 4: Install render counter, click back (warm resume), wait, assert.
await installRenderCounter(page)
await sessionRow.click()
// Wait for the transcript to reappear — the warm cache should already
// have the completed turn (updated by message.complete events).
await page.waitForFunction(
(text: string) =>
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
false,
FIRST_USER_MSG,
{ timeout: 30_000 },
)
// Wait for at least 1 burst, then settle.
await page.waitForFunction(
() => {
const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
},
undefined,
{ timeout: 10_000 },
)
await page.waitForTimeout(2_000)
const result = await readRenderCount(page)
await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') })
assertNoJitter(result)
})
@@ -1,94 +0,0 @@
import { execFileSync } from 'node:child_process'
import * as fs from 'node:fs'
import * as path from 'node:path'
import { test, expect } from './test'
import {
buildAppEnv,
createSandbox,
launchDesktop,
writeEnvFile,
writeMockProviderConfig,
type MockBackendFixture,
waitForAppReady,
} from './fixtures'
import { startMockServer } from './mock-server'
const BRANCH_NAME = 'e2e-composer-branch'
function createGitRepo(root: string): string {
const repo = path.join(root, 'repo')
fs.mkdirSync(repo, { recursive: true })
execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo })
execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo })
execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo })
fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8')
execFileSync('git', ['add', 'README.md'], { cwd: repo })
execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo })
return repo
}
function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void {
writeMockProviderConfig(hermesHome, mockUrl)
fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8')
writeEnvFile(hermesHome)
}
let fixture: MockBackendFixture | null = null
test.beforeAll(async () => {
const sandbox = createSandbox('worktree-branch-status')
const repo = createGitRepo(sandbox.root)
const mock = await startMockServer()
configureRepoCwd(sandbox.hermesHome, mock.url, repo)
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
fixture = {
app,
page,
mock,
mockUrl: mock.url,
sandbox,
cleanup: async () => {
await app.close().catch(() => undefined)
await mock.close()
sandbox.cleanup()
},
}
await waitForAppReady(fixture, 120_000)
})
test.afterAll(async () => {
await fixture?.cleanup()
fixture = null
})
test('creating a branch with ctrl-shift-b updates the composer git-status branch', async ({}, testInfo) => {
const page = fixture!.page
const codingRow = page.locator('.coding-status-bar')
const composer = page.locator('[contenteditable="true"]').first()
await expect(codingRow).toContainText('main')
await composer.click()
await composer.type('create a repo-backed e2e session', { delay: 2 })
await page.keyboard.press('Enter')
await page.waitForFunction(
prompt => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(prompt),
'create a repo-backed e2e session',
{ timeout: 15_000 },
)
await page.keyboard.press('Control+Shift+B')
const branchInput = page.locator('input[placeholder="e.g. my-feature"]').first()
await expect(branchInput).toBeVisible()
await branchInput.fill(BRANCH_NAME)
await page.getByRole('button', { name: 'New worktree' }).click()
await expect(codingRow).toContainText(BRANCH_NAME, { timeout: 15_000 })
await page.screenshot({ path: testInfo.outputPath('composer-branch-after-create.png') })
})
+1 -10
View File
@@ -12,7 +12,7 @@ import path from 'node:path'
import { test } from 'vitest'
import { canImportHermesCli, hermesRuntimeImportProbe, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
// Resolve the host's own Node binary -- guaranteed to be on disk and
// runnable. We use it as both a stand-in for "a python that doesn't
@@ -51,15 +51,6 @@ test('hermes runtime import probe checks config dependencies', () => {
assert.match(probe, /\bimport hermes_cli\.config\b/)
})
test('explicit Hermes override is authoritative', () => {
assert.equal(shouldTrustHermesOverride('/nix/store/abc/bin/hermes'), true)
})
test('empty Hermes override is not authoritative', () => {
assert.equal(shouldTrustHermesOverride(''), false)
assert.equal(shouldTrustHermesOverride(undefined), false)
})
test('verifyHermesCli returns false when command is falsy', () => {
assert.equal(verifyHermesCli(''), false)
assert.equal(verifyHermesCli(null), false)
+1 -11
View File
@@ -104,16 +104,6 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
* in resolveHermesBackend.
* @returns {boolean}
*/
/**
* An explicit desktop backend command is a deployment contract, not a PATH
* discovery candidate. In particular, the Nix desktop wrapper points this at
* its immutable, matching Hermes package; it must never fall through to the
* mutable install-script bootstrap path if a best-effort probe is slow.
*/
function shouldTrustHermesOverride(hermesOverride?: string) {
return typeof hermesOverride === 'string' && hermesOverride.trim().length > 0
}
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
if (!hermesCommand) {
return false
@@ -133,4 +123,4 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
}
}
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verifyHermesCli }
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }
+22 -21
View File
@@ -34,7 +34,7 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child'
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
import { createBackendConnectionState } from './backend-connection-state'
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
import { canImportHermesCli, verifyHermesCli } from './backend-probes'
import { waitForDashboardPortAnnouncement } from './backend-ready'
import { shouldLatchBackendStartFailure } from './backend-start-failure'
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
@@ -80,6 +80,19 @@ import { installEmbedReferer } from './embed-referer'
import { createEventDeduper } from './event-dedupe'
import { readDirForIpc } from './fs-read-dir'
import { probeGatewayWebSocket } from './gateway-ws-probe'
import { runNativeLogin } from './native-oauth-login'
import {
nativeRefreshUrl,
parseTokenResponse,
resolveLoginStrategy,
tokenNeedsRefresh,
type NativeTokenSet
} from './native-oauth'
import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth
} from './native-auth-decisions'
import { scanGitRepos } from './git-repo-scan'
import {
fileDiffVsHead,
@@ -116,15 +129,6 @@ import {
} from './hardening'
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
import { ensureMainWindow } from './main-window-lifecycle'
import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions'
import {
nativeRefreshUrl,
type NativeTokenSet,
parseTokenResponse,
resolveLoginStrategy,
tokenNeedsRefresh
} from './native-oauth'
import { runNativeLogin } from './native-oauth-login'
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
import { createKeepAwake } from './power-save'
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
@@ -3564,11 +3568,7 @@ function resolveHermesBackend(backendArgs) {
// and lets the resolver fall through to step 6 / bootstrap.
const shellForProbe = isCommandScript(hermesCommand)
// HERMES_DESKTOP_HERMES is an explicit deployment override (used by
// the Nix wrapper), not a discovered PATH candidate. It must not fall
// through to the install-script bootstrap if the optional probe times
// out under load; the pinned backend is the only valid runtime there.
if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
return (
unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || {
label: `existing Hermes CLI at ${hermesCommand}`,
@@ -5851,7 +5851,6 @@ async function ensureNativeAccessToken(baseUrl: string): Promise<string | null>
{ refresh_token: tokens.refreshToken, provider: tokens.provider },
{ timeoutMs: 10_000 }
)
const rotated = parseTokenResponse(body)
_storeNativeTokens(baseUrl, rotated)
@@ -5884,7 +5883,6 @@ async function mintGatewayWsTicket(baseUrl) {
timeoutMs: 8_000,
bearer: nativeAt
})) as any
const ticket = body?.ticket
if (!ticket || typeof ticket !== 'string') {
@@ -6461,7 +6459,10 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
// RFC 8252 flow) counts as connected too — otherwise a completed native
// sign-in shows "not connected" in Settings. The authoritative liveness
// check is the ws-ticket mint in resolveRemoteBackend at actual connect time.
remoteOauthConnected = oauthSessionIsLive(hasNativeSession(remoteUrl), await hasLiveOauthSession(remoteUrl))
remoteOauthConnected = oauthSessionIsLive(
hasNativeSession(remoteUrl),
await hasLiveOauthSession(remoteUrl)
)
} catch {
remoteOauthConnected = false
}
@@ -8954,7 +8955,6 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
postJson: (url, body, opts) => postJsonNoAuth(url, body, opts),
rememberLog
})
_storeNativeTokens(baseUrl, tokens)
return { ok: true, baseUrl, connected: true }
@@ -8977,7 +8977,6 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
await clearOauthSession(baseUrl || undefined)
// Also drop any native (RFC 8252) bearer tokens for this gateway so a
// logout clears BOTH auth shapes.
if (baseUrl) {
@@ -8987,7 +8986,9 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) =
// Report against the SAME liveness notion the Settings indicator uses
// (AT-or-RT cookie, or a native token) so a logout that left any session
// behind is reflected as still-connected rather than silently signed-out.
const connected = baseUrl ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) : false
const connected = baseUrl
? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl)
: false
return { ok: true, connected }
})
@@ -10,7 +10,11 @@ import assert from 'node:assert/strict'
import { test } from 'vitest'
import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions'
import {
oauthSessionIsLive,
resolveJsonBody,
resolveOauthRestAuth
} from './native-auth-decisions'
// --- 1. body encoding (guards the double-JSON.stringify 422) ---
@@ -44,7 +44,9 @@ export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: bo
return hasNativeToken || hasCookieSession
}
export type OauthRestAuth = { kind: 'bearer'; token: string } | { kind: 'cookie' }
export type OauthRestAuth =
| { kind: 'bearer'; token: string }
| { kind: 'cookie' }
/**
* Decide how an oauth-mode REST request authenticates: prefer the native
@@ -22,18 +22,14 @@ function makeFakeServerFactory(port = 51234) {
const createServer: any = (handler: any) => {
state.handler = handler
const server: any = new EventEmitter()
server.listen = (_port: number, _host: string, cb: () => void) => {
state.listening = true
cb()
}
server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port })
server.close = () => {
state.closed = true
}
state.server = server
return server
+2 -4
View File
@@ -32,10 +32,10 @@ import {
buildNativeAuthorizeUrl,
generatePkcePair,
generateState,
type NativeTokenSet,
nativeTokenUrl,
parseLoopbackCallback,
parseTokenResponse
parseTokenResponse,
type NativeTokenSet
} from './native-oauth'
// Loopback login must complete inside this window (user opens browser,
@@ -90,7 +90,6 @@ export async function runNativeLogin(
return new Promise<NativeTokenSet>((resolve, reject) => {
let settled = false
let timer: NodeJS.Timeout | null = null
const server = createServer((req, res) => {
// Only the callback path carries the code; any other path (favicon,
// etc.) still gets the friendly page so the browser tab looks sane.
@@ -180,7 +179,6 @@ export async function runNativeLogin(
}
const redirectUri = `http://127.0.0.1:${addr.port}/callback`
const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, {
challenge,
redirectUri,
+4 -5
View File
@@ -34,7 +34,6 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => {
assert.equal(pair.method, 'S256')
// Verifier length within RFC 7636 range (43–128).
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
// Challenge must be the base64url SHA-256 of the verifier.
const expected = createHash('sha256')
.update(pair.verifier, 'ascii')
@@ -42,7 +41,6 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => {
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
assert.equal(pair.challenge, expected)
// No padding / URL-unsafe chars.
assert.doesNotMatch(pair.verifier, /[+/=]/)
@@ -93,7 +91,6 @@ test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
state: 'STATE',
provider: 'nous'
})
const parsed = new URL(url)
assert.equal(parsed.origin, 'https://gw.example.com')
@@ -111,7 +108,6 @@ test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix
redirectUri: 'http://127.0.0.1:1/cb',
state: 'S'
})
const parsed = new URL(url)
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
@@ -132,7 +128,10 @@ test('parseLoopbackCallback returns the code on a state match', () => {
})
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i)
assert.throws(
() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'),
/state mismatch/i
)
})
test('parseLoopbackCallback surfaces a gateway error param', () => {
+9 -8
View File
@@ -86,7 +86,10 @@ export function statusSupportsNativeFlow(statusBody: any): boolean {
* (e.g. a corporate proxy that blocks loopback). Precedence written down here,
* in one place, as a pure function — per the desktop "observable ladder" rule.
*/
export function resolveLoginStrategy(statusBody: any, opts: { forceEmbedded?: boolean } = {}): 'native' | 'embedded' {
export function resolveLoginStrategy(
statusBody: any,
opts: { forceEmbedded?: boolean } = {}
): 'native' | 'embedded' {
if (opts.forceEmbedded) {
return 'embedded'
}
@@ -106,7 +109,6 @@ export function buildNativeAuthorizeUrl(
): string {
const parsed = new URL(baseUrl)
const prefix = parsed.pathname.replace(/\/+$/, '')
const q = new URLSearchParams({
code_challenge: params.challenge,
code_challenge_method: 'S256',
@@ -143,7 +145,10 @@ export function nativeRefreshUrl(baseUrl: string): string {
* `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch
* throws rather than proceeding.
*/
export function parseLoopbackCallback(requestUrl: string, expectedState: string): { code: string } {
export function parseLoopbackCallback(
requestUrl: string,
expectedState: string
): { code: string } {
// requestUrl is the path+query the loopback server received, e.g.
// "/callback?code=...&state=...". Resolve against a dummy origin to parse.
const parsed = new URL(requestUrl, 'http://127.0.0.1')
@@ -198,11 +203,7 @@ export function parseTokenResponse(body: any): NativeTokenSet {
* before use. `skewSeconds` refreshes slightly early to avoid a race where
* the token expires in flight (mirrors the server's 60s cookie floor).
*/
export function tokenNeedsRefresh(
tokens: Pick<NativeTokenSet, 'expiresAt'>,
nowSeconds: number,
skewSeconds = 60
): boolean {
export function tokenNeedsRefresh(tokens: Pick<NativeTokenSet, 'expiresAt'>, nowSeconds: number, skewSeconds = 60): boolean {
if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) {
// Unknown expiry ⇒ treat as needing refresh so we validate before use.
return true
+8 -12
View File
@@ -11,19 +11,15 @@
"node": "^20.19.0 || >=22.12.0"
},
"scripts": {
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",
"clean:e2e":"tsc --build tsconfig.e2e.json --clean",
"clean:renderer":"tsc --build tsconfig.json --clean ",
"clean:electron":"tsc --build tsconfig.electron.json --clean",
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
"dev:mock": "node scripts/dev-mock.mjs",
"dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174",
"dev:electron": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174",
"dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
"start": "npm run build && electron .",
"prebuild": "npm run clean",
"prebuild": "tsc -b . --clean",
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
"postbuild": "node scripts/assert-dist-built.mjs",
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
@@ -55,9 +51,9 @@
"test": "vitest run",
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
"test:e2e": "npm run build && playwright test e2e/",
"test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
"test:e2e": "playwright test e2e/",
"test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
"test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
},
"dependencies": {
"@assistant-ui/react": "^0.14.23",
@@ -1,79 +0,0 @@
import { cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ChatBarState } from '@/app/chat/composer/types'
import { I18nProvider } from '@/i18n'
import { ComposerControls } from './controls'
vi.mock('./model-pill', () => ({ ModelPill: () => null }))
const state: ChatBarState = {
model: { canSwitch: false, model: '', provider: '' },
tools: { enabled: false, label: '' },
voice: { active: false, enabled: false }
}
function renderControls(overrides: Partial<React.ComponentProps<typeof ComposerControls>> = {}) {
return render(
<I18nProvider configClient={null} initialLocale="en">
<ComposerControls
autoSpeak={false}
busy={false}
busyAction="stop"
canSubmit={true}
conversation={{
active: false,
level: 0,
muted: false,
onEnd: vi.fn(),
onStart: vi.fn(),
onStopTurn: vi.fn(),
onToggleMute: vi.fn(),
status: 'idle'
}}
disabled={false}
hasComposerPayload={true}
onDictate={vi.fn()}
onQueue={vi.fn()}
onToggleAutoSpeak={vi.fn()}
state={state}
voiceStatus="idle"
{...overrides}
/>
</I18nProvider>
)
}
async function expectShortcutTooltip(label: string, shortcut: string) {
fireEvent.pointerMove(screen.getByLabelText(label), { pointerType: 'mouse' })
const tooltip = await screen.findByRole('tooltip')
expect(tooltip.textContent).toContain(label)
expect(tooltip.textContent).toContain(shortcut)
}
afterEach(() => {
cleanup()
})
describe('ComposerControls shortcut tooltips', () => {
it('shows Enter for Send', async () => {
renderControls()
await expectShortcutTooltip('Send', '↵')
})
it('shows Ctrl+Enter for Steer', async () => {
renderControls({ busy: true, busyAction: 'steer' })
await expectShortcutTooltip('Steer the current run', 'Ctrl+↵')
})
it('shows Enter for Queue', async () => {
renderControls({ busy: true, busyAction: 'queue' })
await expectShortcutTooltip('Queue message', '↵')
})
})
+30 -25
View File
@@ -1,9 +1,11 @@
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { KbdCombo } from '@/components/ui/kbd'
import { Tip } from '@/components/ui/tooltip'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { cn } from '@/lib/utils'
import type { ConversationStatus } from './hooks/use-voice-conversation'
@@ -40,6 +42,7 @@ export function ComposerControls({
autoSpeak,
busy,
busyAction,
canSteer,
canSubmit,
compactModelPill = false,
conversation,
@@ -48,12 +51,13 @@ export function ComposerControls({
state,
voiceStatus,
onDictate,
onQueue,
onSteer,
onToggleAutoSpeak
}: {
autoSpeak: boolean
busy: boolean
busyAction: 'steer' | 'queue' | 'stop'
busyAction: 'queue' | 'stop'
canSteer: boolean
canSubmit: boolean
compactModelPill?: boolean
conversation: ConversationProps
@@ -62,39 +66,50 @@ export function ComposerControls({
state: ChatBarState
voiceStatus: VoiceStatus
onDictate: () => void
onQueue: () => void
onSteer: () => void
onToggleAutoSpeak: () => void
}) {
const { t } = useI18n()
const c = t.composer
const steerCombo = formatCombo('mod+enter')
const steerLabel = `${c.steer} (${steerCombo})`
const steerTip = (
<span className="inline-flex items-center gap-1.5">
{c.steer}
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
</span>
)
if (conversation.active) {
return <ConversationPill {...conversation} disabled={disabled} />
}
const showVoicePrimary = !busy && !hasComposerPayload
const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop
return (
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
{busyAction === 'steer' ? (
<Tip label={<TipKeybindLabel actionId="composer.send" text={c.queueMessage} />}>
{/* While the agent runs and the user is typing, steer takes over the mic's
slot rather than crowding the row with an extra button. */}
{canSteer ? (
<Tip label={steerTip}>
<Button
aria-label={c.queueMessage}
aria-label={steerLabel}
className={GHOST_ICON_BTN}
disabled={disabled}
onClick={onQueue}
onClick={onSteer}
size="icon"
type="button"
variant="ghost"
>
<Layers3 className={iconSize.sm} />
<SteeringWheel className={iconSize.sm} />
</Button>
</Tip>
) : null}
) : (
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
)}
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
{showVoicePrimary ? (
<Tip label={c.startVoice}>
<Button
@@ -112,17 +127,9 @@ export function ComposerControls({
</Button>
</Tip>
) : (
<Tip
label={
busy ? (
<TipKeybindLabel actionId={busyAction === 'steer' ? 'composer.steer' : 'composer.send'} text={busyLabel} />
) : (
<TipKeybindLabel actionId="composer.send" text={c.send} />
)
}
>
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
<Button
aria-label={busy ? busyLabel : c.send}
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
className={PRIMARY_ICON_BTN}
disabled={disabled || !canSubmit}
type="submit"
@@ -130,8 +137,6 @@ export function ComposerControls({
{busy ? (
busyAction === 'queue' ? (
<Layers3 className={iconSize.sm} />
) : busyAction === 'steer' ? (
<SteeringWheel className={iconSize.sm} />
) : (
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
)
@@ -1,143 +0,0 @@
import { act, cleanup, renderHook, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ComposerAttachment } from '@/store/composer'
import { useComposerSubmit } from './use-composer-submit'
interface SubmitHarnessOptions {
attachments?: ComposerAttachment[]
busy?: boolean
text?: string
}
function renderSubmitHook({ attachments = [], busy = false, text = '' }: SubmitHarnessOptions = {}) {
const draftRef = { current: text }
const editor = document.createElement('div')
editor.dataset.slot = 'composer-rich-input'
editor.textContent = text
const editorRef = { current: editor }
const onCancel = vi.fn()
const onSteer = vi.fn(async () => true)
const onSubmit = vi.fn(async () => true)
const queueCurrentDraft = vi.fn(() => true)
const clearDraft = vi.fn(() => {
draftRef.current = ''
editorRef.current!.textContent = ''
})
const hook = renderHook(() =>
useComposerSubmit({
activeQueueSessionKey: 'stored-session',
activeQueueSessionKeyRef: { current: 'stored-session' },
attachments,
busy,
clearDraft,
disabled: false,
draftRef,
drainNextQueued: vi.fn(async () => false),
editorRef,
exitQueuedEdit: vi.fn(() => false),
focusInput: vi.fn(),
inputDisabled: false,
loadIntoComposer: vi.fn(),
onCancel,
onSteer,
onSubmit,
queueCurrentDraft,
queueEdit: null,
queuedPrompts: [],
sessionId: 'runtime-session',
setComposerText: vi.fn(),
stashAt: vi.fn()
})
)
return { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft }
}
describe('useComposerSubmit busy-turn routing', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('steers a plain-text follow-up instead of queueing or stopping', async () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
text: 'change course'
})
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSteer).toHaveBeenCalledWith('change course'))
expect(queueCurrentDraft).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
})
it('runs slash commands immediately while busy', async () => {
const { clearDraft, hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
busy: true,
text: '/compress preserve context'
})
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('/compress preserve context'))
expect(clearDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('queues an attachment-bearing follow-up while busy', () => {
const attachment: ComposerAttachment = { id: 'doc', kind: 'file', label: 'notes.txt' }
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({
attachments: [attachment],
busy: true,
text: 'read this'
})
act(() => {
hook.result.current.submitDraft()
})
expect(queueCurrentDraft).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
it('stops an active turn only with an empty composer', () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ busy: true })
act(() => {
hook.result.current.submitDraft()
})
expect(onCancel).toHaveBeenCalledTimes(1)
expect(onSteer).not.toHaveBeenCalled()
expect(onSubmit).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
})
it('submits a normal turn while idle', async () => {
const { hook, onCancel, onSteer, onSubmit, queueCurrentDraft } = renderSubmitHook({ text: 'ordinary question' })
act(() => {
hook.result.current.submitDraft()
})
await waitFor(() => expect(onSubmit).toHaveBeenCalledWith('ordinary question', { attachments: [] }))
expect(onSteer).not.toHaveBeenCalled()
expect(queueCurrentDraft).not.toHaveBeenCalled()
expect(onCancel).not.toHaveBeenCalled()
})
})
@@ -17,6 +17,7 @@ interface UseComposerSubmitArgs {
activeQueueSessionKeyRef: RefObject<string | null>
attachments: ComposerAttachment[]
busy: boolean
canSteer: boolean
clearDraft: () => void
disabled: boolean
draftRef: RefObject<string>
@@ -42,7 +43,7 @@ interface UseComposerSubmitArgs {
* queue meet. `submitDraft` is the one decision tree (queue-edit save · slash-
* now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared
* send-with-restore primitive (re-loads + re-stashes the draft if the gateway
* rejects, so nothing is ever lost); `steerDraft` redirects the live turn. Reads
* rejects, so nothing is ever lost); `steerDraft` nudges the live turn. Reads
* the draft + queue APIs; owns no state of its own beyond the stable
* external-submit listener ref.
*/
@@ -51,6 +52,7 @@ export function useComposerSubmit({
activeQueueSessionKeyRef,
attachments,
busy,
canSteer,
clearDraft,
disabled,
draftRef,
@@ -149,14 +151,7 @@ export function useComposerSubmit({
triggerHaptic('submit')
clearDraft()
dispatchSubmit(text)
} else if (!attachments.length && text.trim()) {
// Cursor-style stop-and-correct: interrupt the live turn and redirect
// it with this text. redirect() preserves the shown reasoning/work; if
// the turn already ended, steerDraft re-queues so nothing is lost.
steerDraft()
} else if (payloadPresent) {
// Attachments can't ride a redirect (no tool-result image carriage) —
// queue the whole payload for the next turn.
queueCurrentDraft()
} else {
// Stop button (the only way to reach here while busy with an empty
@@ -178,18 +173,16 @@ export function useComposerSubmit({
focusInput()
}
// Redirect the live turn with a correction. The gateway either restarts the
// active model request with its displayed context or waits for the current
// tool boundary. If the turn already ended, queue the words instead.
// Steer the live turn (nudge without interrupting). Clears the draft up front
// for snappy feedback; if the gateway rejects (no live tool window) the words
// are re-queued so nothing is lost — same safety net as a plain queue.
const steerDraft = () => {
const text = draftRef.current.trim()
// Guard on live editor state, not the render-lagged `canSteer`: a redirect
// fired on a fast Enter must not be dropped because state hasn't synced.
if (!onSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
if (!onSteer || !canSteer) {
return
}
const text = draftRef.current.trim()
triggerHaptic('submit')
clearDraft()
@@ -200,14 +193,5 @@ export function useComposerSubmit({
})
}
const queueDraft = () => {
if (disabled || !busy) {
return
}
queueCurrentDraft()
focusInput()
}
return { dispatchSubmit, queueDraft, steerDraft, submitDraft }
return { dispatchSubmit, steerDraft, submitDraft }
}
@@ -1,14 +1,7 @@
import { useCallback, useEffect, useRef, useState } from 'react'
import { useI18n } from '@/i18n'
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
import {
markVoicePlaybackInterrupted,
playSpeechText,
type SpeechStreamSession,
startSpeechStream,
stopVoicePlayback
} from '@/lib/voice-playback'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
import { notify, notifyError } from '@/store/notifications'
import { useMicRecorder } from './use-mic-recorder'
@@ -51,9 +44,7 @@ export function useVoiceConversation({
const awaitingSpokenResponseRef = useRef(false)
const responseIdRef = useRef<string | null>(null)
const spokenSourceLengthRef = useRef(0)
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
const bargeCapturePendingRef = useRef(false)
const speechBufferRef = useRef('')
const enabledRef = useRef(enabled)
const mutedRef = useRef(muted)
const busyRef = useRef(busy)
@@ -83,13 +74,60 @@ export function useVoiceConversation({
}
}
const dropSpeechSession = () => {
stopBargeMonitorRef.current?.()
stopBargeMonitorRef.current = null
bargeCapturePendingRef.current = false
speechSessionRef.current = null
const resetSpeechBuffer = () => {
responseIdRef.current = null
spokenSourceLengthRef.current = 0
speechBufferRef.current = ''
}
const appendSpeechText = (text: string) => {
if (!text) {
return
}
speechBufferRef.current = `${speechBufferRef.current}${text}`
}
const takeSpeechChunk = (force = false): string | null => {
const buffer = speechBufferRef.current.replace(/\s+/g, ' ').trim()
if (!buffer) {
speechBufferRef.current = ''
return null
}
const sentence = buffer.match(/^(.+?[.!?。!?])(?:\s+|$)/)
if (sentence?.[1] && (sentence[1].length >= 8 || force)) {
const chunk = sentence[1].trim()
speechBufferRef.current = buffer.slice(sentence[1].length).trim()
return chunk
}
if (!force && buffer.length > 220) {
const softBoundary = Math.max(
buffer.lastIndexOf(', ', 180),
buffer.lastIndexOf('; ', 180),
buffer.lastIndexOf(': ', 180)
)
if (softBoundary > 80) {
const chunk = buffer.slice(0, softBoundary + 1).trim()
speechBufferRef.current = buffer.slice(softBoundary + 1).trim()
return chunk
}
}
if (!force) {
return null
}
speechBufferRef.current = ''
return buffer
}
const handleTurn = useCallback(
@@ -129,7 +167,7 @@ export function useVoiceConversation({
}
awaitingSpokenResponseRef.current = true
dropSpeechSession()
resetSpeechBuffer()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
@@ -155,10 +193,6 @@ export function useVoiceConversation({
return
}
if (bargeCapturePendingRef.current) {
return // the barge monitor is mid-capture and owns the mic
}
if (statusRef.current !== 'idle') {
return
}
@@ -186,238 +220,24 @@ export function useVoiceConversation({
}
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
const settleAfterSpeech = useCallback(
(barged: boolean) => {
if (barged || !awaitingSpokenResponseRef.current) {
awaitingSpokenResponseRef.current = false
consumePendingResponse()
}
if (bargeCapturePendingRef.current) {
// The barge monitor is still capturing the user's interruption — it
// owns the next turn. Keep it alive and don't re-open the mic; the
// utterance callback transcribes and submits when they go quiet.
speechSessionRef.current = null
responseIdRef.current = null
spokenSourceLengthRef.current = 0
setStatus('listening')
return
}
dropSpeechSession()
if (enabledRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
},
[consumePendingResponse]
)
/**
* Submit the utterance the barge monitor captured — the user's interruption
* from its first syllable, no re-listen round trip. Empty/failed captures
* fall back to normal listening.
*/
const submitCapturedUtterance = useCallback(
async (audio: Blob | null) => {
const resumeListening = () => {
if (enabledRef.current && !mutedRef.current) {
pendingStartRef.current = true
}
setStatus('idle')
}
if (!audio || !onTranscribeAudio) {
resumeListening()
return
}
setStatus('transcribing')
try {
const transcript = (await onTranscribeAudio(audio)).trim()
if (!transcript) {
resumeListening()
return
}
awaitingSpokenResponseRef.current = true
dropSpeechSession()
consumePendingResponse()
await onSubmit(transcript)
setStatus('thinking')
} catch (error) {
notifyError(error, voiceCopy.transcriptionFailed)
resumeListening()
}
},
[consumePendingResponse, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
)
/** Barge-in monitor wiring shared by the live and fallback speech paths. */
const openBargeMonitor = useCallback(
(onBarge: () => void) =>
monitorSpeechDuringPlayback({
onSpeech: () => {
bargeCapturePendingRef.current = true
onBarge()
markVoicePlaybackInterrupted()
stopVoicePlayback()
},
onUtterance: audio => {
bargeCapturePendingRef.current = false
stopBargeMonitorRef.current = null
void submitCapturedUtterance(audio)
}
}),
[submitCapturedUtterance]
)
/** Push any new reply text into the live session; finish when complete. */
const feedSpeechSession = useCallback(
(responseId: string) => {
const session = speechSessionRef.current
if (!session || responseIdRef.current !== responseId) {
return
}
const response = pendingResponse()
if (response && response.id === responseId) {
if (response.text.length > spokenSourceLengthRef.current) {
session.append(response.text.slice(spokenSourceLengthRef.current))
spokenSourceLengthRef.current = response.text.length
}
if (!response.pending && !busyRef.current) {
session.finish()
}
} else if (!busyRef.current) {
// Reply consumed/vanished while we were speaking — close out the turn.
session.finish()
}
},
[pendingResponse]
)
/** Whole-text fallback: wait for the reply to complete, then speak it. */
const awaitFallbackSpeech = useCallback(
(responseId: string) => {
const poll = () => {
if (responseIdRef.current !== responseId) {
return
}
const response = pendingResponse()
if (!response || response.id !== responseId) {
settleAfterSpeech(false)
return
}
if (response.pending || busyRef.current) {
window.setTimeout(poll, 250)
return
}
let barged = false
stopBargeMonitorRef.current?.()
stopBargeMonitorRef.current = openBargeMonitor(() => {
barged = true
})
void playSpeechText(response.text, { source: 'voice-conversation' })
.catch(error => notifyError(error, voiceCopy.playbackFailed))
.finally(() => {
if (responseIdRef.current === responseId) {
awaitingSpokenResponseRef.current = false
settleAfterSpeech(barged)
}
})
}
poll()
},
[openBargeMonitor, pendingResponse, settleAfterSpeech, voiceCopy.playbackFailed]
)
/**
* Live-speak the streaming reply: one speech session per response, fed
* incremental text as the assistant generates it. Audio overlaps generation
* — no wait for the full reply, no per-sentence gaps.
*/
const openLiveSpeech = useCallback(
(responseId: string) => {
responseIdRef.current = responseId
spokenSourceLengthRef.current = 0
const speak = useCallback(
async (text: string) => {
setStatus('speaking')
let barged = false
// VAD barge-in: the user talking over the reply cuts playback, drops
// the not-yet-spoken remainder, AND keeps capturing — the interruption
// is transcribed from its first syllable instead of losing the opening
// words to a mic re-open.
stopBargeMonitorRef.current = openBargeMonitor(() => {
barged = true
})
void (async () => {
const session = await startSpeechStream({ source: 'voice-conversation' })
// The session may resolve after the loop moved on (barge, disable).
if (responseIdRef.current !== responseId) {
if (session) {
stopVoicePlayback()
}
return
try {
await playSpeechText(text, { source: 'voice-conversation' })
} catch (error) {
notifyError(error, voiceCopy.playbackFailed)
} finally {
if (enabledRef.current) {
pendingStartRef.current = true
setStatus('idle')
} else {
setStatus('idle')
}
if (!session) {
// No streaming backend/provider: speak the whole reply once it lands.
speechSessionRef.current = null
awaitFallbackSpeech(responseId)
return
}
speechSessionRef.current = session
// Timer-driven feed: reply text flows into the session at delta rate
// regardless of React render cadence.
const feedTimer = window.setInterval(() => feedSpeechSession(responseId), 150)
feedSpeechSession(responseId)
const outcome = await session.done
window.clearInterval(feedTimer)
if (responseIdRef.current !== responseId) {
return
}
if (outcome === 'fallback') {
awaitFallbackSpeech(responseId)
return
}
awaitingSpokenResponseRef.current = false
settleAfterSpeech(barged)
})()
}
},
[awaitFallbackSpeech, feedSpeechSession, openBargeMonitor, settleAfterSpeech]
[voiceCopy.playbackFailed]
)
const start = useCallback(async () => {
@@ -434,7 +254,7 @@ export function useVoiceConversation({
setMuted(false)
awaitingSpokenResponseRef.current = false
dropSpeechSession()
resetSpeechBuffer()
consumePendingResponse()
pendingStartRef.current = true
await startListening()
@@ -454,7 +274,7 @@ export function useVoiceConversation({
handle.cancel()
turnClosingRef.current = false
awaitingSpokenResponseRef.current = false
dropSpeechSession()
resetSpeechBuffer()
consumePendingResponse()
setMuted(false)
setStatus('idle')
@@ -505,9 +325,8 @@ export function useVoiceConversation({
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
}, [enabled, stopTurn])
// Drive the loop: when a voice-submitted reply appears, open a live speech
// session (which feeds itself from then on). Otherwise start listening when
// idle between turns.
// Drive the loop: after a voice-submitted turn, speak stable chunks as the
// assistant stream grows. Otherwise start listening when idle between turns.
useEffect(() => {
if (!enabled || muted) {
return
@@ -517,15 +336,38 @@ export function useVoiceConversation({
const response = pendingResponse()
if (response) {
openLiveSpeech(response.id)
if (response.id !== responseIdRef.current) {
resetSpeechBuffer()
responseIdRef.current = response.id
}
return
if (response.text.length > spokenSourceLengthRef.current) {
appendSpeechText(response.text.slice(spokenSourceLengthRef.current))
spokenSourceLengthRef.current = response.text.length
}
const chunk = takeSpeechChunk(!response.pending && !busy)
if (chunk) {
void speak(chunk)
return
}
if (!response.pending && !busy) {
awaitingSpokenResponseRef.current = false
consumePendingResponse()
resetSpeechBuffer()
pendingStartRef.current = true
setStatus('idle')
return
}
}
if (!busy && status === 'thinking') {
// Turn finished without any speakable reply (tool-only, error).
awaitingSpokenResponseRef.current = false
dropSpeechSession()
resetSpeechBuffer()
pendingStartRef.current = true
setStatus('idle')
@@ -540,7 +382,7 @@ export function useVoiceConversation({
if (pendingStartRef.current) {
void startListening()
}
}, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status])
}, [busy, consumePendingResponse, enabled, muted, pendingResponse, speak, startListening, status])
useEffect(() => {
if (enabled && !wasEnabledRef.current) {
+5 -6
View File
@@ -227,22 +227,20 @@ export function ChatBar({
const { compactPill, stacked } = useComposerMetrics({ composerRef, composerSurfaceRef, editorRef, poppedOut })
const hasComposerPayload = hasText || attachments.length > 0
const canSubmit = busy || hasComposerPayload
const busyAction = busy && hasComposerPayload ? 'queue' : 'stop'
// Steer only makes sense mid-turn, text-only (the gateway can't carry images
// into a tool result) and never for a slash command (those execute inline).
const canSteer = busy && !!onSteer && attachments.length === 0 && isSteerableText
// While busy: text redirects the live turn (Cursor-style stop-and-correct),
// attachments queue for the next turn, an empty composer stops.
const busyAction: 'steer' | 'queue' | 'stop' = canSteer ? 'steer' : hasComposerPayload ? 'queue' : 'stop'
// The submit engine — the orchestration seam where draft + queue meet. Owns
// the submit decision tree, the send-with-restore primitive, and steer.
const { queueDraft, steerDraft, submitDraft } = useComposerSubmit({
const { steerDraft, submitDraft } = useComposerSubmit({
activeQueueSessionKey,
activeQueueSessionKeyRef,
attachments,
busy,
canSteer,
clearDraft,
disabled,
draftRef,
@@ -723,6 +721,7 @@ export function ChatBar({
autoSpeak={autoSpeak}
busy={busy}
busyAction={busyAction}
canSteer={canSteer}
canSubmit={canSubmit}
compactModelPill={poppedOut || compactPill}
conversation={{
@@ -738,7 +737,7 @@ export function ChatBar({
disabled={disabled}
hasComposerPayload={hasComposerPayload}
onDictate={dictate}
onQueue={queueDraft}
onSteer={steerDraft}
onToggleAutoSpeak={handleToggleAutoSpeak}
state={state}
voiceStatus={voiceStatus}
+29 -11
View File
@@ -31,7 +31,7 @@ import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
import { $activeGatewayProfile, $freshSessionRequest, $profileScope, refreshActiveProfile } from '@/store/profile'
import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects'
import { $startWorkSessionRequest, followActiveSessionCwd, resolveNewSessionCwd } from '@/store/projects'
import {
$activeSessionId,
$connection,
@@ -48,6 +48,8 @@ import {
sessionPinId,
setAwaitingResponse,
setBusy,
setCurrentBranch,
setCurrentCwd,
setCurrentModel,
setCurrentModelSource,
setCurrentProvider,
@@ -87,7 +89,6 @@ import { useRouteResume } from '../session/hooks/use-route-resume'
import { useSessionActions } from '../session/hooks/use-session-actions'
import { useSessionListActions } from '../session/hooks/use-session-list-actions'
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
import { startWorkspaceSession } from '../session/workspace-session-target'
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
import { titlebarControlsPosition } from '../shell/titlebar'
@@ -449,16 +450,33 @@ export function ContribWiring({ children }: { children: ReactNode }) {
// also drills the sidebar into that project so the new lane is visible.
const startSessionInWorkspace = useCallback(
(path: null | string) => {
startWorkspaceSession({
activeSessionIdRef,
followActiveSessionCwd,
onExplicitWorkspace: restoreWorktree,
path,
requestGateway,
startFreshSessionDraft
})
startFreshSessionDraft()
// A worktree lane carries its own path; the trunk "+" can be path-less
// (the main checkout is implicit), so fall back to the active project's
// root instead of no-op'ing on null.
const target = path?.trim() || resolveNewSessionCwd()
if (!target) {
return
}
setCurrentCwd(target)
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
.then(info => {
const resolved = info.cwd || target
setCurrentCwd(resolved)
setCurrentBranch(info.branch || '')
if (path?.trim()) {
restoreWorktree(resolved)
void followActiveSessionCwd(resolved)
}
})
.catch(() => undefined)
},
[activeSessionIdRef, requestGateway, startFreshSessionDraft]
[requestGateway, startFreshSessionDraft]
)
// Composer "branch off into a new worktree": open a fresh session anchored
@@ -1,125 +0,0 @@
import { describe, expect, it } from 'vitest'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
import { routeTargetFromToken, sessionContextDrift } from './session-context-drift'
const SESS_A = 'sess-a'
const SESS_B = 'sess-b'
// Build a route token the way desktop-controller does: pathname:search:hash.
const routeToken = (pathname: string, search = '', hash = '') => `${pathname}:${search}:${hash}`
describe('routeTargetFromToken', () => {
it('maps a session route to its session id, a non-chat route to null, and the new-chat route to __new__', () => {
expect(routeTargetFromToken(routeToken(sessionRoute(SESS_A)))).toBe(SESS_A)
expect(routeTargetFromToken(routeToken(SETTINGS_ROUTE))).toBeNull()
expect(routeTargetFromToken(routeToken(NEW_CHAT_ROUTE))).toBe('__new__')
})
it('ignores search and hash — only the pathname selects the chat', () => {
expect(routeTargetFromToken(routeToken(sessionRoute(SESS_A), '?panel=preview', '#reply'))).toBe(SESS_A)
})
it('treats a colon-free token as a bare pathname', () => {
expect(routeTargetFromToken(sessionRoute(SESS_A))).toBe(SESS_A)
})
})
describe('sessionContextDrift', () => {
it('does not drift on search/hash-only route churn', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(sessionRoute(SESS_A), '?panel=preview', '#reply'),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_A,
submitTargetStoredId: SESS_A
})
expect(reason).toBeNull()
})
it('does not drift on a selection null-reset (gateway/profile switch, reconnect)', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(sessionRoute(SESS_A)),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: null,
submitTargetStoredId: SESS_A
})
expect(reason).toBeNull()
})
it('drifts when selection moves to a different non-null stored session', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(sessionRoute(SESS_A)),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_B,
submitTargetStoredId: SESS_A
})
expect(reason).toBe('selection:sess-a->sess-b')
})
it('drifts when the routed session id changes to another session', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(sessionRoute(SESS_B)),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_A,
submitTargetStoredId: SESS_A
})
expect(reason).toBe('route:sess-a->sess-b')
})
it('drifts when the route moves to the new-chat route mid-submit', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(NEW_CHAT_ROUTE),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_A,
submitTargetStoredId: SESS_A
})
expect(reason).toBe('route:sess-a->__new__')
})
it('does not drift when the route moves to a non-chat route (null target)', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(sessionRoute(SESS_A)),
nowRouteToken: routeToken(SETTINGS_ROUTE),
startSelectedStoredId: SESS_A,
nowSelectedStoredId: SESS_A,
submitTargetStoredId: SESS_A
})
expect(reason).toBeNull()
})
it('does not drift when route and selection re-home onto the submit target (the create pipeline re-home)', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(NEW_CHAT_ROUTE),
nowRouteToken: routeToken(sessionRoute(SESS_A)),
startSelectedStoredId: null,
nowSelectedStoredId: SESS_A,
submitTargetStoredId: SESS_A
})
expect(reason).toBeNull()
})
it('drifts when a new-chat draft with no target yet is switched to an existing chat', () => {
const reason = sessionContextDrift({
startRouteToken: routeToken(NEW_CHAT_ROUTE),
nowRouteToken: routeToken(sessionRoute(SESS_B)),
startSelectedStoredId: null,
nowSelectedStoredId: SESS_B,
submitTargetStoredId: null
})
expect(reason).toBe('route:__new__->sess-b')
})
})
@@ -1,84 +0,0 @@
import { isNewChatRoute, routeSessionId } from '../../routes'
/**
* The chat a route token points at: the stored/routed session id, `'__new__'`
* for the new-chat route, or null for a route that isn't a chat (settings and
* the other overlay routes). Used to compare two route tokens by their *chat*
* rather than their raw string.
*/
export type RouteTarget = string | null
/**
* Reduce a route token to the chat it targets. The token is
* `${pathname}:${search}:${hash}` (desktop-controller's routeToken), and only
* the pathname selects the chat — search/hash carry overlay/panel state, so a
* change there must not read as a session switch. We take the substring before
* the first ':' as the pathname; that is safe because `location.pathname` never
* contains a raw ':' (sessionRoute encodeURIComponent's the id, so a ':' in an
* id arrives as %3A, and the app's other routes are literal colon-free paths).
*/
export function routeTargetFromToken(token: string): RouteTarget {
const separator = token.indexOf(':')
const pathname = separator === -1 ? token : token.slice(0, separator)
return routeSessionId(pathname) ?? (isNewChatRoute(pathname) ? '__new__' : null)
}
interface SessionContextDriftArgs {
startRouteToken: string
nowRouteToken: string
startSelectedStoredId: string | null
nowSelectedStoredId: string | null
/**
* The stored session this submit is bound to, when known. Drift ignores a
* move *to* this id: the submit pipeline itself re-homes selection and route
* onto its target (a fresh create, a resume), and that self-inflicted move is
* not a user switch. Omit it (pre-create new-chat draft) to treat any move to
* a real chat as drift.
*/
submitTargetStoredId?: string | null
}
/**
* Decide whether the session context genuinely changed under an in-flight
* submit — the user (or a real navigation) moved to a DIFFERENT chat — as
* opposed to the programmatic churn a busy gateway produces constantly:
* - selection null-resets on a gateway/profile switch or reconnect
* (gateway-switch's `setSelectedStoredSessionId(null)`),
* - search/hash-only route changes from overlays and side panels,
* - background gateway events retargeting the active runtime id (#47709 class,
* which is why the active ref is not a prong here at all).
* Returns null when nothing genuinely drifted, or a short reason string
* (`route:<from>-><to>` / `selection:<from>-><to>`) for the abort log.
*/
export function sessionContextDrift({
startRouteToken,
nowRouteToken,
startSelectedStoredId,
nowSelectedStoredId,
submitTargetStoredId
}: SessionContextDriftArgs): string | null {
const targetStart = routeTargetFromToken(startRouteToken)
const targetNow = routeTargetFromToken(nowRouteToken)
// Route prong: the routed chat moved to a different, real chat. A null target
// (navigated to settings / a non-chat overlay route) or a search/hash-only
// change (same target) is not drift, and neither is landing on the submit's
// own target.
if (targetNow !== targetStart && targetNow !== null && targetNow !== submitTargetStoredId) {
return `route:${targetStart}->${targetNow}`
}
// Selection prong: selection moved to a different, real stored session. A
// null-reset (nowSelectedStoredId === null) or a move onto the submit's own
// target is not drift.
if (
nowSelectedStoredId !== null &&
nowSelectedStoredId !== startSelectedStoredId &&
nowSelectedStoredId !== submitTargetStoredId
) {
return `selection:${startSelectedStoredId}->${nowSelectedStoredId}`
}
return null
}
@@ -79,14 +79,4 @@ describe('useMessageStream compaction lifecycle', () => {
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
})
it('clears the compaction phase on the structured completion edge', async () => {
await mountStream()
setSessionCompacting(OTHER_SID, true)
emit('status.update', { kind: 'compacting' })
emit('status.update', { kind: 'compacted' })
expect($compactingSessions.get()).toEqual({ [OTHER_SID]: true })
})
})
@@ -22,7 +22,6 @@ import { $gateway } from '@/store/gateway'
import { dispatchNativeNotification } from '@/store/native-notifications'
import { notify } from '@/store/notifications'
import { requestDesktopOnboarding } from '@/store/onboarding'
import { revealDesktopPane } from '@/store/pane-focus'
import { flashPetActivity, markPetUnread, setPetActivity } from '@/store/pet'
import { $activeGatewayProfile, normalizeProfileKey } from '@/store/profile'
import { followActiveSessionCwd } from '@/store/projects'
@@ -751,21 +750,10 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
// Agent closed its own read-only tab via the desktop-gated close_terminal tool.
// The process is untouched — this only drops the view.
closeAgentTerminalByProc(payload?.process_id ?? '')
} else if (event.type === 'pane.reveal') {
// Agent revealed a pane via the desktop-gated focus_pane tool, in
// response to an explicit user request. Active session only — a
// background turn must never move the user's focus (desktop AGENTS.md:
// offer, don't hijack).
if (isActiveEvent) {
revealDesktopPane(payload?.pane ?? '')
}
} else if (event.type === 'status.update') {
if (sessionId && payload?.kind === 'compacting') {
setSessionCompacting(sessionId, true)
compactedTurnRef.current.add(sessionId)
} else if (sessionId && payload?.kind === 'compacted') {
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
} else if (sessionId && payload?.kind === 'process') {
// The gateway's notification poller announces background process
// completions / watch matches here — re-sync the status stack.
@@ -55,13 +55,6 @@ interface QueuedStreamDeltas {
reasoning: string
}
// Date.now() alone can collide when an interim seal and the next segment's
// first delta land in the same millisecond — the new segment would then find
// the sealed bubble by id and append into it instead of starting fresh.
let streamMessageSeq = 0
const nextStreamMessageId = (prefix: string) => `${prefix}-${Date.now()}-${++streamMessageSeq}`
export function useMessageStream({
activeGatewayProfile = 'default',
activeSessionIdRef,
@@ -98,7 +91,7 @@ export function useMessageStream({
return state
}
const streamId = state.streamId ?? nextStreamMessageId('assistant-stream')
const streamId = state.streamId ?? `assistant-stream-${Date.now()}`
const groupId = state.pendingBranchGroup ?? undefined
const prev = state.messages
let nextMessages: ChatMessage[]
@@ -404,21 +397,19 @@ export function useMessageStream({
let nextMessages = state.messages
if (streamId && nextMessages.some(m => m.id === streamId)) {
// Seal the streaming bubble in place, marked interim so it renders
// without an action footer (see ChatMessage.interim).
// Finalize the existing streaming bubble in place
nextMessages = nextMessages.map(m =>
m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false, interim: true } : m
m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false } : m
)
} else {
// No streaming bubble — create a standalone interim message
nextMessages = [
...nextMessages,
{
id: nextStreamMessageId('assistant-interim'),
id: `assistant-interim-${Date.now()}`,
role: 'assistant' as const,
parts: [assistantTextPart(authoritativeText)],
pending: false,
interim: true,
branchGroupId: state.pendingBranchGroup ?? undefined
}
]
@@ -468,15 +459,19 @@ export function useMessageStream({
return mergeFinalAssistantText(parts, visibleFinalText)
}
// Settling the final response onto a bubble makes it the turn's real
// reply — clear `interim` so it regains the action footer.
const completeMessage = (message: ChatMessage): ChatMessage => {
const settled = { ...message, pending: false, interim: false }
return completionError
? { ...settled, error: completionError, parts: message.parts.filter(part => part.type !== 'text') }
: { ...settled, parts: replaceTextPart(message.parts) }
}
const completeMessage = (message: ChatMessage): ChatMessage =>
completionError
? {
...message,
error: completionError,
parts: message.parts.filter(part => part.type !== 'text'),
pending: false
}
: {
...message,
parts: replaceTextPart(message.parts),
pending: false
}
const newAssistantFromCompletion = (): ChatMessage => ({
id: `assistant-${Date.now()}`,
@@ -111,36 +111,6 @@ describe('useMessageStream interim text sealing', () => {
expect(texts).toContain('All checks passed.')
})
it('marks sealed interim bubbles interim and leaves the final reply unmarked', async () => {
await mountStream()
await start()
await delta('Let me check the files.')
await interim('Let me check the files.')
await delta('Now the second pass.')
await interim('Now the second pass.')
await complete('All done.')
const assistants = getState().messages.filter(m => m.role === 'assistant' && !m.hidden)
const byText = (text: string) => assistants.find(m => chatMessageText(m) === text)
expect(byText('Let me check the files.')?.interim).toBe(true)
expect(byText('Now the second pass.')?.interim).toBe(true)
expect(byText('All done.')?.interim).toBeFalsy()
})
it('clears the interim mark when a previewed final settles onto the interim bubble', async () => {
await mountStream()
await start()
await interim('same reply')
await completePreviewed('same reply')
const assistants = getState().messages.filter(m => m.role === 'assistant' && !m.hidden)
expect(assistants).toHaveLength(1)
expect(assistants[0].interim).toBeFalsy()
})
it('dedupes interim text when the final response includes it', async () => {
await mountStream()
await start()
@@ -120,46 +120,6 @@ describe('usePreviewRouting', () => {
expect(window.hermesDesktop.normalizePreviewTarget).not.toHaveBeenCalled()
})
it('opens the preview pane on a preview.open event for the active session', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({
payload: { url: 'https://www.cnn.com', label: 'CNN' },
session_id: 'session-1',
type: 'preview.open'
})
)
await waitFor(() => {
expect($previewTarget.get()).toMatchObject({ kind: 'url', label: 'CNN', url: 'https://www.cnn.com' })
})
})
it('ignores a preview.open event for a background session', async () => {
render(
<PreviewRoutingHarness
onEvent={handler => {
handleEvent = handler
}}
/>
)
act(() =>
handleEvent({ payload: { url: 'https://www.cnn.com' }, session_id: 'other-session', type: 'preview.open' })
)
// Give any (wrongly) scheduled async open a tick to resolve before asserting.
await Promise.resolve()
expect($previewTarget.get()).toBeNull()
})
it('does not auto-open a preview from tool results', async () => {
render(
<PreviewRoutingHarness
@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react'
import { type MutableRefObject, useCallback, useEffect } from 'react'
import { gatewayEventCompletedFileDiff } from '@/lib/gateway-events'
import { normalizeOrLocalPreviewTarget } from '@/lib/local-preview'
import {
$previewTarget,
$sessionPreviewRegistry,
@@ -11,7 +10,6 @@ import {
getSessionPreviewRecord,
progressPreviewServerRestart,
requestPreviewReload,
setCurrentSessionPreviewTarget,
setPreviewTarget
} from '@/store/preview'
import { $currentCwd } from '@/store/session'
@@ -103,30 +101,6 @@ export function usePreviewRouting({
event => {
baseHandleGatewayEvent(event)
if (event.type === 'preview.open') {
// Agent-driven open in response to an explicit user request ("show
// cnn.com in the preview pane"). Honor it only for the active session —
// a background turn must not yank the pane open (see desktop AGENTS.md:
// offer, don't hijack). Routes through the same normalizer as the file
// browser so URLs, localhost, and file paths all resolve correctly.
const { url, label } = asRecord(event.payload)
const target = typeof url === 'string' ? url.trim() : ''
if (target && (!event.session_id || event.session_id === activeSessionIdRef.current)) {
void normalizeOrLocalPreviewTarget(target, $currentCwd.get() || currentCwd || undefined).then(resolved => {
if (resolved) {
const trimmedLabel = typeof label === 'string' ? label.trim() : ''
setCurrentSessionPreviewTarget(
trimmedLabel ? { ...resolved, label: trimmedLabel } : resolved,
'tool-result'
)
}
})
}
return
}
if (event.type === 'preview.restart.complete') {
const { task_id, text } = asRecord(event.payload)
@@ -152,7 +126,7 @@ export function usePreviewRouting({
requestPreviewReload()
}
},
[activeSessionIdRef, baseHandleGatewayEvent, currentCwd]
[activeSessionIdRef, baseHandleGatewayEvent]
)
return { handleDesktopGatewayEvent, restartPreviewServer }
@@ -5,18 +5,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { textPart } from '@/lib/chat-messages'
import { $composerAttachments, $composerDraft, type ComposerAttachment, setComposerDraft } from '@/store/composer'
import { $notifications, clearNotifications } from '@/store/notifications'
import {
$busy,
$connection,
$currentUsage,
$messages,
$sessions,
$turnStartedAt,
setCurrentUsage,
setMessages,
setSessions
} from '@/store/session'
import { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'
import type { SessionInfo } from '@/types/hermes'
import type { SubmitTextOptions } from './utils'
@@ -72,10 +61,7 @@ interface HarnessHandle {
activeSessionIdRef: MutableRefObject<string | null>
cancelRun: () => Promise<void>
restoreToMessage: (messageId: string, target?: { text?: string; userOrdinal?: number | null }) => Promise<void>
redirectPrompt: (text: string) => Promise<boolean>
/** @deprecated Use `redirectPrompt`. */
steerPrompt: (text: string) => Promise<boolean>
submitTextRaw: (text: string, options?: SubmitTextOptions) => Promise<boolean>
submitText: (text: string, options?: SubmitTextOptions) => Promise<boolean>
}
@@ -112,7 +98,7 @@ function Harness({
onSeedState?: (state: Record<string, unknown>) => void
openMemoryGraph?: () => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
seedMessages?: unknown[]
selectedStoredSessionIdRef?: MutableRefObject<string | null>
@@ -174,18 +160,14 @@ function Harness({
act(async () => actions.cancelRun(...args)) as Promise<void>,
restoreToMessage: (...args: Parameters<typeof actions.restoreToMessage>) =>
act(async () => actions.restoreToMessage(...args)) as Promise<void>,
redirectPrompt: (...args: Parameters<typeof actions.redirectPrompt>) =>
act(async () => actions.redirectPrompt(...args)) as Promise<boolean>,
steerPrompt: (...args: Parameters<typeof actions.steerPrompt>) =>
act(async () => actions.steerPrompt(...args)) as Promise<boolean>,
submitTextRaw: actions.submitText,
submitText: (...args: Parameters<typeof actions.submitText>) =>
act(async () => actions.submitText(...args)) as Promise<boolean>
})
}, [
actions.cancelRun,
actions.restoreToMessage,
actions.redirectPrompt,
actions.steerPrompt,
actions.submitText,
activeSessionIdRef,
@@ -296,513 +278,6 @@ describe('usePromptActions /title', () => {
})
})
// Helper: extract rendered text parts from captured updateSessionState seeds.
function renderedSeedTexts(seeds: Record<string, unknown>[]): string[] {
return seeds.flatMap(state => {
const messages = Array.isArray(state.messages)
? (state.messages as Array<{ parts?: Array<{ text?: string }> }>)
: []
return messages.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
})
}
describe('usePromptActions /compress', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
})
afterEach(() => {
cleanup()
clearNotifications()
setCurrentUsage({ calls: 0, input: 0, output: 0, total: 0 })
setMessages([])
vi.restoreAllMocks()
})
it('routes through session.compress (not slash.exec) with a 120s timeout and renders the summary', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => {
if (method === 'session.compress') {
return {
removed: 8,
summary: {
headline: 'Compressed: 234 → 226 messages',
noop: false,
token_line: 'Approx request size: ~285,727 → ~198,104 tokens'
}
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
// The dedicated RPC is the TUI's path and has no slash-worker pipe
// timeout — and the call carries an LLM-sized client timeout instead of
// the 30s WS default, so a large session's compression can finish.
expect(requestGateway).toHaveBeenCalledWith(
'session.compress',
expect.objectContaining({ session_id: RUNTIME_SESSION_ID }),
120_000
)
expect(requestGateway).not.toHaveBeenCalledWith('slash.exec', expect.anything())
expect(requestGateway).not.toHaveBeenCalledWith('command.dispatch', expect.anything())
})
it('replaces the transcript from the response messages', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => {
if (method === 'session.compress') {
return {
removed: 2,
summary: { headline: 'Compressed: 4 → 2 messages' },
messages: [
{ role: 'user', content: 'summarized context' },
{ role: 'assistant', content: 'sure, here is the summary' }
]
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
// The transcript was replaced with the post-compress history.
const finalMessages = seeds[seeds.length - 1]?.messages as Array<{ parts?: Array<{ text?: string }> }>
const renderedText = (finalMessages ?? [])
.flatMap(message => (message.parts ?? []).map(part => part.text ?? ''))
.join('\n')
expect(renderedText).toContain('summarized context')
expect(renderedText).toContain('sure, here is the summary')
})
it('uses the compute-host response transcript and success output', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return {
host_ack: { output: 'Compressed 4 → 2 messages' },
messages: [
{ role: 'user', content: 'compute-host summary' },
{ role: 'assistant', content: 'compute-host answer' }
]
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
const computeHostTexts = renderedSeedTexts(seeds)
expect(computeHostTexts).toEqual(expect.arrayContaining(['compute-host summary', 'compute-host answer']))
expect(computeHostTexts.some(text => text.includes('Compressed 4 → 2 messages'))).toBe(true)
expect($notifications.get()).toEqual(
expect.arrayContaining([expect.objectContaining({ message: 'Compressed 4 → 2 messages' })])
)
})
it('renders an aborted compression as an error, not a success', async () => {
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return {
status: 'aborted',
summary: {
aborted: true,
headline: 'Compression aborted: 6 messages preserved',
note: 'No compression provider is configured.'
}
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await handle!.submitText('/compress')
expect($notifications.get()).toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'error', message: expect.stringContaining('Compression aborted') })
])
)
expect($notifications.get()).not.toEqual(
expect.arrayContaining([
expect.objectContaining({ kind: 'success', message: expect.stringContaining('Compression aborted') })
])
)
})
it('passes a focus topic through as focus_topic', async () => {
const requestGateway = vi.fn(
async (_method: string, _params?: Record<string, unknown>, _timeoutMs?: number) => ({ removed: 0 }) as never
)
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
await handle!.submitText('/compress the auth refactor')
expect(requestGateway).toHaveBeenCalledWith(
'session.compress',
expect.objectContaining({ focus_topic: 'the auth refactor' }),
120_000
)
})
it('surfaces the RPC error verbatim (e.g. the busy guard) instead of a routing error', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
throw new Error('session busy — /interrupt the current turn before /compress')
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
const texts = renderedSeedTexts(seeds)
expect(texts.some(text => text.includes('session busy'))).toBe(true)
expect(texts.some(text => text.includes('not a quick/plugin/skill command'))).toBe(false)
})
it('falls back to the slash worker when an older gateway lacks session.compress', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
throw new Error('method not found: session.compress')
}
if (method === 'slash.exec') {
return { output: 'compressed by legacy gateway' } as never
}
throw new Error(`unexpected method: ${method}`)
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/compress')
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'compress' }))
expect(renderedSeedTexts(seeds).some(text => text.includes('compressed by legacy gateway'))).toBe(true)
})
it('does not clobber the foreground transcript when compression resolves after a session switch', async () => {
const RUNTIME_SESSION_B = 'rt-session-b'
const storedSessionIdRef: MutableRefObject<string | null> = { current: 'stored-a' }
const updates: Array<{ sessionId: string; storedSessionId: null | string | undefined }> = []
let resolveCompress: (value: unknown) => void = () => undefined
const compressResult = new Promise(resolve => {
resolveCompress = resolve
})
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}
throw new Error(`unexpected method: ${method}`)
})
setMessages([{ id: 'foreground-b', parts: [textPart('session B transcript')], role: 'user', timestamp: 0 }])
setCurrentUsage({ calls: 7, input: 70, output: 30, total: 100 })
let handle: HarnessHandle | null = null
await actRender(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => (handle = h)}
onUpdateState={(sessionId, storedSessionId) => updates.push({ sessionId, storedSessionId })}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
selectedStoredSessionIdRef={storedSessionIdRef}
/>
)
// Start the request in a synchronous act. The async `submitText` harness
// helper cannot be used here: this test intentionally keeps its promise
// pending while the user switches sessions, which would leave React's
// async act scope open and overlap the wait below.
let submitted: Promise<boolean>
act(() => {
submitted = handle!.submitTextRaw('/compress')
})
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.anything(), 120_000))
// Switch to session B before compression resolves.
activeSessionIdRef.current = RUNTIME_SESSION_B
resolveCompress({
info: { usage: { context_used: 4_000, total: 12_000 } },
messages: [{ content: 'compressed session A transcript', role: 'system' }],
removed: 5
})
await act(async () => {
await submitted
})
// The foreground transcript + usage are unchanged — the late result only
// updated session A's cache, not the active session B's.
expect($messages.get()).toEqual([
{ id: 'foreground-b', parts: [textPart('session B transcript')], role: 'user', timestamp: 0 }
])
expect($currentUsage.get()).toEqual(expect.objectContaining({ calls: 7, input: 70, output: 30, total: 100 }))
expect(updates).toContainEqual({ sessionId: RUNTIME_SESSION_ID, storedSessionId: 'stored-a' })
})
it('keeps a late compression error bound to its invocation-time stored session', async () => {
const RUNTIME_SESSION_B = 'rt-session-b'
const storedSessionIdRef: MutableRefObject<string | null> = { current: 'stored-a' }
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const updates: Array<{ sessionId: string; storedSessionId: null | string | undefined }> = []
let rejectCompress: (reason?: unknown) => void = () => undefined
const compressResult = new Promise((_, reject) => {
rejectCompress = reject
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}
throw new Error(`unexpected method: ${method}`)
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
activeSessionIdRef={activeSessionIdRef}
onReady={h => (handle = h)}
onUpdateState={(sessionId, storedSessionId) => updates.push({ sessionId, storedSessionId })}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
selectedStoredSessionIdRef={storedSessionIdRef}
/>
)
// Keep the RPC pending while the selected stored session changes without
// leaving an async React act scope open (see the foreground-race test).
let submitted: Promise<boolean>
act(() => {
submitted = handle!.submitTextRaw('/compress')
})
await waitFor(() => expect(requestGateway).toHaveBeenCalledWith('session.compress', expect.anything(), 120_000))
activeSessionIdRef.current = RUNTIME_SESSION_B
storedSessionIdRef.current = 'stored-b'
rejectCompress(new Error('compression failed'))
await act(async () => {
await submitted
})
expect(updates).toContainEqual({ sessionId: RUNTIME_SESSION_ID, storedSessionId: 'stored-a' })
})
it('shows a compression progress toast outside the transcript', async () => {
let resolveCompress: (value: unknown) => void = () => undefined
const compressResult = new Promise(resolve => {
resolveCompress = resolve
})
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.compress') {
return (await compressResult) as never
}
throw new Error(`unexpected method: ${method}`)
})
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const submitted = handle!.submitTextRaw('/compress')
await waitFor(() => expect($notifications.get().some(item => item.message === 'compressing context...')).toBe(true))
resolveCompress({ messages: [{ content: 'compressed transcript', role: 'system' }] })
await submitted
})
})
describe('usePromptActions exec fallback error reporting', () => {
beforeEach(() => {
setSessions(() => [sessionInfo()])
})
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('surfaces the slash.exec failure when command.dispatch only adds "not a quick/plugin/skill command"', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'slash.exec') {
throw new Error('slash worker timed out')
}
if (method === 'command.dispatch') {
throw new Error('not a quick/plugin/skill command: debug')
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
// /debug still goes through exec (no dedicated RPC), so it exercises the
// slash.exec → command.dispatch fallback + error unmasking path.
await handle!.submitText('/debug')
// The dispatch fallback knowing nothing about /debug is routing noise;
// the worker timeout is what actually went wrong (#44456).
const texts = renderedSeedTexts(seeds)
expect(texts.some(text => text.includes('slash worker timed out'))).toBe(true)
expect(texts.some(text => text.includes('not a quick/plugin/skill command'))).toBe(false)
})
it('falls back to slash.exec when an older gateway lacks a dedicated RPC', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'session.status') {
throw new Error('method not found: session.status')
}
if (method === 'slash.exec') {
return { output: 'session status from slash worker' } as never
}
throw new Error(`unexpected method: ${method}`)
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/status')
expect(requestGateway).toHaveBeenCalledWith('session.status', expect.anything(), undefined)
expect(requestGateway).toHaveBeenCalledWith('slash.exec', expect.objectContaining({ command: 'status' }))
expect(renderedSeedTexts(seeds).some(text => text.includes('session status from slash worker'))).toBe(true)
})
it('still reports a real command.dispatch failure for skill/quick commands', async () => {
const seeds: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
if (method === 'slash.exec') {
throw new Error('skill command: use command.dispatch for /my-skill')
}
if (method === 'command.dispatch') {
throw new Error('quick command failed with exit code 1')
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={s => seeds.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
await handle!.submitText('/my-skill')
const texts = renderedSeedTexts(seeds)
expect(texts.some(text => text.includes('quick command failed with exit code 1'))).toBe(true)
})
})
describe('usePromptActions slash.exec dispatch payloads', () => {
afterEach(() => {
cleanup()
@@ -1056,44 +531,6 @@ describe('usePromptActions submit / queue drain semantics', () => {
)
})
it('flags prompt.submit with interrupted:true after a voice-playback barge', async () => {
const { markVoicePlaybackInterrupted } = await import('@/lib/voice-playback')
const requestGateway = vi.fn(async () => ({}) as never)
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
markVoicePlaybackInterrupted()
await handle!.submitText('stop! rude interruption')
// The latch is one-shot: the flag rides this submit, the next is clean.
expect(requestGateway).toHaveBeenCalledWith(
'prompt.submit',
{
session_id: RUNTIME_SESSION_ID,
text: 'stop! rude interruption',
interrupted: true
},
1_800_000
)
await handle!.submitText('follow-up without a barge')
expect(requestGateway).toHaveBeenLastCalledWith(
'prompt.submit',
{
session_id: RUNTIME_SESSION_ID,
text: 'follow-up without a barge'
},
1_800_000
)
})
it('a fromQueue drain sends even when busyRef is still true on the settle edge', async () => {
// busyRef lags $busy by one effect tick on the busy→false settle edge, so a
// drained queue send would otherwise hit the busy guard and silently no-op.
@@ -1266,41 +703,32 @@ describe('usePromptActions submit / queue drain semantics', () => {
})
})
describe('usePromptActions redirectPrompt', () => {
describe('usePromptActions steerPrompt', () => {
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('redirects the live turn with trimmed correction text', async () => {
const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never)
it('injects the trimmed text via session.steer and reports acceptance on a queued status', async () => {
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
const capturedStates: Record<string, unknown>[] = []
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => capturedStates.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
const accepted = await handle!.redirectPrompt(' nudge the run ')
const accepted = await handle!.steerPrompt(' nudge the run ')
expect(accepted).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('session.redirect', {
// Steer never starts a turn — it rides the live run via session.steer only.
expect(requestGateway).toHaveBeenCalledWith('session.steer', {
session_id: RUNTIME_SESSION_ID,
text: 'nudge the run'
})
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
expect((capturedStates.at(-1)?.messages as unknown[]).at(-1)).toMatchObject({
role: 'user',
parts: [{ type: 'text', text: 'nudge the run' }]
})
})
it('reports rejection so the caller queues when the turn already ended', async () => {
it('reports rejection (so the caller queues) when the gateway has no live tool window', async () => {
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
let handle: HarnessHandle | null = null
@@ -1308,12 +736,12 @@ describe('usePromptActions redirectPrompt', () => {
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.redirectPrompt('too late')).toBe(false)
expect(await handle!.steerPrompt('too late')).toBe(false)
})
it('reports rejection without throwing when the redirect RPC errors', async () => {
it('reports rejection (never throws) when the steer RPC errors', async () => {
const requestGateway = vi.fn(async () => {
throw new Error('agent does not support redirect')
throw new Error('agent does not support steer')
})
let handle: HarnessHandle | null = null
@@ -1321,93 +749,20 @@ describe('usePromptActions redirectPrompt', () => {
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.redirectPrompt('boom')).toBe(false)
expect(await handle!.steerPrompt('boom')).toBe(false)
})
it('skips the RPC entirely for empty text', async () => {
const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never)
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
await actRender(
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
)
expect(await handle!.redirectPrompt(' ')).toBe(false)
expect(await handle!.steerPrompt(' ')).toBe(false)
expect(requestGateway).not.toHaveBeenCalled()
})
it('accepts a queued redirect during the agent-build window and records the correction', async () => {
// running=True but the agent is still building: the gateway queues the
// correction instead of rejecting, so the composer must NOT re-queue it.
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
let handle: HarnessHandle | null = null
const capturedStates: Record<string, unknown>[] = []
await actRender(
<Harness
onReady={h => (handle = h)}
onSeedState={state => capturedStates.push(state)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
/>
)
expect(await handle!.redirectPrompt('build-window nudge')).toBe(true)
expect(requestGateway).toHaveBeenCalledWith('session.redirect', {
session_id: RUNTIME_SESSION_ID,
text: 'build-window nudge'
})
expect(requestGateway).not.toHaveBeenCalledWith('prompt.submit', expect.anything())
expect((capturedStates.at(-1)?.messages as unknown[]).at(-1)).toMatchObject({
role: 'user',
parts: [{ type: 'text', text: 'build-window nudge' }]
})
})
it('resumes the stored session and retries once when session.redirect reports "session not found"', async () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
const calls: { method: string; params?: Record<string, unknown> }[] = []
let redirectAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'session.redirect') {
redirectAttempts += 1
if (redirectAttempts === 1) {
throw new Error('session not found')
}
return { status: 'redirected' } as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
await actRender(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
await waitFor(() => expect(handle).not.toBeNull())
expect(await handle!.redirectPrompt('reconnect nudge')).toBe(true)
expect(calls.map(c => c.method)).toEqual(['session.redirect', 'session.resume', 'session.redirect'])
expect(calls[0]?.params).toEqual({ session_id: RUNTIME_SESSION_ID, text: 'reconnect nudge' })
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID, source: 'desktop' })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'reconnect nudge' })
expect(handle!.activeSessionIdRef.current).toBe(RECOVERED_SESSION_ID)
})
})
describe('usePromptActions restoreToMessage', () => {
@@ -41,7 +41,7 @@ import type {
HandoffRequestResponse,
HandoffStateResponse,
ImageAttachResponse,
SessionRedirectResponse
SessionSteerResponse
} from '../../../types'
import {
@@ -220,7 +220,7 @@ export function usePromptActions({
const copy = t.desktop
const appendSessionTextMessage = useCallback(
(sessionId: string, role: ChatMessage['role'], text: string, storedSessionId?: string | null) => {
(sessionId: string, role: ChatMessage['role'], text: string) => {
// Strip ANSI: slash-command output from the backend worker carries SGR
// color codes (e.g. "Unknown command" in red). The ESC byte is invisible
// in the chat panel, so without this the `[1;31m…[0m` payload leaks as
@@ -244,7 +244,7 @@ export function usePromptActions({
}
]
}),
storedSessionId ?? selectedStoredSessionIdRef.current
selectedStoredSessionIdRef.current
)
},
[selectedStoredSessionIdRef, updateSessionState]
@@ -478,10 +478,8 @@ export function usePromptActions({
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
submitPromptText,
updateSessionState
submitPromptText
})
const submitText = useCallback(
@@ -601,11 +599,11 @@ export function usePromptActions({
}
}, [activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState])
// The desktop steering action is an immediate correction: the core cancels
// model generation and rebuilds the live turn with displayed reasoning and
// completed work intact. During a tool it waits for the safe result boundary.
// Returns false when the turn raced to completion so the composer can queue.
const redirectPrompt = useCallback(
// Steer = nudge the live turn without interrupting: the gateway appends the
// text to the next tool result so the model reads it on its next iteration
// (desktop parity with `/steer`). Returns false on reject (no live tool
// window) so the caller can fall back to queueing the words for the next turn.
const steerPrompt = useCallback(
async (rawText: string): Promise<boolean> => {
const text = sanitizeComposerInput(rawText).trim()
const sessionId = activeSessionId || activeSessionIdRef.current
@@ -614,54 +612,25 @@ export function usePromptActions({
return false
}
// Accepted whether the live turn was redirected in place or queued for
// the next turn (the build window, before the agent is wired) — either
// way the correction reaches the model, so record it once as a real user
// message after the interrupted checkpoint, matching the durable core
// transcript rather than a system note that changes role after reload.
const send = async (id: string): Promise<boolean> => {
const result = await requestGateway<SessionRedirectResponse>('session.redirect', { session_id: id, text })
try {
const result = await requestGateway<SessionSteerResponse>('session.steer', { session_id: sessionId, text })
if (result?.status === 'redirected' || result?.status === 'queued') {
if (result?.status === 'queued') {
triggerHaptic('submit')
appendSessionTextMessage(id, 'user', text)
// Inline note (not a toast) so the nudge lives in the transcript next
// to the turn it steered. The `steer:` prefix is rendered as a codicon
// row by SystemMessage (see STEER_NOTE_RE), same style as slash output.
appendSessionTextMessage(sessionId, 'system', `steer:${text}`)
return true
}
return false
}
try {
return await send(sessionId)
} catch (err) {
// A stale runtime id after reconnect 404s ("session not found"): resume
// the stored session and retry once, mirroring stopPrompt so a
// correction right after a reconnect isn't lost to the race.
if (isSessionNotFoundError(err) && selectedStoredSessionIdRef.current) {
try {
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current,
source: 'desktop'
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
return await send(recoveredId)
}
} catch {
// fall through — caller queues so nothing is lost
}
}
} catch {
// Swallow — caller queues the text so nothing is lost.
}
return false
},
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway, selectedStoredSessionIdRef]
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway]
)
const reloadFromMessage = useCallback(
@@ -837,9 +806,7 @@ export function usePromptActions({
handoffSession,
reloadFromMessage,
restoreToMessage,
redirectPrompt,
/** @deprecated Use `redirectPrompt` — this is an active-turn redirect, not tool steer. */
steerPrompt: redirectPrompt,
steerPrompt,
submitText,
transcribeVoiceAudio
}
@@ -1,23 +1,21 @@
import { type MutableRefObject, useCallback, useRef } from 'react'
import { type MutableRefObject, useCallback } from 'react'
import { getProfiles } from '@/hermes'
import type { Translations } from '@/i18n'
import { type ChatMessage, toChatMessages } from '@/lib/chat-messages'
import { type ChatMessage } from '@/lib/chat-messages'
import { parseCommandDispatch, parseSlashCommand, sessionTitle } from '@/lib/chat-runtime'
import {
type CommandsCatalogLike,
type DesktopActionId,
type DesktopCommandSurface,
type DesktopPickerId,
desktopSlashUnavailableMessage,
isDesktopSlashCommand,
resolveDesktopCommand
} from '@/lib/desktop-slash-commands'
import { isMissingRpcMethod } from '@/lib/gateway-rpc'
import { setSessionYolo } from '@/lib/yolo-session'
import { openCommandPalettePage } from '@/store/command-palette'
import { setComposerDraft } from '@/store/composer'
import { dismissNotification, notify, notifyError } from '@/store/notifications'
import { notify, notifyError } from '@/store/notifications'
import { setPetScale } from '@/store/pet-gallery'
import { $petGenInput, openPetGenerate } from '@/store/pet-generate'
import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile'
@@ -25,35 +23,22 @@ import {
$connection,
$sessions,
$yoloActive,
setCurrentUsage,
setModelPickerOpen,
setSessionPickerOpen,
setSessions,
setYoloActive
} from '@/store/session'
import type {
BrowserManageResponse,
ClientSessionState,
SessionCompressResponse,
SessionTitleResponse,
SlashExecResponse
} from '../../../types'
import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types'
import {
type GatewayRequest,
isSessionIdCandidate,
renderCommandsCatalog,
renderRpcResult,
slashStatusText,
type SubmitTextOptions
} from './utils'
// Manual compression is LLM-bound and routinely outlives the desktop's 30s
// default WS request timeout on large sessions — give it the TUI client's
// 120s RPC budget (HERMES_TUI_RPC_TIMEOUT_MS default) instead.
const SESSION_COMPRESS_TIMEOUT_MS = 120_000
/** Everything a slash handler needs about the invocation it's serving. */
interface SlashActionCtx {
arg: string
@@ -65,12 +50,7 @@ interface SlashActionCtx {
interface SlashCommandDeps {
activeSessionIdRef: MutableRefObject<string | null>
appendSessionTextMessage: (
sessionId: string,
role: ChatMessage['role'],
text: string,
storedSessionId?: string | null
) => void
appendSessionTextMessage: (sessionId: string, role: ChatMessage['role'], text: string) => void
branchCurrentSession: () => Promise<boolean>
busyRef: MutableRefObject<boolean>
copy: Translations['desktop']
@@ -84,14 +64,8 @@ interface SlashCommandDeps {
refreshSessions: () => Promise<void>
requestGateway: GatewayRequest
resumeStoredSession: (storedSessionId: string) => Promise<void> | void
selectedStoredSessionIdRef: MutableRefObject<string | null>
startFreshSessionDraft: () => void
submitPromptText: (rawText: string, options?: SubmitTextOptions) => Promise<boolean>
updateSessionState: (
sessionId: string,
updater: (state: ClientSessionState) => ClientSessionState,
storedSessionId?: string | null
) => ClientSessionState
}
/** The /slash command dispatcher, extracted from usePromptActions. */
@@ -109,14 +83,10 @@ export function useSlashCommand(deps: SlashCommandDeps) {
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
submitPromptText,
updateSessionState
submitPromptText
} = deps
const compressInFlightRef = useRef(new Set<string>())
return useCallback(
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
const ensureSessionId = async (sessionHint?: string) =>
@@ -127,7 +97,7 @@ export function useSlashCommand(deps: SlashCommandDeps) {
// build-renderSlashOutput boilerplate every exec-style handler repeats.
const withSlashOutput = async (
ctx: SlashActionCtx
): Promise<{ render: (text: string) => void; sessionId: string; storedSessionId: string | null } | null> => {
): Promise<{ render: (text: string) => void; sessionId: string } | null> => {
const sessionId = await ensureSessionId(ctx.sessionHint)
if (!sessionId) {
@@ -136,19 +106,10 @@ export function useSlashCommand(deps: SlashCommandDeps) {
return null
}
// A long-running command can finish after a session switch. Keep its
// output bound to the stored session selected at invocation time.
const storedSessionId = selectedStoredSessionIdRef.current
const render = (text: string) =>
appendSessionTextMessage(
sessionId,
'system',
ctx.recordInput ? slashStatusText(ctx.command, text) : text,
storedSessionId
)
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
return { render, sessionId, storedSessionId }
return { render, sessionId }
}
// `exec` commands (and unknown skill / quick commands the backend owns)
@@ -170,8 +131,6 @@ export function useSlashCommand(deps: SlashCommandDeps) {
return
}
let slashExecError: unknown = null
const handleDispatch = async (
dispatch: NonNullable<ReturnType<typeof parseCommandDispatch>>
): Promise<void> => {
@@ -247,11 +206,8 @@ export function useSlashCommand(deps: SlashCommandDeps) {
renderSlashOutput(output?.warning ? `warning: ${output.warning}\n${body}` : body)
return
} catch (error) {
// Fall back to command.dispatch for skill/send/alias directives, but
// keep the worker error: a slash.exec worker timeout/crash is the real
// failure, not the "not a quick/plugin/skill command" routing noise.
slashExecError = error
} catch {
// Fall back to command.dispatch for skill/send/alias directives.
}
try {
@@ -267,66 +223,6 @@ export function useSlashCommand(deps: SlashCommandDeps) {
await handleDispatch(dispatch)
} catch (err) {
// "not a quick/plugin/skill command" just means the fallback had
// nothing to add — the slash.exec failure (worker timeout, crash) is
// the real error, so don't bury it under the routing noise.
const dispatchMessage = err instanceof Error ? err.message : String(err)
if (slashExecError && /not a quick\/plugin\/skill command/i.test(dispatchMessage)) {
const original = slashExecError instanceof Error ? slashExecError.message : String(slashExecError)
renderSlashOutput(`error: /${name} failed: ${original}`)
return
}
renderSlashOutput(`error: ${dispatchMessage}`)
}
}
// `rpc` commands have a dedicated gateway handler — skip slash.exec /
// command.dispatch entirely. The response is structured (a JSON RPC
// reply, not an inline text stream) so we render it via the generic
// `renderRpcResult` shaper that knows the field conventions shared by
// session.save / session.status / session.usage / session.steer /
// process.stop / agents.list.
async function runRpc(
surface: Extract<DesktopCommandSurface, { kind: 'rpc' }>,
ctx: SlashActionCtx
): Promise<void> {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId } = resolved
try {
const params = surface.buildParams({
arg: ctx.arg,
command: ctx.command,
name: ctx.name,
sessionId
})
// Forward the surface's declared timeout when present; the default
// requestGateway layer keeps (30s) is too tight for RPCs that do
// real work.
const result = await requestGateway<unknown>(surface.rpc, params, surface.timeoutMs)
const body = renderRpcResult(result, ctx.name)
renderSlashOutput(body || `/${ctx.name}: no output`)
} catch (err) {
// TODO: remove this compatibility fallback once every supported
// managed runtime exposes the dedicated RPC surface. Desktop and its
// gateway update independently; older gateways still support the
// slash-worker route.
if (isMissingRpcMethod(err)) {
await runExec(ctx)
return
}
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
}
}
@@ -341,145 +237,6 @@ export function useSlashCommand(deps: SlashCommandDeps) {
branch: async () => {
await branchCurrentSession()
},
// /compress (alias /compact) runs the gateway's dedicated
// session.compress RPC — the TUI's path
// (ui-tui/src/app/slash/commands/session.ts). It must NOT go through
// runExec: compressing a large session outlives the slash worker's pipe
// timeout (45s) and the desktop's 30s WS default, and the resulting
// slash.exec error cascaded into command.dispatch's misleading "not a
// quick/plugin/skill command: compress" (#44456).
//
// The RPC returns the post-compress `messages` array (same shape
// session.resume returns), so we replace the transcript from it —
// otherwise the summarized bubbles stay on screen forever (#44462
// review). `updateSessionState` only publishes for the active runtime,
// so a late result after a session switch refreshes its own cache
// without clobbering the foreground transcript (#53755 review).
compress: async ctx => {
const resolved = await withSlashOutput(ctx)
if (!resolved) {
return
}
const { render: renderSlashOutput, sessionId, storedSessionId } = resolved
const focusTopic = ctx.arg.trim()
const noticeId = `session-compress:${sessionId}`
// Coalesce concurrent compress requests for the same session so a
// double-enter doesn't fire two LLM summarise calls.
if (compressInFlightRef.current.has(sessionId)) {
return
}
compressInFlightRef.current.add(sessionId)
notify({
durationMs: 0,
id: noticeId,
kind: 'info',
message: focusTopic ? `compressing context for: ${focusTopic}` : 'compressing context...'
})
try {
const result = await requestGateway<SessionCompressResponse>(
'session.compress',
{
session_id: sessionId,
...(focusTopic ? { focus_topic: focusTopic } : {})
},
SESSION_COMPRESS_TIMEOUT_MS
)
// Replace the transcript with the post-compress history so the
// summarized bubbles actually disappear. `messages` is the same
// shape session.resume returns (_history_to_messages), so
// toChatMessages handles it directly. updateSessionState only
// publishes for the active runtime, guarding against a late result
// clobbering the foreground after a session switch.
if (Array.isArray(result?.messages)) {
updateSessionState(
sessionId,
state => ({ ...state, messages: toChatMessages(result.messages!) }),
storedSessionId
)
}
const usage = { ...result?.usage, ...result?.info?.usage }
if (Object.keys(usage).length && activeSessionIdRef.current === sessionId) {
setCurrentUsage(current => ({ ...current, ...usage }))
}
if (result?.info?.title !== undefined) {
setSessions(prev =>
prev.map(session =>
session.id === sessionId ? { ...session, title: result.info!.title || null } : session
)
)
}
if (result?.summary?.headline) {
const lines = [result.summary.headline, result.summary.token_line, result.summary.note].filter(
(line): line is string => Boolean(line)
)
const aborted = result.status === 'aborted' || result.summary.aborted === true
if (!aborted) {
// Keep a durable record of a successful manual compression in
// the chat after replacing it with the authoritative backend
// transcript. Errors remain transient: appending an error as a
// system message would look like a successful state change.
renderSlashOutput(lines.join('\n'))
}
notify({
durationMs: 5_000,
id: noticeId,
kind: aborted ? 'error' : 'success',
message: lines.join('\n')
})
return
}
const hostOutput = result?.host_ack?.output?.trim()
if (hostOutput) {
renderSlashOutput(hostOutput)
notify({ durationMs: 5_000, id: noticeId, kind: 'success', message: hostOutput })
return
}
const removed = result?.removed ?? 0
const message = removed > 0 ? `compressed ${removed} messages` : 'nothing to compress'
renderSlashOutput(message)
notify({
durationMs: 5_000,
id: noticeId,
kind: 'success',
message
})
} catch (err) {
dismissNotification(noticeId)
// Desktop and gateway runtimes update independently. Preserve the
// historical slash-worker path for an older gateway that has not
// shipped session.compress yet; it cannot offer the longer RPC
// timeout, but it must not turn a once-working command into an
// immediate "method not found" error.
if (isMissingRpcMethod(err)) {
await runExec(ctx)
return
}
renderSlashOutput(`error: ${err instanceof Error ? err.message : String(err)}`)
} finally {
compressInFlightRef.current.delete(sessionId)
}
},
// /yolo maps to the status-bar YOLO control — a per-session approval
// bypass, same scope as the TUI's Shift+Tab. With no session yet we arm
// it locally; the session-create path applies it on the first message.
@@ -850,9 +607,6 @@ export function useSlashCommand(deps: SlashCommandDeps) {
case 'action':
return actionHandlers[surface.action](ctx)
case 'rpc':
return runRpc(surface, ctx)
default:
// exec spec, or an unknown skill / quick command the backend owns.
return runExec(ctx)
@@ -874,10 +628,8 @@ export function useSlashCommand(deps: SlashCommandDeps) {
refreshSessions,
requestGateway,
resumeStoredSession,
selectedStoredSessionIdRef,
startFreshSessionDraft,
submitPromptText,
updateSessionState
submitPromptText
]
)
}
@@ -6,12 +6,6 @@ import { type ChatMessage, textPart } from '@/lib/chat-messages'
import { optimisticAttachmentRef } from '@/lib/chat-runtime'
import { sanitizeComposerInput } from '@/lib/composer-input-sanitize'
import { setMutableRef } from '@/lib/mutable-ref'
import {
isVoicePlaybackActive,
markVoicePlaybackInterrupted,
stopVoicePlayback,
takeVoicePlaybackInterrupted
} from '@/lib/voice-playback'
import {
$composerAttachments,
clearComposerAttachments,
@@ -23,7 +17,6 @@ import { requestDesktopOnboarding } from '@/store/onboarding'
import { setAwaitingResponse, setBusy, setMessages } from '@/store/session'
import type { ClientSessionState } from '../../../types'
import { sessionContextDrift } from '../session-context-drift'
import {
_submitInFlight,
@@ -148,16 +141,6 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return false
}
// Typing barge-in: a new send silences any in-flight spoken reply.
if (isVoicePlaybackActive()) {
markVoicePlaybackInterrupted()
stopVoicePlayback()
}
// Barged mid-speech (here or via the voice loop's VAD)? Flag the submit
// so the backend notes the interruption to the model.
const interrupted = takeVoicePlaybackInterrupted()
// Queue drains carry their source session explicitly. A background drain
// must never inherit the currently selected session after the user moves
// to another chat.
@@ -192,28 +175,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
let startingRouteToken = getRouteToken()
// Reason string (or null) for why the session context genuinely drifted
// under this in-flight submit. sessionContextDrift ignores the churn a
// busy gateway produces (selection null-resets on a gateway/profile
// switch, search/hash-only route changes, background active-ref
// retargets) so a second-session send doesn't silently abort — it fires
// only on a real move to a DIFFERENT chat. Reads the live refs/route each
// call and measures against the (mutable) baseline, which is re-pinned to
// the created chat after createBackendSessionForSend. submitTargetStoredId
// is the stored session this submit targets, so a move ONTO it (the
// pipeline's own re-home) is never counted as drift.
const sessionDriftReason = (): string | null =>
targetStartedInCurrentView
? sessionContextDrift({
startRouteToken: startingRouteToken,
nowRouteToken: getRouteToken(),
startSelectedStoredId: startingStoredSessionId,
nowSelectedStoredId: selectedStoredSessionIdRef.current,
submitTargetStoredId: startingStoredSessionId
})
: null
const sessionContextDrifted = (): boolean =>
targetStartedInCurrentView &&
(selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken)
const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionDriftReason()
const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionContextDrifted()
// One submit in flight per session — drop any concurrent re-fire so a
// stalled turn can't stack the same prompt into multiple real turns. The
@@ -351,11 +317,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return abortForSessionSwitch(null)
}
const routedResumeDrift = sessionDriftReason()
if (routedResumeDrift) {
console.warn('[submit-drift-abort]', routedResumeDrift, { phase: 'post-routed-resume' })
if (sessionContextDrifted()) {
return abortForSessionSwitch(null)
}
@@ -389,11 +351,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
source: 'desktop'
})
const resumeDrift = sessionDriftReason()
if (resumeDrift) {
console.warn('[submit-drift-abort]', resumeDrift, { phase: 'post-resume' })
if (sessionContextDrifted()) {
return abortForSessionSwitch(sessionId)
}
@@ -413,11 +371,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
return abortForSessionSwitch(null)
}
const resumeSettleDrift = sessionDriftReason()
if (resumeSettleDrift) {
console.warn('[submit-drift-abort]', resumeSettleDrift, { phase: 'post-resume-settle' })
if (sessionContextDrifted()) {
return abortForSessionSwitch(sessionId)
}
@@ -446,11 +400,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
// createBackendSessionForSend returns null when the user switched
// sessions mid-create (it closes the orphaned session itself) —
// abort silently. Anything else is a real failure worth a toast.
const createNullDrift = sessionDriftReason()
if (createNullDrift) {
console.warn('[submit-drift-abort]', createNullDrift, { phase: 'post-create-null' })
if (sessionContextDrifted()) {
return abortForSessionSwitch(null)
}
@@ -489,11 +439,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
updateComposerAttachments: usingComposerAttachments
})
const attachmentsDrift = sessionDriftReason()
if (attachmentsDrift) {
console.warn('[submit-drift-abort]', attachmentsDrift, { phase: 'post-attachments' })
if (sessionContextDrifted()) {
return abortForSessionSwitch(sessionId)
}
@@ -504,12 +450,6 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)
const submitParams = (targetId: string) => ({
session_id: targetId,
text,
...(interrupted && { interrupted })
})
// On sleep/wake the gateway's in-memory session may have been cleared
// while the desktop app still holds the old session ID. Detect this,
// resume the stored session to re-register it, and retry once.
@@ -517,7 +457,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
try {
await withSessionBusyRetry(() =>
requestGateway('prompt.submit', submitParams(sessionId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
)
} catch (firstErr) {
const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current
@@ -533,11 +473,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
source: 'desktop'
})
const resumeRetryDrift = sessionDriftReason()
if (resumeRetryDrift) {
console.warn('[submit-drift-abort]', resumeRetryDrift, { phase: 'post-resume-retry' })
if (sessionContextDrifted()) {
return abortForSessionSwitch(sessionId)
}
@@ -549,7 +485,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
}
await withSessionBusyRetry(() =>
requestGateway('prompt.submit', submitParams(recoveredId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
)
} else {
submitErr = firstErr
@@ -12,7 +12,6 @@ import {
isSessionBusyError,
isSessionIdCandidate,
isSessionNotFoundError,
renderRpcResult,
slashStatusText,
visibleUserIndexAtOrdinal,
visibleUserOrdinal
@@ -126,139 +125,3 @@ describe('visible user ordinals', () => {
expect(visibleUserIndexAtOrdinal(messages, 5)).toBe(-1)
})
})
describe('renderRpcResult', () => {
describe('session.compress (summary shape)', () => {
it('renders the summary headline with token line and note', () => {
expect(
renderRpcResult(
{
summary: {
headline: 'Compressed: 280 → 120 messages',
token_line: 'Approx request size: ~126,575 → ~30,000 tokens',
note: 'Removed 8 older turns',
noop: false
}
},
'compress'
)
).toBe(
[
'✓ Compressed: 280 → 120 messages',
' Approx request size: ~126,575 → ~30,000 tokens',
' Removed 8 older turns'
].join('\n')
)
})
it('drops the checkmark when the summary is a noop', () => {
expect(
renderRpcResult(
{ summary: { headline: 'Already compressed', note: 'No new turns since last compress', noop: true } },
'compress'
)
).toBe('Already compressed\n No new turns since last compress')
})
})
describe('session.steer', () => {
it('reports a queued steer with the original text', () => {
expect(renderRpcResult({ status: 'queued', text: 'skip the docs' }, 'steer')).toBe(
'Steered · "skip the docs" queued for next tool call'
)
})
it('reports a rejected steer without echoing user text', () => {
expect(renderRpcResult({ status: 'rejected', text: 'whatever' }, 'steer')).toBe(
'Steer rejected — agent declined input'
)
})
})
describe('process.stop', () => {
it('reports the numeric number of stopped processes', () => {
expect(renderRpcResult({ killed: 2 }, 'stop')).toBe('Stopped 2 background processes.')
})
it('reports nothing-to-stop when the numeric count is zero', () => {
expect(renderRpcResult({ killed: 0 }, 'stop')).toBe('No background processes to stop.')
})
})
describe('session.save', () => {
it('echoes the saved file path', () => {
expect(renderRpcResult({ file: '/home/user/.hermes/sessions/saved/x.json' }, 'save')).toBe(
'Saved transcript to /home/user/.hermes/sessions/saved/x.json'
)
})
})
describe('session.status', () => {
it('passes through the multi-line plain-text output verbatim', () => {
const output = 'Hermes TUI Status\n\nSession ID: s-1\nModel: nous-hermes-3 (unknown)'
expect(renderRpcResult({ output }, 'status')).toBe(output)
})
})
describe('session.usage', () => {
it('formats calls / input / output / total with thousands separators', () => {
expect(renderRpcResult({ calls: 12, input: 1_234_567, output: 89_012, total: 1_323_579 }, 'usage')).toBe(
'Usage: 12 calls · 1,234,567 in / 89,012 out · 1,323,579 total'
)
})
it('appends credits_lines when present', () => {
const body = renderRpcResult(
{
calls: 1,
input: 10,
output: 20,
total: 30,
credits_lines: ['Nous credits: 8,420 remaining', 'Resets: 2026-08-01']
},
'usage'
)
expect(body.split('\n')).toEqual([
'Usage: 1 calls · 10 in / 20 out · 30 total',
'Nous credits: 8,420 remaining',
'Resets: 2026-08-01'
])
})
})
describe('agents.list', () => {
it('reports no running tasks when the array is empty', () => {
expect(renderRpcResult({ processes: [] }, 'agents')).toBe('No background tasks running.')
})
it('formats each process with status, command, and metadata', () => {
expect(
renderRpcResult(
{
processes: [
{ session_id: 's-1', command: 'npm test', status: 'running', uptime: 42 },
{ session_id: 's-2', command: 'vitest', status: 'completed' }
]
},
'agents'
)
).toBe(['• [running] npm test (42s · s-1)', '• [completed] vitest (s-2)'].join('\n'))
})
})
describe('fallback', () => {
it('serialises unknown shapes as JSON so we never lose data', () => {
expect(renderRpcResult({ custom: 'value', nested: { a: 1 } }, 'mystery')).toBe(
'/mystery: {"custom":"value","nested":{"a":1}}'
)
})
it('returns an empty string for null and primitive payloads', () => {
expect(renderRpcResult(null, 'x')).toBe('')
expect(renderRpcResult(undefined, 'x')).toBe('')
expect(renderRpcResult('plain string', 'x')).toBe('')
expect(renderRpcResult(42, 'x')).toBe('')
})
})
})
@@ -192,140 +192,6 @@ export function slashStatusText(command: string, output: string): string {
return [`slash:${command}`, output.trim()].filter(Boolean).join('\n')
}
/**
* Format the JSON reply from a gateway RPC surfaced as `kind: 'rpc'` in
* desktop-slash-commands.ts. Kept here (instead of slash.ts) so it has no
* React / store dependencies and can be unit-tested in isolation.
*
* The renderer follows the field conventions shared by the gateway handlers
* we route to today:
* - `session.compress`: { summary: { headline, token_line, note, noop } }
* (only used if /compress ever moves to `rpc`; it's an `action` today
* because it needs transcript replacement)
* - `session.status`: { output: "<multi-line plain text>" }
* - `session.save`: { file: "<absolute path>" }
* - `session.usage`: { calls, input, output, total, credits_lines? }
* - `session.steer`: { status: 'queued' | 'rejected', text }
* - `process.stop`: { killed: boolean }
* - `agents.list`: { processes: [{ session_id, command, status, uptime }] }
*
* Any RPC whose response doesn't match these shapes falls through to a
* JSON.stringify dump so we never silently swallow data.
*/
export function renderRpcResult(response: unknown, name: string): string {
if (!response || typeof response !== 'object') {
return ''
}
const r = response as Record<string, unknown>
const summary = r.summary as { headline?: string; token_line?: string; note?: string; noop?: boolean } | undefined
if (summary && typeof summary === 'object' && typeof summary.headline === 'string' && summary.headline) {
const lines: string[] = [`${summary.noop ? '' : '✓ '}${summary.headline}`]
if (summary.token_line) {
lines.push(` ${summary.token_line}`)
}
if (summary.note) {
lines.push(` ${summary.note}`)
}
return lines.join('\n')
}
// session.steer — { status: 'queued' | 'rejected', text }
if (r.status === 'queued' || r.status === 'rejected') {
const text = typeof r.text === 'string' ? r.text : ''
if (r.status === 'queued') {
return text ? `Steered · "${text}" queued for next tool call` : 'Steered next tool call'
}
return 'Steer rejected — agent declined input'
}
// process.stop — { killed: number }
if ('killed' in r && typeof r.killed === 'number') {
return r.killed > 0
? `Stopped ${r.killed} background process${r.killed === 1 ? '' : 'es'}.`
: 'No background processes to stop.'
}
// session.save — { file }
if (typeof r.file === 'string' && r.file) {
return `Saved transcript to ${r.file}`
}
// session.status — { output }
if (typeof r.output === 'string' && r.output) {
return r.output
}
// session.usage — { calls, input, output, total, credits_lines? }
if ('total' in r || 'input' in r || 'output' in r || 'calls' in r) {
const calls = Number(r.calls ?? 0)
const input = Number(r.input ?? 0)
const output = Number(r.output ?? 0)
const total = Number(r.total ?? 0)
const lines: string[] = [
`Usage: ${calls.toLocaleString()} calls · ${input.toLocaleString()} in / ${output.toLocaleString()} out · ${total.toLocaleString()} total`
]
if (Array.isArray(r.credits_lines)) {
for (const credit of r.credits_lines) {
if (typeof credit === 'string' && credit.trim()) {
lines.push(credit.trim())
}
}
}
return lines.join('\n')
}
// agents.list — { processes: [{ session_id, command, status, uptime }] }
if (Array.isArray(r.processes)) {
if (r.processes.length === 0) {
return 'No background tasks running.'
}
return r.processes
.map(p => {
if (!p || typeof p !== 'object') {
return ''
}
const proc = p as Record<string, unknown>
const status = typeof proc.status === 'string' ? proc.status : 'unknown'
const command = typeof proc.command === 'string' ? proc.command : ''
const sessionId = typeof proc.session_id === 'string' ? proc.session_id : ''
const uptime = proc.uptime
const meta: string[] = []
if (typeof uptime === 'number' && uptime >= 0) {
meta.push(`${uptime}s`)
}
if (sessionId) {
meta.push(sessionId)
}
const tail = meta.length ? ` (${meta.join(' · ')})` : ''
return `• [${status}] ${command}${tail}`
})
.filter(Boolean)
.join('\n')
}
// Generic fallback — keeps us honest if the gateway adds new fields we
// haven't shaped yet.
return `/${name}: ${JSON.stringify(r)}`
}
export function appendText(message: AppendMessage): string {
return message.content
.map(part => ('text' in part ? part.text : ''))
@@ -60,11 +60,10 @@ import {
} from '@/store/session-states'
import { broadcastSessionsChanged } from '@/store/session-sync'
import { isWatchWindow } from '@/store/windows'
import type { SessionCreateResponse, SessionMessage, SessionResumeResponse, UsageStats } from '@/types/hermes'
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../../routes'
import type { ClientSessionState, SidebarNavItem } from '../../../types'
import { sessionContextDrift } from '../session-context-drift'
import {
appendLiveSessionProjection,
@@ -340,6 +339,7 @@ export function useSessionActions({
const createBackendSessionForSend = useCallback(
async (preview: string | null = null): Promise<string | null> => {
const startingActiveSessionId = activeSessionIdRef.current
const startingStoredSessionId = selectedStoredSessionIdRef.current
const startingRouteToken = getRouteToken()
@@ -362,24 +362,11 @@ export function useSessionActions({
const created = await requestGateway<SessionCreateResponse>('session.create', params)
const stored = created.stored_session_id ?? null
// Only a genuine move to a DIFFERENT chat mid-create should orphan the
// session we just minted. The active runtime ref is deliberately not a
// prong: background gateway events retarget it while other sessions
// stream (#47709 class), and the seconds-long session.create round-trip
// (server-side agent + MCP init) makes that churn near-certain — every
// genuine user switch retargets selection AND route synchronously
// anyway. submitTargetStoredId is the just-created stored session, so
// our own upcoming re-home onto it never reads as drift.
const drift = sessionContextDrift({
startRouteToken: startingRouteToken,
nowRouteToken: getRouteToken(),
startSelectedStoredId: startingStoredSessionId,
nowSelectedStoredId: selectedStoredSessionIdRef.current,
submitTargetStoredId: stored
})
if (drift) {
console.warn('[submit-drift-abort]', drift, { phase: 'mid-create' })
if (
activeSessionIdRef.current !== startingActiveSessionId ||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
getRouteToken() !== startingRouteToken
) {
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
return null
@@ -778,9 +765,8 @@ export function useSessionActions({
setMessages([])
}
// A history load is not a live turn. Toggling busy here and again in the
// finally block re-renders the thread viewport after it has loaded.
busyRef.current = true
setBusy(true)
setAwaitingResponse(false)
clearNotifications()
setSelectedStoredSessionId(storedSessionId)
@@ -806,6 +792,7 @@ export function useSessionActions({
: $messages.get()
let prefetchApplied = false
let prefetchedMessageCount = 0
let prefetchedStoredSessionId: string | null = null
// REST transcript prefetch and the gateway resume RPC are independent
@@ -832,14 +819,24 @@ export function useSessionActions({
// keeps it from surfacing as unhandled while the prefetch settles.
resumePromise.catch(() => undefined)
// Keep both requests concurrent, but do not paint the REST result until
// the runtime resume has also settled. An eager prefetch paint followed
// by the runtime projection rebuilds large transcripts during resume.
let prefetchedResult: { messages: SessionMessage[]; session_id?: string } | null = null
try {
if (prefetchPromise) {
prefetchedResult = await prefetchPromise
const storedMessages = await prefetchPromise
if (isCurrentResume()) {
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
: $messages.get()
localSnapshot = reconcileAuthoritativeMessages(storedMessages.messages, previousMessages)
prefetchApplied = true
prefetchedMessageCount = storedMessages.messages.length
prefetchedStoredSessionId = storedMessages.session_id || storedSessionId
if (!chatMessageArraysEquivalent($messages.get(), localSnapshot)) {
setMessages(localSnapshot)
}
}
}
} catch {
// Non-fatal: gateway resume below can still hydrate the session.
@@ -851,16 +848,6 @@ export function useSessionActions({
return
}
if (prefetchedResult) {
const previousMessages = resumedSameSelectedSession
? preserveLocalPendingTurnMessages($messages.get(), resumeStartMessages)
: $messages.get()
localSnapshot = reconcileAuthoritativeMessages(prefetchedResult.messages, previousMessages)
prefetchApplied = true
prefetchedStoredSessionId = prefetchedResult.session_id || storedSessionId
}
const currentMessages = $messages.get()
// Keep the local snapshot when resume would only reshuffle runtime
@@ -876,7 +863,10 @@ export function useSessionActions({
const hasLiveProjection = Boolean(resumed.inflight || resumed.queued)
const preferredMessages =
prefetchApplied && prefetchMatchesResumedSession && !hasLiveProjection
prefetchApplied &&
prefetchMatchesResumedSession &&
!hasLiveProjection &&
resumed.messages.length <= prefetchedMessageCount
? localSnapshot
: (() => {
const previousMessages = resumedSameSelectedSession
@@ -924,14 +914,6 @@ export function useSessionActions({
}),
storedSessionId
)
// updateSessionState stages its view sync through requestAnimationFrame.
// Commit the final, already-reconciled transcript now so resume has one
// additive DOM build instead of an eager prefetch build plus a later
// runtime projection build.
if (!chatMessageArraysEquivalent($messages.get(), messagesForView)) {
setMessages(messagesForView)
}
} catch (err) {
if (!isCurrentResume()) {
return
@@ -327,42 +327,6 @@ describe('preserveLocalPendingTurnMessages', () => {
expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next)
})
it('drops stale optimistic history after compression and keeps only the live tail', () => {
const compressedAuthority = [
msg('stored-user', 'user', 'first turn that survived compression'),
msg('stored-assistant', 'assistant', 'latest authoritative reply')
]
const pollutedWarmCache = [
msg('user-old-1', 'user', 'compressed-away prompt one'),
msg('assistant-old-1', 'assistant', 'compressed-away reply one'),
msg('user-old-2', 'user', 'compressed-away prompt two'),
msg('assistant-old-2', 'assistant', 'compressed-away reply two'),
msg('user-inflight', 'user', 'the one genuinely in-flight prompt')
]
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache).map(message => message.id)).toEqual([
'stored-user',
'stored-assistant',
'user-inflight'
])
})
it('drops the live tail once the latest authoritative user has persisted it after compression', () => {
const compressedAuthority = [
msg('stored-user', 'user', 'the one genuinely in-flight prompt'),
msg('stored-assistant', 'assistant', 'its authoritative reply')
]
const pollutedWarmCache = [
msg('user-old-1', 'user', 'compressed-away prompt one'),
msg('assistant-old-1', 'assistant', 'compressed-away reply one'),
msg('user-inflight', 'user', 'the one genuinely in-flight prompt')
]
expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority)
})
})
describe('appendLiveSessionProjection', () => {
@@ -390,32 +354,6 @@ describe('appendLiveSessionProjection', () => {
expect(restored[3]).toMatchObject({ id: 'assistant-stream-runtime-1', pending: true })
})
it('does not duplicate a persisted inflight user after consecutive canceled user turns', () => {
const stored = [
msg('stored-user-1', 'user', 'canceled prompt one'),
msg('stored-user-2', 'user', 'canceled prompt two'),
msg('stored-user-3', 'user', 'current running prompt')
]
const restored = appendLiveSessionProjection(stored, {
session_id: 'runtime-1',
inflight: {
user: 'current running prompt',
assistant: 'partial answer',
streaming: true
}
})
expect(restored.map(message => message.role)).toEqual(['user', 'user', 'user', 'assistant'])
expect(restored.map(message => message.parts.map(part => ('text' in part ? part.text : '')).join(''))).toEqual([
'canceled prompt one',
'canceled prompt two',
'current running prompt',
'partial answer'
])
expect(restored[3]).toMatchObject({ id: 'assistant-stream-runtime-1', pending: true })
})
it('preserves the original array when no live projection exists', () => {
const stored = [msg('stored-user', 'user', 'earlier')]
@@ -73,7 +73,7 @@ const _chatMessageFieldsExhaustive: {
[K in Exclude<keyof ChatMessage, (typeof COMPARED_FIELDS)[number] | (typeof IGNORED_FIELDS)[number]>]: never
} = {}
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId'] as const
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const
// Compile-time check: every ChatMessagePart discriminant must be handled by
@@ -154,10 +154,7 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean
a.pending !== b.pending ||
a.error !== b.error ||
a.hidden !== b.hidden ||
a.branchGroupId !== b.branchGroupId ||
// Interim gates the action footer, so flipping it must repaint (e.g. a
// previewed final settling onto a sealed interim bubble restores the bar).
(a.interim ?? false) !== (b.interim ?? false)
a.branchGroupId !== b.branchGroupId
) {
return false
}
@@ -234,12 +231,9 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
* dropping either makes an accepted turn appear to vanish during transport
* churn.
*
* A lagging projection can be behind by one live turn, never a whole local
* history window. Preserve only the newest optimistic user row: compression
* rewrites past context, so older `user-*` rows in a warm cache are stale
* history, not in-flight work. The latest authoritative user confirms whether
* that tail has persisted; any authoritative assistant at the same ordinal
* supersedes the local stream.
* Authoritative rows use different ids, so match by role ordinal. A matching
* user row is considered committed only when its visible text also matches;
* any authoritative assistant at the same ordinal supersedes the local stream.
*/
export function preserveLocalPendingTurnMessages(
nextMessages: ChatMessage[],
@@ -260,10 +254,6 @@ export function preserveLocalPendingTurnMessages(
const nextIds = new Set(nextMessages.map(message => message.id))
const previousRoleCounts = new Map<ChatMessage['role'], number>()
const newestOptimisticUser = [...previousMessages]
.reverse()
.find(message => message.role === 'user' && message.id.startsWith('user-'))
const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user')
const preserved: ChatMessage[] = []
for (const message of previousMessages) {
@@ -279,14 +269,6 @@ export function preserveLocalPendingTurnMessages(
continue
}
if (isOptimisticUser && message !== newestOptimisticUser) {
continue
}
if (isOptimisticUser && latestAuthoritativeUser && chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()) {
continue
}
const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`)
if (authoritative) {
@@ -329,15 +311,8 @@ export function appendLiveSessionProjection(
const sessionId = projection.session_id || 'session'
const projected: ChatMessage[] = []
// A turn normally persists its user row before inference begins. session.resume
// then returns that stored row *and* the still-live inflight projection; adding
// both makes a backgrounded prompt appear twice when its session is reopened.
// Only suppress the projection when the latest authoritative user row is the
// same turn — older identical prompts must not hide a newly accepted repeat.
const latestUser = [...messages].reverse().find(message => message.role === 'user')
const inflightUserAlreadyPersisted = latestUser && chatMessageText(latestUser).trim() === inflightUser
if (inflightUser && !inflightUserAlreadyPersisted) {
if (inflightUser) {
projected.push({
id: `user-inflight-${sessionId}`,
role: 'user',
@@ -21,7 +21,27 @@ import { publishSessionState } from '@/store/session-states'
import type { ClientSessionState } from '../../types'
import { chatMessageArraysEquivalent } from './use-session-actions/utils'
// Shallow per-message identity check. When a flush carries no transcript
// changes, `preserveLocalAssistantErrors` returns the same message objects in
// the same order, so reference equality per slot is enough to detect "nothing
// to publish" and avoid a needless `$messages` churn.
function sameMessageList(a: ChatMessage[], b: ChatMessage[]): boolean {
if (a === b) {
return true
}
if (a.length !== b.length) {
return false
}
for (let index = 0; index < a.length; index += 1) {
if (a[index] !== b[index]) {
return false
}
}
return true
}
interface SessionStateCacheOptions {
activeSessionId: string | null
@@ -139,12 +159,7 @@ export function useSessionStateCache({
// the transcript. That churns ChatView → runtimeMessageRepository → the
// assistant-ui runtime → the virtualizer, which re-measures and visibly
// jerks the scroll position while the user is reading. Skip the publish when
// the merged result is content-equivalent to what's already on screen.
// Deep comparison (not just reference equality) is needed because the warm
// resume path's `reconcileAuthoritativeMessages` creates new message objects
// via `toChatMessages` even when the content hasn't changed — reference
// equality would fail and cause a redundant second paint (the "warm resume
// jitter" bug).
// the merged result is content-identical to what's already on screen.
const currentMessages = $messages.get()
// On a thread switch `$messages` still holds the *previous* thread, so
@@ -157,7 +172,7 @@ export function useSessionStateCache({
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
: pending.state.messages
if (!chatMessageArraysEquivalent(nextMessages, currentMessages)) {
if (!sameMessageList(nextMessages, currentMessages)) {
setMessages(nextMessages)
}
+4 -5
View File
@@ -247,11 +247,10 @@ export const ENUM_OPTIONS: Record<string, string[]> = {
'code_execution.mode': ['project', 'strict'],
'context.engine': ['compressor', 'default', 'custom'],
'delegation.reasoning_effort': ['', 'minimal', 'low', 'medium', 'high', 'xhigh', 'max', 'ultra'],
// NOTE: memory.provider is intentionally NOT listed here. Its options are
// discovery-driven and served by the backend config schema (merged
// per-request in web_server._schema_with_dynamic_provider_options), so
// config-field consumes schema.options directly — a static list here would
// shadow that and hide user-installed/pip providers (#49513).
// Built-in memory is not a provider plugin: the empty sentinel renders as
// "Built-in only" and a legacy literal `builtin` value is only kept visible
// via enumOptionsFor's current-value passthrough (#49513).
'memory.provider': ['', 'honcho', 'hindsight'],
// Terminal execution backends — kept in sync with the dispatch ladder in
// tools/terminal_tool.py::_create_environment (local/docker/singularity/
// modal/daytona/ssh). Remote backends need extra env (image, tokens, host).
+12 -6
View File
@@ -30,12 +30,18 @@ describe('settings helpers', () => {
expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy()
})
it('does not shadow the backend schema options for memory.provider', () => {
// memory.provider options are discovery-driven and served by the backend
// config schema (merged per-request); enumOptionsFor must return undefined
// so config-field consumes schema.options instead of a stale static list.
expect(enumOptionsFor('memory.provider', '', {})).toBeUndefined()
expect(enumOptionsFor('memory.provider', 'honcho', {})).toBeUndefined()
it('lists the desktop memory provider options in their declared order', () => {
const options = enumOptionsFor('memory.provider', '', {})
// Built-in memory is not a provider plugin; the empty sentinel is the
// only built-in-shaped entry (#49513).
expect(options).toEqual(['', 'honcho', 'hindsight'])
})
it('keeps a legacy literal builtin value visible as the current selection', () => {
const options = enumOptionsFor('memory.provider', 'builtin', {})
expect(options).toEqual(['', 'honcho', 'hindsight', 'builtin'])
})
describe('isExternalMemoryProvider', () => {
+1 -1
View File
@@ -113,7 +113,7 @@ export function inferFieldSchema(value: unknown): ConfigFieldSchema {
return { type: 'string' }
}
// Backend schema omits some declared keys; config presence is the availability signal.
// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal.
export function sectionFieldEntries(
schema: Record<string, ConfigFieldSchema>,
config: HermesConfigRecord
@@ -211,31 +211,6 @@ describe('ToolsetConfigPanel', () => {
await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'ElevenLabs'))
})
it('serializes provider selection while a previous choice is pending', async () => {
let resolveSelection: (value: { name: string; ok: boolean; provider: string }) => void = () => undefined
selectToolsetProvider.mockImplementationOnce(
() =>
new Promise(resolve => {
resolveSelection = resolve
})
)
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
const edge = await screen.findByRole('button', { name: /Microsoft Edge TTS/ })
const elevenlabs = screen.getByRole('button', { name: /ElevenLabs/ })
fireEvent.click(edge)
await waitFor(() => expect(selectToolsetProvider).toHaveBeenCalledWith('tts', 'Microsoft Edge TTS'))
expect(elevenlabs.hasAttribute('disabled')).toBe(true)
fireEvent.click(elevenlabs)
expect(selectToolsetProvider).toHaveBeenCalledTimes(1)
resolveSelection({ name: 'tts', ok: true, provider: 'Microsoft Edge TTS' })
await waitFor(() => expect(elevenlabs.hasAttribute('disabled')).toBe(false))
})
it('shows a backend model catalog for image_gen and persists a pick', async () => {
getToolsetConfig.mockResolvedValue(
config({
@@ -942,13 +917,8 @@ describe('ToolsetConfigPanel', () => {
const { ToolsetConfigPanel } = await import('./toolset-config-panel')
render(<ToolsetConfigPanel onConfiguredChange={vi.fn()} toolset="tts" />)
// Expand the keyed provider so its env row renders. Wait for the
// selection to commit: on a freshly loaded panel this races the default
// provider initializer, and user intent must win that race.
const elevenLabs = await screen.findByRole('button', { name: /ElevenLabs/ })
fireEvent.click(elevenLabs)
await waitFor(() => expect(elevenLabs.getAttribute('aria-pressed')).toBe('true'))
// Expand the keyed provider so its env row renders.
fireEvent.click(await screen.findByRole('button', { name: /ElevenLabs/ }))
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
@@ -490,9 +490,6 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
const [activeProvider, setActiveProvider] = useState<string | null>(null)
// Live per-key set/unset state, seeded from the endpoint then patched locally.
const [envState, setEnvState] = useState<Record<string, boolean>>({})
// Default-provider selection and a user click race just after config arrives:
// a stale initialization effect must never replace an explicit choice.
const providerChoiceClaimedRef = useRef(false)
// Guard the Nous Portal sign-in poll loop against unmount/state updates.
const mountedRef = useRef(true)
@@ -538,7 +535,7 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
// panel highlighted the first keyless provider (e.g. Nous Portal) even when
// the user had already selected another (e.g. DuckDuckGo).
useEffect(() => {
if (providerChoiceClaimedRef.current || activeProvider || providers.length === 0) {
if (activeProvider || providers.length === 0) {
return
}
@@ -548,19 +545,10 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
providers.find(p => providerConfigured(p, envState)) ??
providers[0]
// Claim before enqueueing the state update. Effects can run with a stale
// activeProvider closure after a user click, so state alone is too late to
// protect that choice.
providerChoiceClaimedRef.current = true
setActiveProvider(selected.name)
}, [activeProvider, providers, envState, cfg])
async function handleSelect(provider: ToolProvider) {
if (selecting !== null) {
return
}
providerChoiceClaimedRef.current = true
setActiveProvider(provider.name)
setSelecting(provider.name)
@@ -742,7 +730,6 @@ export function ToolsetConfigPanel({ toolset, onConfiguredChange }: ToolsetConfi
'flex w-full items-center justify-between gap-3 px-3 py-2.5 text-left transition hover:bg-accent/50',
isActive && 'bg-accent/40'
)}
disabled={selecting !== null}
onClick={() => void handleSelect(provider)}
type="button"
>
@@ -1,93 +0,0 @@
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { useState } from 'react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import type { ContextBreakdown, UsageStats } from '@/types/hermes'
import { ContextUsagePanel } from './context-usage-panel'
const initialUsage: UsageStats = {
calls: 1,
context_max: 272_000,
context_percent: 47,
context_used: 128_200,
input: 0,
output: 0,
total: 0
}
const breakdown: ContextBreakdown = {
categories: [{ color: 'teal', id: 'conversation', label: 'Conversation', tokens: 241_400 }],
context_max: 272_000,
context_percent: 89,
context_used: 241_400,
estimated_total: 286_600,
model: 'test-model'
}
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
describe('ContextUsagePanel', () => {
it('publishes once without refetching when publication recreates the callback', async () => {
const requestGateway = vi.fn().mockResolvedValue(breakdown)
const published = vi.fn()
const renderedUsage: UsageStats[] = []
function Harness() {
const [currentUsage, setCurrentUsage] = useState(initialUsage)
renderedUsage.push(currentUsage)
return (
<ContextUsagePanel
currentUsage={currentUsage}
onUsageSnapshot={snapshot => {
published(snapshot)
setCurrentUsage(current => ({ ...current, ...snapshot }))
}}
requestGateway={requestGateway}
sessionId="runtime-1"
/>
)
}
render(<Harness />)
await waitFor(() => {
expect(published).toHaveBeenCalledWith({
context_max: 272_000,
context_percent: 89,
context_used: 241_400
})
expect(renderedUsage.at(-1)?.context_used).toBe(241_400)
})
await act(async () => {})
expect(requestGateway).toHaveBeenCalledTimes(1)
expect(requestGateway).toHaveBeenCalledWith('session.context_breakdown', { session_id: 'runtime-1' })
})
it('refetches when the session or gateway requester changes', async () => {
const firstGateway = vi.fn().mockResolvedValue(breakdown)
const secondGateway = vi.fn().mockResolvedValue(breakdown)
const { rerender } = render(
<ContextUsagePanel currentUsage={initialUsage} requestGateway={firstGateway} sessionId="runtime-1" />
)
await waitFor(() => expect(firstGateway).toHaveBeenCalledTimes(1))
rerender(<ContextUsagePanel currentUsage={initialUsage} requestGateway={firstGateway} sessionId="runtime-2" />)
await waitFor(() => {
expect(firstGateway).toHaveBeenCalledTimes(2)
expect(firstGateway).toHaveBeenLastCalledWith('session.context_breakdown', { session_id: 'runtime-2' })
})
rerender(<ContextUsagePanel currentUsage={initialUsage} requestGateway={secondGateway} sessionId="runtime-2" />)
await waitFor(() => expect(secondGateway).toHaveBeenCalledTimes(1))
})
})
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { useEffect, useMemo, useState } from 'react'
import { useI18n } from '@/i18n'
import { compactNumber } from '@/lib/format'
@@ -7,18 +7,15 @@ import type { ContextBreakdown, ContextUsageCategory, UsageStats } from '@/types
interface ContextUsagePanelProps {
currentUsage: UsageStats
onUsageSnapshot?: (usage: Pick<UsageStats, 'context_max' | 'context_percent' | 'context_used'>) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
sessionId: string | null
}
export function ContextUsagePanel({ currentUsage, onUsageSnapshot, requestGateway, sessionId }: ContextUsagePanelProps) {
export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: ContextUsagePanelProps) {
const { t } = useI18n()
const copy = t.shell.statusbar.contextUsagePanel
const [breakdown, setBreakdown] = useState<ContextBreakdown | null>(null)
const [loading, setLoading] = useState(false)
const onUsageSnapshotRef = useRef(onUsageSnapshot)
onUsageSnapshotRef.current = onUsageSnapshot
useEffect(() => {
if (!sessionId) {
@@ -35,11 +32,6 @@ export function ContextUsagePanel({ currentUsage, onUsageSnapshot, requestGatewa
.then(data => {
if (!cancelled) {
setBreakdown(data)
onUsageSnapshotRef.current?.({
context_max: data.context_max,
context_percent: data.context_percent,
context_used: data.context_used
})
}
})
.catch(() => {
@@ -1,5 +1,5 @@
import { useStore } from '@nanostores/react'
import { useCallback, useMemo } from 'react'
import { useMemo } from 'react'
import type { CommandCenterSection } from '@/app/command-center'
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
@@ -27,8 +27,7 @@ import {
$sessions,
$sessionStartedAt,
$turnStartedAt,
sessionMatchesStoredId,
setCurrentUsage
sessionMatchesStoredId
} from '@/store/session'
import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
@@ -41,7 +40,7 @@ import {
$updateStatus,
openUpdateOverlayFor
} from '@/store/updates'
import type { StatusResponse, UsageStats } from '@/types/hermes'
import type { StatusResponse } from '@/types/hermes'
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
import type { StatusbarItem } from '../statusbar-controls'
@@ -145,14 +144,6 @@ export function useStatusbarItems({
const contextUsage = useMemo(() => usageContextLabel(currentUsage), [currentUsage])
const contextBar = useMemo(() => contextBarLabel(currentUsage), [currentUsage])
const publishContextUsage = useCallback(
(snapshot: Pick<UsageStats, 'context_max' | 'context_percent' | 'context_used'>) => {
setCurrentUsage(current => ({ ...current, ...snapshot }))
},
[]
)
const approvalModeItem = useApprovalModeStatusbarItem(activeGatewayProfile, requestGateway)
const gatewayMenuContent = useMemo(
@@ -464,12 +455,7 @@ export function useStatusbarItems({
menuAlign: 'end',
menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0',
menuContent: (
<ContextUsagePanel
currentUsage={currentUsage}
onUsageSnapshot={publishContextUsage}
requestGateway={requestGateway}
sessionId={activeSessionId}
/>
<ContextUsagePanel currentUsage={currentUsage} requestGateway={requestGateway} sessionId={activeSessionId} />
),
title: copy.openContextUsage,
variant: 'menu'
@@ -510,7 +496,6 @@ export function useStatusbarItems({
contextUsage,
copy,
currentUsage,
publishContextUsage,
requestGateway,
sessionStartedAt,
gatewayState,
+1 -31
View File
@@ -1,7 +1,7 @@
import type * as React from 'react'
import type { ChatMessage } from '@/lib/chat-messages'
import type { SessionMessage, UsageStats } from '@/types/hermes'
import type { UsageStats } from '@/types/hermes'
export interface ContextSuggestion {
text: string
@@ -53,31 +53,6 @@ export interface BrowserManageResponse {
messages?: string[]
}
/** Response from the `session.compress` RPC. `messages` is the post-compress
* history (same shape `session.resume` returns via `_history_to_messages`),
* so the desktop can replace its transcript from it rather than leaving stale
* bubbles on screen. `summary` carries the "compressed N → M messages" line. */
export interface SessionCompressResponse {
host_ack?: {
output?: string
}
info?: {
title?: string
usage?: Partial<UsageStats>
}
messages?: SessionMessage[]
removed?: number
status?: string
summary?: {
aborted?: boolean
headline?: string
noop?: boolean
note?: null | string
token_line?: string
}
usage?: Partial<UsageStats>
}
export interface SessionSteerResponse {
// 'queued' == accepted into the live turn's steer slot (injected at the next
// tool-result boundary); 'rejected' == no live tool window, caller queues.
@@ -85,11 +60,6 @@ export interface SessionSteerResponse {
text?: string
}
export interface SessionRedirectResponse {
status?: 'redirected' | 'queued' | 'rejected'
text?: string
}
export interface SessionTitleResponse {
title?: string
// True when the session row isn't persisted yet and the title was queued
@@ -7,7 +7,7 @@ import {
useMessageRuntime
} from '@assistant-ui/react'
import { useStore } from '@nanostores/react'
import { type FC, useCallback, useMemo } from 'react'
import { type FC, useCallback, useMemo, useState } from 'react'
import {
contentHasVisibleText,
@@ -15,17 +15,23 @@ import {
pickPrimaryPreviewTarget
} from '@/components/assistant-ui/thread/content'
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
import { ResponseLoadingIndicator, StreamStallIndicator } from '@/components/assistant-ui/thread/status'
import { StreamStallIndicator } from '@/components/assistant-ui/thread/status'
import { formatMessageTimestamp } from '@/components/assistant-ui/thread/timestamp'
import { TooltipIconButton } from '@/components/assistant-ui/tooltip-icon-button'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuLabel,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, VolumeXIcon, XIcon } from '@/lib/icons'
import { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
import { extractPreviewTargets } from '@/lib/preview-targets'
import { formatAgo } from '@/lib/time'
import { useEnterAnimation } from '@/lib/use-enter-animation'
import { cn } from '@/lib/utils'
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
@@ -59,10 +65,6 @@ export const AssistantMessage: FC<{
const isRunning = messageStatus === 'running'
const isPlaceholder = useAuiState(s => s.message.status?.type === 'running' && s.message.content.length === 0)
const hasVisibleText = useAuiState(s => contentHasVisibleText(s.message.content))
// Sealed mid-turn commentary keeps its text but not the footer, so a
// tool-heavy turn doesn't grow a copy/refresh bar per paragraph (see
// ChatMessage.interim).
const isInterim = useAuiState(s => s.message.metadata?.custom?.interim === true)
// Preview targets only materialize once the turn completes — while running
// the selector returns '' (stable), so per-token flushes skip the regex
@@ -83,6 +85,10 @@ export const AssistantMessage: FC<{
const enterRef = useEnterAnimation(isRunning, `assistant-message:${messageId}`)
if (isPlaceholder) {
return null
}
return (
<MessagePrimitive.Root
className="group flex w-full min-w-0 max-w-full flex-col gap-0 self-start overflow-hidden"
@@ -97,7 +103,7 @@ export const AssistantMessage: FC<{
>
{/* Todos render in the composer status stack now, not inline. */}
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
{isPlaceholder ? <ResponseLoadingIndicator /> : isRunning && <StreamStallIndicator />}
{isRunning && <StreamStallIndicator />}
{previewTargets.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{previewTargets.map(target => (
@@ -124,7 +130,7 @@ export const AssistantMessage: FC<{
</ErrorPrimitive.Root>
</MessagePrimitive.Error>
</div>
{hasVisibleText && !isInterim && (
{hasVisibleText && (
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
)}
</MessagePrimitive.Root>
@@ -134,11 +140,12 @@ export const AssistantMessage: FC<{
const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText, onBranchInNewChat }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const [menuOpen, setMenuOpen] = useState(false)
return (
<div className="relative flex w-full shrink-0 justify-end">
<ActionBarPrimitive.Root
className={
className={cn(
// NOTE: intentionally NOT `hideWhenRunning`. That prop unmounts the
// bar while the thread streams, which collapses every completed
// assistant message's footer by this bar's height and shifts the
@@ -146,33 +153,38 @@ const AssistantActionBar: FC<MessageActionProps> = ({ messageId, getMessageText,
// invisible by default (opacity-0 + pointer-events-none, reveals on
// hover), so keeping it mounted reserves stable layout height with
// no visual change during streaming.
'relative flex flex-row items-center justify-end gap-1.5 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100'
}
'relative flex flex-row items-center justify-end gap-2 py-1.5 opacity-0 pointer-events-none group-hover:pointer-events-auto group-hover:opacity-100 focus-within:pointer-events-auto focus-within:opacity-100',
menuOpen && 'pointer-events-auto opacity-100 [&_button]:opacity-100'
)}
data-slot="aui_msg-actions"
>
<MessageAge />
<TooltipIconButton
onClick={() => {
triggerHaptic('selection')
onBranchInNewChat?.(messageId)
}}
tooltip={copy.branchNewChat}
>
<GitForkIcon className="size-3.5" />
</TooltipIconButton>
<CopyButton appearance="icon" buttonSize="icon" label={copy.copy} text={getMessageText} />
<ReadAloudButton getText={getMessageText} messageId={messageId} />
<ActionBarPrimitive.Reload asChild>
<TooltipIconButton onClick={() => triggerHaptic('submit')} tooltip={copy.refresh}>
<RefreshCwIcon className="size-3.5" />
<Codicon name="refresh" />
</TooltipIconButton>
</ActionBarPrimitive.Reload>
<DropdownMenu onOpenChange={setMenuOpen} open={menuOpen}>
<DropdownMenuTrigger asChild>
<TooltipIconButton tooltip={copy.moreActions}>
<Codicon name="ellipsis" />
</TooltipIconButton>
</DropdownMenuTrigger>
<DropdownMenuContent align="start" onCloseAutoFocus={e => e.preventDefault()} sideOffset={6}>
<MessageTimestamp />
<DropdownMenuItem onSelect={() => onBranchInNewChat?.(messageId)}>
<GitBranchIcon />
{copy.branchNewChat}
</DropdownMenuItem>
<ReadAloudItem getText={getMessageText} messageId={messageId} />
</DropdownMenuContent>
</DropdownMenu>
</ActionBarPrimitive.Root>
</div>
)
}
const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
const { t } = useI18n()
const copy = t.assistant.thread
const voicePlayback = useStore($voicePlayback)
@@ -183,8 +195,7 @@ const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ get
const isPreparing = readAloudStatus === 'preparing'
const isSpeaking = readAloudStatus === 'speaking'
const anyPlaybackActive = voicePlayback.status !== 'idle'
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : AudioLines
const tooltip = isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
const read = useCallback(async () => {
const text = getText()
@@ -201,37 +212,29 @@ const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ get
}, [copy.readAloudFailed, getText, messageId])
return (
<TooltipIconButton
<DropdownMenuItem
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
onClick={() => {
triggerHaptic('selection')
onSelect={e => {
e.preventDefault()
void (isSpeaking ? stopVoicePlayback() : read())
}}
tooltip={tooltip}
>
<Icon className={cn('size-3.5', isPreparing && 'animate-spin')} />
</TooltipIconButton>
<Icon className={isPreparing ? 'animate-spin' : undefined} />
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
</DropdownMenuItem>
)
}
const MessageAge: FC = () => {
const MessageTimestamp: FC = () => {
const { t } = useI18n()
const createdAt = useAuiState(s => s.message.createdAt)
const date = createdAt ? new Date(createdAt) : null
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
if (!date || Number.isNaN(date.getTime())) {
if (!label) {
return null
}
// Compact "2h ago" (shared util) with the absolute time on hover.
return (
<span
className="px-0.5 text-[0.6875rem] tabular-nums text-muted-foreground"
title={formatMessageTimestamp(date, t.assistant.thread) || undefined}
>
{formatAgo(date.getTime(), t.agents)}
</span>
)
return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
}
const AssistantFooter: FC<MessageActionProps> = props => (
@@ -2,7 +2,11 @@ import { type FC, useCallback, useMemo, useState } from 'react'
import { AssistantMessage } from '@/components/assistant-ui/thread/assistant-message'
import { ThreadMessageList } from '@/components/assistant-ui/thread/list'
import { BackgroundResumeNotice, CenteredThreadSpinner } from '@/components/assistant-ui/thread/status'
import {
BackgroundResumeNotice,
CenteredThreadSpinner,
ResponseLoadingIndicator
} from '@/components/assistant-ui/thread/status'
import { SystemMessage } from '@/components/assistant-ui/thread/system-message'
import { ThreadTimeline } from '@/components/assistant-ui/thread/timeline'
import { type RestoreMessageTarget } from '@/components/assistant-ui/thread/types'
@@ -96,7 +100,7 @@ export const Thread: FC<{
clampToComposer={clampToComposer}
components={messageComponents}
emptyPlaceholder={emptyPlaceholder}
loadingIndicator={<BackgroundResumeNotice />}
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : <BackgroundResumeNotice />}
sessionKey={sessionKey}
/>
{loading === 'session' && <CenteredThreadSpinner />}
@@ -73,7 +73,6 @@ export const ResponseLoadingIndicator: FC = () => {
return (
<StatusRow
className="text-[length:var(--conversation-text-font-size)] leading-(--dt-line-height)"
data-slot="aui_response-loading"
label={compacting ? COMPACTION_LABEL : t.assistant.thread.loadingResponse}
>
@@ -328,37 +328,6 @@ function MessageHarness({ message }: { message: ThreadMessage }) {
)
}
function TranscriptHarness({ messages }: { messages: ThreadMessage[] }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages,
isRunning: false,
onNew: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
function assistantInterimMessage(text: string, id = 'assistant-interim-1'): ThreadMessage {
return {
id,
role: 'assistant',
content: [{ type: 'text', text }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: { interim: true }
}
} as ThreadMessage
}
function RunningMessageHarness({ message }: { message: ThreadMessage }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [message],
@@ -486,34 +455,6 @@ describe('assistant-ui streaming renderer', () => {
expect(container.querySelector('[data-slot="aui_composer-clearance"]')).toBeNull()
})
it('suppresses the action footer on sealed interim messages, keeping it on the final reply', () => {
const { container } = render(
<TranscriptHarness
messages={[
userMessage(),
assistantInterimMessage('Let me check the files.'),
assistantInterimMessage('Now applying the patch.', 'assistant-interim-2'),
assistantMessage('All done — patch applied.', false)
]}
/>
)
// Interim commentary stays visible…
expect(container.textContent).toContain('Let me check the files.')
expect(container.textContent).toContain('Now applying the patch.')
expect(container.textContent).toContain('All done — patch applied.')
// …but only the turn's final reply carries the copy/refresh action bar.
const actionBars = container.querySelectorAll('[data-slot="aui_msg-actions"]')
expect(actionBars).toHaveLength(1)
const finalRoot = [...container.querySelectorAll('[data-slot="aui_assistant-message-root"]')].find(root =>
root.textContent?.includes('All done — patch applied.')
)
expect(finalRoot?.querySelector('[data-slot="aui_msg-actions"]')).toBeTruthy()
})
it('renders assistant provider errors inline', () => {
render(<MessageHarness message={assistantErrorMessage('OpenRouter rejected the request (403).')} />)
+2 -26
View File
@@ -268,39 +268,15 @@ function ModelPrice({ price, isCurrent }: { price?: ModelPricing; isCurrent: boo
)
}
const onSale = typeof price.discount_percent === 'number' && Boolean(price.was_input || price.was_output)
return (
<span
className={cn(
'shrink-0 inline-flex items-center gap-1.5 text-[0.66rem] tabular-nums',
'shrink-0 text-[0.66rem] tabular-nums',
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
)}
title={copy.priceTitle}
>
{onSale ? (
<span
className={cn(
'rounded-sm px-1 py-0.5 text-[0.62rem] font-semibold',
isCurrent ? 'bg-primary-foreground/20' : 'bg-amber-500/15 text-amber-700 dark:text-amber-400'
)}
>
-{price.discount_percent}%
</span>
) : null}
<span>
{price.input || '?'} / {price.output || '?'}
</span>
{onSale ? (
<span
className={cn(
'line-through decoration-from-font opacity-70',
isCurrent ? 'text-primary-foreground/60' : 'text-muted-foreground/80'
)}
>
{copy.wasPrice} {price.was_input || '?'} / {price.was_output || '?'}
</span>
) : null}
{price.input || '?'} / {price.output || '?'}
</span>
)
}
+1 -3
View File
@@ -1077,7 +1077,6 @@ export const en: Translations = {
ageSeconds: seconds => `${seconds}s ago`,
ageMinutes: minutes => `${minutes}m ago`,
ageHours: hours => `${hours}h ago`,
ageDays: days => `${days}d ago`,
durationSeconds: seconds => `${seconds}s`,
durationMinutes: (minutes, seconds) => `${minutes}m ${seconds}s`,
tokens: value => `${value} tok`
@@ -2138,8 +2137,7 @@ export const en: Translations = {
proNeedsSubscription: 'Pro models need a paid Nous subscription.',
free: 'Free',
freeTier: 'Free tier',
priceTitle: 'Input / Output price per million tokens',
wasPrice: 'was'
priceTitle: 'Input / Output price per million tokens'
},
modelVisibility: {
+1 -3
View File
@@ -1057,7 +1057,6 @@ export const ja = defineLocale({
ageSeconds: seconds => `${seconds}秒前`,
ageMinutes: minutes => `${minutes}分前`,
ageHours: hours => `${hours}時間前`,
ageDays: days => `${days}日前`,
durationSeconds: seconds => `${seconds}秒`,
durationMinutes: (minutes, seconds) => `${minutes}分 ${seconds}秒`,
tokens: value => `${value} トーク`
@@ -2074,8 +2073,7 @@ export const ja = defineLocale({
proNeedsSubscription: 'Pro モデルには有料の Nous サブスクリプションが必要です。',
free: '無料',
freeTier: '無料プラン',
priceTitle: '100 万トークンあたりの入力/出力価格',
wasPrice: '旧価格'
priceTitle: '100 万トークンあたりの入力/出力価格'
},
modelVisibility: {
-2
View File
@@ -949,7 +949,6 @@ export interface Translations {
ageSeconds: (seconds: number) => string
ageMinutes: (minutes: number) => string
ageHours: (hours: number) => string
ageDays: (days: number) => string
durationSeconds: (seconds: string) => string
durationMinutes: (minutes: number, seconds: number) => string
tokens: (value: number | string) => string
@@ -1762,7 +1761,6 @@ export interface Translations {
free: string
freeTier: string
priceTitle: string
wasPrice: string
}
modelVisibility: {
+1 -3
View File
@@ -1025,7 +1025,6 @@ export const zhHant = defineLocale({
ageSeconds: seconds => `${seconds} 秒前`,
ageMinutes: minutes => `${minutes} 分鐘前`,
ageHours: hours => `${hours} 小時前`,
ageDays: days => `${days} 天前`,
durationSeconds: seconds => `${seconds} 秒`,
durationMinutes: (minutes, seconds) => `${minutes} 分 ${seconds} 秒`,
tokens: value => `${value} 詞元`
@@ -2005,8 +2004,7 @@ export const zhHant = defineLocale({
proNeedsSubscription: 'Pro 模型需要付費 Nous 訂閱。',
free: '免費',
freeTier: '免費層',
priceTitle: '每百萬 Token 的輸入/輸出價格',
wasPrice: '原價'
priceTitle: '每百萬 Token 的輸入/輸出價格'
},
modelVisibility: {
+1 -3
View File
@@ -1272,7 +1272,6 @@ export const zh: Translations = {
ageSeconds: seconds => `${seconds} 秒前`,
ageMinutes: minutes => `${minutes} 分钟前`,
ageHours: hours => `${hours} 小时前`,
ageDays: days => `${days} 天前`,
durationSeconds: seconds => `${seconds} 秒`,
durationMinutes: (minutes, seconds) => `${minutes} 分 ${seconds} 秒`,
tokens: value => `${value} 词元`
@@ -2316,8 +2315,7 @@ export const zh: Translations = {
proNeedsSubscription: 'Pro 模型需要付费 Nous 订阅。',
free: '免费',
freeTier: '免费层',
priceTitle: '每百万 token 的输入/输出价格',
wasPrice: '原价'
priceTitle: '每百万 token 的输入/输出价格'
},
modelVisibility: {
-6
View File
@@ -17,10 +17,6 @@ export type ChatMessage = {
error?: string
branchGroupId?: string
hidden?: boolean
/** Sealed mid-turn commentary (`message.interim`) — rendered without the
* action footer so only the turn's final reply carries copy/refresh, and
* the live view matches rehydration (which merges the turn into one bubble). */
interim?: boolean
/** Composer attachment ref strings (`@file:...`, `@image:...`) sent with this user message. */
attachmentRefs?: string[]
}
@@ -80,8 +76,6 @@ export type GatewayEventPayload = {
count?: number
// status.update (kind=process → background process completion/watch-match)
kind?: string
// pane.reveal (agent focusing a desktop pane via the focus_pane tool)
pane?: string
// session.title (live auto-title push) — stored session id + generated title
session_id?: string
title?: string
+1 -2
View File
@@ -381,8 +381,7 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
unstable_annotations: [],
unstable_data: [],
steps: [],
// Carries ChatMessage.interim to AssistantMessage's footer gate.
custom: message.interim ? { interim: true } : {}
custom: {}
}
} as ThreadMessage
}
-6
View File
@@ -36,12 +36,6 @@ describe('desktop git facade', () => {
$connection.set(null)
})
it('returns undefined after the renderer global is torn down', () => {
vi.stubGlobal('window', undefined)
expect(desktopGit()).toBeUndefined()
})
it('uses Electron git locally', async () => {
$connection.set({ mode: 'local' } as never)
-4
View File
@@ -101,9 +101,5 @@ const remoteGit: GitBridge = {
}
export function desktopGit(): GitBridge | undefined {
if (typeof window === 'undefined') {
return undefined
}
return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git
}
@@ -81,66 +81,6 @@ describe('desktop slash command curation', () => {
expect(resolveDesktopCommand('/browser')?.args).toBe(true)
})
it('routes /compress through the session-compression action', () => {
// /compress must be an action (session.compress RPC), not exec: the slash
// worker route times out on large sessions (#44456).
expect(resolveDesktopCommand('/compress')?.surface).toEqual({ kind: 'action', action: 'compress' })
expect(resolveDesktopCommand('/compress')?.args).toBe(true)
expect(isDesktopSlashCommand('/compress')).toBe(true)
expect(isDesktopSlashSuggestion('/compress')).toBe(true)
expect(desktopSlashUnavailableMessage('/compress')).toBeNull()
// /compact is an alias — executes but stays out of the popover.
expect(resolveDesktopCommand('/compact')?.surface).toEqual({ kind: 'action', action: 'compress' })
expect(isDesktopSlashCommand('/compact')).toBe(true)
expect(isDesktopSlashSuggestion('/compact')).toBe(false)
})
it('routes only stateless session commands through dedicated gateway RPCs', () => {
const expected = {
'/save': 'session.save',
'/status': 'session.status'
} as const
for (const [name, rpcName] of Object.entries(expected)) {
const surface = resolveDesktopCommand(name)?.surface
expect(surface?.kind).toBe('rpc')
if (surface?.kind !== 'rpc') {
continue
}
expect(surface.rpc).toBe(rpcName)
expect(surface.buildParams({ arg: 'topic A', command: name, name: name.slice(1), sessionId: 's-1' })).toEqual({
session_id: 's-1'
})
}
})
it('keeps commands with richer CLI semantics on the slash worker', () => {
for (const name of ['/agents', '/steer', '/stop', '/usage']) {
expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' })
}
})
it('still routes commands without dedicated RPCs through exec()', () => {
const execNames = [
'/background',
'/debug',
'/goal',
'/personality',
'/queue',
'/retry',
'/rollback',
'/tools',
'/undo',
'/version'
]
for (const name of execNames) {
expect(resolveDesktopCommand(name)?.surface).toEqual({ kind: 'exec' })
}
})
it('routes /journey (and aliases) to the memory graph overlay action', () => {
expect(resolveDesktopCommand('/journey')?.surface).toEqual({ kind: 'action', action: 'journey' })
expect(resolveDesktopCommand('/memory-graph')?.surface).toEqual({ kind: 'action', action: 'journey' })

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