Compare commits
89
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f21f388697 | ||
|
|
3651627d88 | ||
|
|
c35fe293a1 | ||
|
|
7580bc66d5 | ||
|
|
a007ac55c6 | ||
|
|
e907ecccef | ||
|
|
98c0d8b291 | ||
|
|
711f1c2f1a | ||
|
|
8c745314b9 | ||
|
|
1f4eaec88a | ||
|
|
7fa795a674 | ||
|
|
50026fbaa1 | ||
|
|
24e4c6fbf6 | ||
|
|
93c97073d8 | ||
|
|
6283a33a50 | ||
|
|
433673067e | ||
|
|
8d6e045b8f | ||
|
|
55759cb273 | ||
|
|
9024835bf2 | ||
|
|
0ac07fdafd | ||
|
|
e762ea1741 | ||
|
|
54aaff142e | ||
|
|
68abf0b33d | ||
|
|
177a57f70c | ||
|
|
05b3637d8b | ||
|
|
393c100a92 | ||
|
|
83456d0bc6 | ||
|
|
93e9061f15 | ||
|
|
68e1fedd2d | ||
|
|
b135a8badd | ||
|
|
8ce18d2557 | ||
|
|
d8f30b1df7 | ||
|
|
8da98ce082 | ||
|
|
592effcb2a | ||
|
|
15dc65eeda | ||
|
|
73c7f68456 | ||
|
|
1b8e34e945 | ||
|
|
7055385379 | ||
|
|
e0d62b509e | ||
|
|
1f652214df | ||
|
|
2244be2282 | ||
|
|
d6080d4cf7 | ||
|
|
0033cffcfd | ||
|
|
2a3e3157d2 | ||
|
|
b5d82319d8 | ||
|
|
d84e11af4d | ||
|
|
0ee05d72f2 | ||
|
|
681abcc897 | ||
|
|
41e00dcf87 | ||
|
|
1bdd478efa | ||
|
|
87088d1821 | ||
|
|
afd0270a64 | ||
|
|
e0b9ab5ac5 | ||
|
|
39f72e4a5e | ||
|
|
0ec4a873c1 | ||
|
|
bb24364f25 | ||
|
|
c07973aa81 | ||
|
|
5a40fd3777 | ||
|
|
02fa447f8e | ||
|
|
9dbad81077 | ||
|
|
fea838c9f2 | ||
|
|
3459461663 | ||
|
|
2b27c171ca | ||
|
|
b591afe1af | ||
|
|
96560ee60f | ||
|
|
59614ef9af | ||
|
|
9160f10fc9 | ||
|
|
ca244e2168 | ||
|
|
4204e6d1cb | ||
|
|
70ba3c4828 | ||
|
|
3d40a1cbf2 | ||
|
|
79b047820f | ||
|
|
34d0de80e6 | ||
|
|
e4877ba96e | ||
|
|
f071f42244 | ||
|
|
f6d2ee4afc | ||
|
|
cbf5b05c70 | ||
|
|
9b1028f297 | ||
|
|
72dd01c553 | ||
|
|
bcc3396b25 | ||
|
|
163fab8d00 | ||
|
|
1efe7094ad | ||
|
|
72056faf8f | ||
|
|
76e17bc32d | ||
|
|
a4aedde3ae | ||
|
|
08ea88f4f7 | ||
|
|
e60a4ccbf4 | ||
|
|
baa3bf3915 | ||
|
|
3493a6c73c |
@@ -97,9 +97,6 @@ packaging/
|
||||
plans/
|
||||
.plans/
|
||||
|
||||
# ACP registry manifest (icon + agent.json) — not consumed at runtime
|
||||
acp_registry/
|
||||
|
||||
# Repo-level dotfiles that are git-only or dev-tooling config
|
||||
.env.example
|
||||
.envrc
|
||||
|
||||
@@ -39,6 +39,9 @@ outputs:
|
||||
ci_review:
|
||||
description: Require CI-sensitive file review label.
|
||||
value: ${{ steps.classify.outputs.ci_review }}
|
||||
ci_review_files:
|
||||
description: JSON list of CI-sensitive files changed by the pull request.
|
||||
value: ${{ steps.classify.outputs.ci_review_files }}
|
||||
|
||||
runs:
|
||||
using: composite
|
||||
|
||||
@@ -46,6 +46,7 @@ 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
|
||||
@@ -163,6 +164,7 @@ 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
|
||||
@@ -186,7 +188,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]
|
||||
needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check, e2e-desktop]
|
||||
if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 40
|
||||
|
||||
@@ -2,6 +2,10 @@ name: E2E Desktop
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
outputs:
|
||||
review_status:
|
||||
description: Screenshot and visual-diff status for the CI review comment.
|
||||
value: ${{ jobs.e2e.outputs.review_status }}
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
@@ -15,6 +19,8 @@ jobs:
|
||||
name: Playwright E2E (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
outputs:
|
||||
review_status: ${{ steps.review-status.outputs.review_status }}
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
@@ -58,7 +64,8 @@ jobs:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
# ── Build desktop app ─────────────────────────────────────────────
|
||||
- run: npm run --prefix apps/desktop build
|
||||
# The Playwright step below runs `npm run build` before testing so
|
||||
# dist/ is always fresh — no separate build step needed here.
|
||||
|
||||
# ── Restore visual baseline screenshots from main ──────────────────
|
||||
# Baselines are generated on main (via --update-snapshots) and cached.
|
||||
@@ -79,16 +86,18 @@ jobs:
|
||||
# xvfb runs at a fixed 1280x1024 screen so the 1220x800 Electron
|
||||
# window always has a consistent viewport for screenshot comparison.
|
||||
# On main, we run with --update-snapshots to generate baselines.
|
||||
# `npm run test:e2e` builds dist/ as a pretest hook so the renderer
|
||||
# is always fresh — no separate build step needed.
|
||||
- name: Run Playwright E2E tests
|
||||
working-directory: apps/desktop
|
||||
run: |
|
||||
if [ "${{ github.ref_name }}" = "main" ]; then
|
||||
echo "On main — generating/updating baseline screenshots"
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list --update-snapshots
|
||||
else
|
||||
echo "On PR — comparing against cached baselines"
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npm run build && xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list
|
||||
fi
|
||||
env:
|
||||
@@ -143,6 +152,25 @@ 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
|
||||
@@ -156,49 +184,50 @@ jobs:
|
||||
RESULTS_URL: ${{ steps.upload-results.outputs.artifact-url }}
|
||||
DIFFS_URL: ${{ steps.upload-diffs.outputs.artifact-url }}
|
||||
run: |
|
||||
echo "## Desktop E2E — Visual Diff Report" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
{
|
||||
echo "## Desktop E2E — Visual Diff Report"
|
||||
echo ""
|
||||
|
||||
# Count diff images (playwright writes *-diff.png on mismatch)
|
||||
DIFF_COUNT=$(find test-results -name '*-diff.png' 2>/dev/null | wc -l)
|
||||
ACTUAL_COUNT=$(find test-results -name '*-actual.png' 2>/dev/null | wc -l)
|
||||
|
||||
if [ "$DIFF_COUNT" -eq 0 ]; then
|
||||
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "✅ All $ACTUAL_COUNT screenshot(s) matched their baselines (or no baselines existed yet)."
|
||||
else
|
||||
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| Test | Diff | Actual | Expected |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "|------|------|--------|----------|" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "📸 **$DIFF_COUNT of $ACTUAL_COUNT screenshot(s) differ from baseline:**"
|
||||
echo ""
|
||||
echo "| Test | Diff | Actual | Expected |"
|
||||
echo "|------|------|--------|----------|"
|
||||
|
||||
# List each diff image with a link to the artifact
|
||||
for diff in $(find test-results -name '*-diff.png' 2>/dev/null | sort); do
|
||||
base=$(echo "$diff" | sed 's/-diff\.png$//')
|
||||
base=${diff%-diff.png}
|
||||
test_name=$(basename "$base")
|
||||
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo ""
|
||||
echo "📥 **Artifacts:**"
|
||||
echo ""
|
||||
if [ -n "$RESULTS_URL" ]; then
|
||||
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces"
|
||||
fi
|
||||
if [ -n "$REPORT_URL" ]; then
|
||||
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- [playwright-report]($REPORT_URL) — interactive HTML report"
|
||||
fi
|
||||
if [ -n "$DIFFS_URL" ]; then
|
||||
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)"
|
||||
fi
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally." >> "$GITHUB_STEP_SUMMARY"
|
||||
echo ""
|
||||
echo "**To update baselines:** merge to main (baselines auto-update on main runs) or run \`npx playwright test --update-snapshots\` locally."
|
||||
|
||||
# Also parse the JSON report for pass/fail counts
|
||||
if [ -f playwright-report/results.json ]; then
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "### Test Results" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo ""
|
||||
echo "### Test Results"
|
||||
echo ""
|
||||
node -e "
|
||||
const r = require('./playwright-report/results.json');
|
||||
const stats = r.stats || {};
|
||||
@@ -208,5 +237,6 @@ jobs:
|
||||
console.log('| ❌ Failed | ' + (stats.unexpected || 0) + ' |');
|
||||
console.log('| ⏭️ Skipped | ' + (stats.skipped || 0) + ' |');
|
||||
console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |');
|
||||
" >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true
|
||||
" 2>/dev/null || true
|
||||
fi
|
||||
} >> "$GITHUB_STEP_SUMMARY"
|
||||
|
||||
@@ -48,6 +48,9 @@ jobs:
|
||||
--lockfile=uv.lock
|
||||
--lockfile=package-lock.json
|
||||
--lockfile=website/package-lock.json
|
||||
# The upstream reusable workflow uploads this exact file under its
|
||||
# fixed artifact name, which the wrapper downloads below.
|
||||
results-file-name: osv-results.sarif
|
||||
fail-on-vuln: false
|
||||
|
||||
emit-status:
|
||||
@@ -64,7 +67,7 @@ jobs:
|
||||
- name: Download SARIF result
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: osv-results
|
||||
name: OSV Scanner SARIF file
|
||||
path: /tmp/osv-results
|
||||
continue-on-error: true
|
||||
|
||||
|
||||
@@ -23,6 +23,10 @@ on:
|
||||
description: Whether CI-sensitive files (eslint config, workflows, actions) changed.
|
||||
type: boolean
|
||||
default: false
|
||||
ci_review_files:
|
||||
description: JSON list of CI-sensitive files changed by the pull request.
|
||||
type: string
|
||||
default: '[]'
|
||||
mcp_catalog:
|
||||
description: Whether the MCP catalog / installer changed.
|
||||
type: boolean
|
||||
@@ -78,18 +82,25 @@ jobs:
|
||||
id: build-status
|
||||
env:
|
||||
CI_REVIEW: ${{ inputs.ci_review }}
|
||||
CI_REVIEW_FILES: ${{ inputs.ci_review_files }}
|
||||
MCP_CATALOG: ${{ inputs.mcp_catalog }}
|
||||
SUPPLY_CHAIN: ${{ inputs.supply_chain }}
|
||||
LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }}
|
||||
REPO_URL: ${{ github.server_url }}/${{ github.repository }}
|
||||
BASE_SHA: ${{ github.event.pull_request.base.sha }}
|
||||
HEAD_SHA: ${{ github.event.pull_request.head.sha }}
|
||||
run: |
|
||||
set -euo pipefail
|
||||
args=()
|
||||
if [ "$CI_REVIEW" = "true" ]; then args+=(--ci-review); fi
|
||||
args+=(--ci-review-files "$CI_REVIEW_FILES")
|
||||
if [ "$MCP_CATALOG" = "true" ]; then args+=(--mcp-catalog); fi
|
||||
if [ "$SUPPLY_CHAIN" = "true" ]; then args+=(--supply-chain); fi
|
||||
if [ "$LABEL_PRESENT" = "true" ]; then args+=(--label-present); fi
|
||||
|
||||
python3 scripts/ci/emit_review_status.py "${args[@]}" --output "$GITHUB_OUTPUT"
|
||||
python3 scripts/ci/emit_review_status.py "${args[@]}" \
|
||||
--repo-url "$REPO_URL" --base-sha "$BASE_SHA" --head-sha "$HEAD_SHA" \
|
||||
--output "$GITHUB_OUTPUT"
|
||||
|
||||
- name: Fail on missing label
|
||||
if: steps.label-check.outputs.ci_reviewed != 'true'
|
||||
|
||||
@@ -76,6 +76,7 @@ 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
|
||||
|
||||
@@ -93,7 +94,7 @@ jobs:
|
||||
# --- .pth files (auto-execute on Python startup) ---
|
||||
# The exact mechanism used in the litellm supply chain attack:
|
||||
# https://github.com/BerriAI/litellm/issues/24512
|
||||
PTH_FILES=$(git diff --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
|
||||
PTH_FILES=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep '\.pth$' || true)
|
||||
if [ -n "$PTH_FILES" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: .pth file added or modified
|
||||
@@ -141,8 +142,11 @@ jobs:
|
||||
# auto-loaded by the interpreter via site.py. Any nested file with the
|
||||
# same name (e.g. hermes_cli/setup.py — the CLI setup wizard) is unrelated
|
||||
# and produced false positives that trained reviewers to ignore the scanner.
|
||||
SETUP_HITS=$(git diff --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
if [ -n "$SETUP_HITS" ]; then
|
||||
SETUP_HITS=$(git diff --diff-filter=d --name-only "$BASE"..."$HEAD" | grep -E '^(setup\.py|setup\.cfg|sitecustomize\.py|usercustomize\.py|__init__\.pth)$' || true)
|
||||
# A maintainer-applied ci-reviewed label records the manual review
|
||||
# required for intentional changes to an install hook. The scanner
|
||||
# still blocks every unreviewed addition or modification.
|
||||
if [ -n "$SETUP_HITS" ] && [ "$CI_REVIEWED" != "true" ]; then
|
||||
FINDINGS="${FINDINGS}
|
||||
### 🚨 CRITICAL: Install-hook file added or modified
|
||||
These files can execute code during package installation or interpreter startup.
|
||||
|
||||
@@ -215,11 +215,6 @@ jobs:
|
||||
# re-download, keeping the persisted cache small and fast to restore.
|
||||
run: uv cache prune --ci
|
||||
|
||||
- name: Packaged-wheel i18n smoke test
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
python -m pytest -m integration tests/test_wheel_locales_e2e.py -v
|
||||
|
||||
- name: Run e2e tests
|
||||
run: |
|
||||
source .venv/bin/activate
|
||||
|
||||
@@ -1,188 +0,0 @@
|
||||
name: Publish to PyPI
|
||||
|
||||
# Triggered by CalVer tag pushes from scripts/release.py (e.g. v2026.5.15)
|
||||
# Can also be triggered manually from the Actions tab as an escape hatch.
|
||||
on:
|
||||
push:
|
||||
tags:
|
||||
- "v20*" # CalVer tags: v2026.5.15, v2026.5.15.2, etc.
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
confirm_tag:
|
||||
description: "Tag to publish (e.g. v2026.5.15). Must already exist."
|
||||
required: true
|
||||
type: string
|
||||
|
||||
# Restrict default token to read-only; each job escalates as needed.
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
# Prevent overlapping publishes (e.g. two same-day tags pushed quickly).
|
||||
concurrency:
|
||||
group: pypi-publish
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: Build distribution 📦
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
with:
|
||||
persist-credentials: false
|
||||
# On workflow_dispatch, check out the confirmed tag.
|
||||
ref: ${{ inputs.confirm_tag || github.ref }}
|
||||
fetch-tags: true
|
||||
|
||||
- name: Validate tag exists
|
||||
if: github.event_name == 'workflow_dispatch'
|
||||
run: |
|
||||
if ! git tag -l "${{ inputs.confirm_tag }}" | grep -q .; then
|
||||
echo "::error::Tag '${{ inputs.confirm_tag }}' does not exist in the repo"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
|
||||
- name: Set up Node.js
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: "22"
|
||||
|
||||
- name: Build web dashboard
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
working-directory: web
|
||||
|
||||
- name: Compile web dashboard
|
||||
run: npm run build
|
||||
working-directory: web
|
||||
|
||||
- name: Build TUI bundle
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
working-directory: ui-tui
|
||||
|
||||
- name: Compile TUI bundle
|
||||
run: npm run build
|
||||
working-directory: ui-tui
|
||||
|
||||
- name: Bundle TUI into hermes_cli
|
||||
run: |
|
||||
mkdir -p hermes_cli/tui_dist
|
||||
cp ui-tui/dist/entry.js hermes_cli/tui_dist/entry.js
|
||||
|
||||
- name: Verify frontend assets exist
|
||||
run: |
|
||||
test -f hermes_cli/web_dist/index.html || { echo "ERROR: web_dist not built"; exit 1; }
|
||||
test -f hermes_cli/tui_dist/entry.js || { echo "ERROR: tui_dist not built"; exit 1; }
|
||||
|
||||
- name: Bundle install scripts into wheel
|
||||
run: |
|
||||
mkdir -p hermes_cli/scripts
|
||||
cp scripts/install.sh hermes_cli/scripts/install.sh
|
||||
cp scripts/install.ps1 hermes_cli/scripts/install.ps1
|
||||
|
||||
- name: Build wheel and sdist
|
||||
run: uv build --sdist --wheel
|
||||
|
||||
- name: Upload distribution artifacts
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
publish:
|
||||
name: Publish to PyPI
|
||||
needs: build
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
environment:
|
||||
name: pypi
|
||||
url: https://pypi.org/p/hermes-agent
|
||||
permissions:
|
||||
id-token: write # OIDC trusted publishing
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Publish to PyPI
|
||||
uses: pypa/gh-action-pypi-publish@cef221092ed1bacb1cc03d23a2d87d1d172e277b # v1.14.0
|
||||
with:
|
||||
skip-existing: true
|
||||
|
||||
sign:
|
||||
name: Sign and attach to GitHub Release
|
||||
# Only runs on tag pushes — release.py creates the GitHub Release,
|
||||
# and workflow_dispatch won't have a matching release to attach to.
|
||||
if: startsWith(github.ref, 'refs/tags/')
|
||||
needs: publish
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 30
|
||||
permissions:
|
||||
contents: write # attach assets to the existing release
|
||||
id-token: write # sigstore signing
|
||||
|
||||
steps:
|
||||
- name: Download distribution artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
with:
|
||||
name: python-package-distributions
|
||||
path: dist/
|
||||
|
||||
- name: Get GitHub App token
|
||||
id: app-token
|
||||
uses: ./.github/actions/get-app-token
|
||||
with:
|
||||
client-id: ${{ secrets.APP_CLIENT_ID }}
|
||||
private-key: ${{ secrets.APP_PRIVATE_KEY }}
|
||||
|
||||
- name: Wait for GitHub Release to exist
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
# release.py creates the GitHub Release after pushing the tag,
|
||||
# but this workflow starts from the tag push — wait for it.
|
||||
run: |
|
||||
for i in $(seq 1 30); do
|
||||
if gh release view "$GITHUB_REF_NAME" --repo "$GITHUB_REPOSITORY" >/dev/null 2>&1; then
|
||||
echo "Release $GITHUB_REF_NAME found"
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for release... ($i/30)"
|
||||
sleep 10
|
||||
done
|
||||
echo "::warning::Release $GITHUB_REF_NAME not found after 5 minutes — skipping signature upload"
|
||||
echo "skip_sign=true" >> "$GITHUB_ENV"
|
||||
|
||||
- name: Sign with Sigstore
|
||||
if: env.skip_sign != 'true'
|
||||
uses: sigstore/gh-action-sigstore-python@04cffa1d795717b140764e8b640de88853c92acc # v3.3.0
|
||||
with:
|
||||
inputs: >-
|
||||
./dist/*.tar.gz
|
||||
./dist/*.whl
|
||||
|
||||
- name: Attach signed artifacts to GitHub Release
|
||||
if: env.skip_sign != 'true'
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ steps.app-token.outputs.token }}
|
||||
# release.py already created the GitHub Release — just upload
|
||||
# the Sigstore signatures alongside the existing assets.
|
||||
run: >-
|
||||
gh release upload
|
||||
"$GITHUB_REF_NAME" dist/*.sigstore.json
|
||||
--repo "$GITHUB_REPOSITORY"
|
||||
--clobber
|
||||
@@ -150,6 +150,11 @@ docs/superpowers/*
|
||||
.update-incomplete
|
||||
.update-incomplete.lock
|
||||
|
||||
# Installer-written method stamp in the managed checkout root (scripts/install.sh).
|
||||
# Runtime metadata only — never a code change. Ignore so `git status` stays clean
|
||||
# and `hermes update`'s untracked autostash does not treat it as a local edit (#66189 / #54855).
|
||||
/.install_method
|
||||
|
||||
# Tool Search live-test harness output — non-deterministic model transcripts,
|
||||
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
|
||||
scripts/out/
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
graft skills
|
||||
graft optional-skills
|
||||
graft optional-mcps
|
||||
graft hermes_cli/web_dist
|
||||
graft locales
|
||||
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
|
||||
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
|
||||
# built from the sdist (e.g. Homebrew, downstream packagers). package-data
|
||||
# below covers the wheel; this covers the sdist. See #34034 / #28149.
|
||||
recursive-include plugins plugin.yaml plugin.yml
|
||||
# Gateway assets include images plus YAML catalogs such as status_phrases.yaml.
|
||||
recursive-include gateway/assets *
|
||||
global-exclude __pycache__
|
||||
global-exclude *.py[cod]
|
||||
@@ -190,7 +190,7 @@ def _run_setup_browser(assume_yes: bool = False) -> int:
|
||||
"""Bootstrap agent-browser + Chromium.
|
||||
|
||||
Routes through dep_ensure -> install.{sh,ps1} --ensure, sharing code
|
||||
with ``hermes postinstall`` and the runtime lazy installer.
|
||||
with the runtime lazy installer.
|
||||
|
||||
Returns 0 on success, 1 on failure.
|
||||
"""
|
||||
|
||||
+140
-25
@@ -74,6 +74,10 @@ from acp_adapter.permissions import make_approval_callback
|
||||
from acp_adapter.provenance import session_provenance_meta
|
||||
from acp_adapter.session import SessionManager, SessionState, _expand_acp_enabled_toolsets
|
||||
from acp_adapter.tools import build_tool_complete, build_tool_start
|
||||
from agent.context_compressor import (
|
||||
COMPRESSED_SUMMARY_METADATA_KEY,
|
||||
ContextCompressor,
|
||||
)
|
||||
from tools.approval import (
|
||||
reset_hermes_interactive_context,
|
||||
set_hermes_interactive_context,
|
||||
@@ -969,11 +973,49 @@ class HermesACPAgent(acp.Agent):
|
||||
return text
|
||||
return ""
|
||||
|
||||
@staticmethod
|
||||
def _history_summary_meta(message: dict[str, Any], text: str) -> dict[str, Any] | None:
|
||||
"""Build the ``_meta`` payload for a replayed compaction summary.
|
||||
|
||||
Compaction summaries are persisted as ordinary history messages —
|
||||
standalone handoffs under ``role="user"`` OR ``role="assistant"``
|
||||
(the compressor picks whichever role keeps alternation valid), and
|
||||
merge-into-tail messages where the summary is appended after the
|
||||
first preserved tail message's real content. Without a wire flag,
|
||||
ACP frontends render all of these as ordinary turns.
|
||||
|
||||
Two distinct keys under ``_meta.hermes`` (ACP's extensibility
|
||||
channel), so clients cannot accidentally hide real content:
|
||||
|
||||
* ``compactionSummary: true`` — the entire chunk is the handoff
|
||||
summary. Safe to restyle or collapse wholesale.
|
||||
* ``containsCompactionSummary: true`` — a merged-tail message: real
|
||||
preserved turn content followed by the summary. Clients may style
|
||||
it, but collapsing the whole chunk would hide the preserved
|
||||
content, hence the separate key.
|
||||
|
||||
Detection honors the in-process ``_compressed_summary`` flag and
|
||||
falls back to content classification, so it also works for a
|
||||
DB-reloaded session that lost the in-memory flag.
|
||||
"""
|
||||
kind = ContextCompressor.classify_summary_content(text)
|
||||
if kind is None and message.get(COMPRESSED_SUMMARY_METADATA_KEY):
|
||||
# Flagged in-process but content didn't classify (e.g. future
|
||||
# prefix drift): treat as a standalone summary — the flag is only
|
||||
# ever set on summary-bearing messages.
|
||||
kind = "standalone"
|
||||
if kind == "standalone":
|
||||
return {"hermes": {"compactionSummary": True}}
|
||||
if kind == "merged":
|
||||
return {"hermes": {"containsCompactionSummary": True}}
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _history_message_update(
|
||||
*,
|
||||
role: str,
|
||||
text: str,
|
||||
field_meta: dict[str, Any] | None = None,
|
||||
) -> UserMessageChunk | AgentMessageChunk | None:
|
||||
"""Build an ACP history replay update for a user/assistant message."""
|
||||
block = TextContentBlock(type="text", text=text)
|
||||
@@ -981,11 +1023,13 @@ class HermesACPAgent(acp.Agent):
|
||||
return UserMessageChunk(
|
||||
session_update="user_message_chunk",
|
||||
content=block,
|
||||
field_meta=field_meta,
|
||||
)
|
||||
if role == "assistant":
|
||||
return AgentMessageChunk(
|
||||
session_update="agent_message_chunk",
|
||||
content=block,
|
||||
field_meta=field_meta,
|
||||
)
|
||||
return None
|
||||
|
||||
@@ -1056,7 +1100,11 @@ class HermesACPAgent(acp.Agent):
|
||||
if role == "user":
|
||||
text = self._history_message_text(message)
|
||||
if text:
|
||||
update = self._history_message_update(role=role, text=text)
|
||||
update = self._history_message_update(
|
||||
role=role,
|
||||
text=text,
|
||||
field_meta=self._history_summary_meta(message, text),
|
||||
)
|
||||
if update is not None and not await _send(update):
|
||||
return
|
||||
continue
|
||||
@@ -1068,7 +1116,11 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
text = self._history_message_text(message)
|
||||
if text:
|
||||
update = self._history_message_update(role=role, text=text)
|
||||
update = self._history_message_update(
|
||||
role=role,
|
||||
text=text,
|
||||
field_meta=self._history_summary_meta(message, text),
|
||||
)
|
||||
if update is not None and not await _send(update):
|
||||
return
|
||||
|
||||
@@ -1218,12 +1270,19 @@ class HermesACPAgent(acp.Agent):
|
||||
with state.runtime_lock:
|
||||
if state.is_running and state.current_prompt_text:
|
||||
state.interrupted_prompt_text = state.current_prompt_text
|
||||
state.cancel_event.set()
|
||||
try:
|
||||
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
|
||||
state.agent.interrupt()
|
||||
except Exception:
|
||||
logger.debug("Failed to interrupt ACP session %s", session_id, exc_info=True)
|
||||
# Publish cancellation and hard-stop the agent before another
|
||||
# prompt can acquire this lock and mistake the turn for
|
||||
# redirectable work.
|
||||
state.cancel_event.set()
|
||||
try:
|
||||
if getattr(state, "agent", None) and hasattr(state.agent, "interrupt"):
|
||||
state.agent.interrupt()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to interrupt ACP session %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
logger.info("Cancelled session %s", session_id)
|
||||
|
||||
async def fork_session(
|
||||
@@ -1352,6 +1411,26 @@ class HermesACPAgent(acp.Agent):
|
||||
elif rewrite_idle:
|
||||
user_text = steer_text
|
||||
user_content = steer_text
|
||||
elif (
|
||||
text_only_prompt
|
||||
and isinstance(user_content, str)
|
||||
and not user_text.startswith("/")
|
||||
):
|
||||
# Some ACP clients implement "stop and send" as two protocol calls:
|
||||
# cancel the active prompt, then submit plain correction text. Keep
|
||||
# the cancelled request attached so deictic follow-ups ("not that
|
||||
# file") still have an explicit target.
|
||||
interrupted_prompt = ""
|
||||
with state.runtime_lock:
|
||||
if not state.is_running and state.interrupted_prompt_text:
|
||||
interrupted_prompt = state.interrupted_prompt_text
|
||||
state.interrupted_prompt_text = ""
|
||||
if interrupted_prompt:
|
||||
user_text = (
|
||||
f"{interrupted_prompt}\n\n"
|
||||
f"User correction/guidance after interrupt: {user_text}"
|
||||
)
|
||||
user_content = user_text
|
||||
|
||||
# Intercept slash commands — handle locally without calling the LLM.
|
||||
# Slash commands are text-only; if the client included images/resources,
|
||||
@@ -1366,23 +1445,54 @@ class HermesACPAgent(acp.Agent):
|
||||
await self._send_usage_update(state)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
# If Zed sends another regular prompt while the same ACP session is
|
||||
# still running, queue it instead of racing two AIAgent loops against
|
||||
# the same state.history. /steer and /queue are handled above and can
|
||||
# land immediately.
|
||||
# If the client sends another regular text prompt while this ACP session
|
||||
# is running, route it through the core active-turn redirect. Rich media
|
||||
# and older runtimes retain the proven next-turn queue fallback.
|
||||
redirected = False
|
||||
queued_depth: int | None = None
|
||||
with state.runtime_lock:
|
||||
if state.is_running:
|
||||
queued_text = user_text or "[Image attachment]"
|
||||
state.queued_prompts.append(queued_text)
|
||||
depth = len(state.queued_prompts)
|
||||
if self._conn:
|
||||
update = acp.update_agent_message_text(
|
||||
f"Queued for the next turn. ({depth} queued)"
|
||||
if (
|
||||
text_only_prompt
|
||||
and isinstance(user_content, str)
|
||||
and getattr(
|
||||
state.agent,
|
||||
"_supports_active_turn_redirect",
|
||||
False,
|
||||
)
|
||||
await self._conn.session_update(session_id, update)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
state.is_running = True
|
||||
state.current_prompt_text = user_text or "[Image attachment]"
|
||||
is True
|
||||
and hasattr(state.agent, "redirect")
|
||||
):
|
||||
try:
|
||||
redirected = bool(state.agent.redirect(user_content))
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"ACP active-turn redirect failed for %s",
|
||||
session_id,
|
||||
exc_info=True,
|
||||
)
|
||||
if not redirected:
|
||||
queued_text = user_text or "[Image attachment]"
|
||||
state.queued_prompts.append(queued_text)
|
||||
queued_depth = len(state.queued_prompts)
|
||||
else:
|
||||
state.is_running = True
|
||||
state.current_prompt_text = user_text or "[Image attachment]"
|
||||
|
||||
if redirected:
|
||||
if self._conn:
|
||||
update = acp.update_agent_message_text(
|
||||
"Redirected the active turn with your correction."
|
||||
)
|
||||
await self._conn.session_update(session_id, update)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
if queued_depth is not None:
|
||||
if self._conn:
|
||||
update = acp.update_agent_message_text(
|
||||
f"Queued for the next turn. ({queued_depth} queued)"
|
||||
)
|
||||
await self._conn.session_update(session_id, update)
|
||||
return PromptResponse(stop_reason="end_turn")
|
||||
|
||||
logger.info("Prompt on session %s: %s", session_id, user_text[:100])
|
||||
|
||||
@@ -1911,7 +2021,10 @@ class HermesACPAgent(acp.Agent):
|
||||
lines.append(f"Compression threshold: ~{threshold_tokens:,} tokens")
|
||||
|
||||
if getattr(agent, "compression_enabled", True) is False:
|
||||
lines.append("Compression is disabled for this agent.")
|
||||
lines.append(
|
||||
"Auto-compaction is disabled (compression.enabled: false); "
|
||||
"/compress still compresses manually."
|
||||
)
|
||||
else:
|
||||
lines.append("Tip: run /compress to compress manually before the threshold.")
|
||||
|
||||
@@ -1938,8 +2051,9 @@ class HermesACPAgent(acp.Agent):
|
||||
return "Nothing to compress — conversation is empty."
|
||||
try:
|
||||
agent = state.agent
|
||||
if not getattr(agent, "compression_enabled", True):
|
||||
return "Context compression is disabled for this agent."
|
||||
# No compression_enabled gate: the flag disables *automatic*
|
||||
# compaction only; manual /compress must keep working (matches
|
||||
# the CLI /compress and gateway handlers).
|
||||
if not hasattr(agent, "_compress_context"):
|
||||
return "Context compression not available for this agent."
|
||||
|
||||
@@ -1964,6 +2078,7 @@ class HermesACPAgent(acp.Agent):
|
||||
getattr(agent, "_cached_system_prompt", "") or "",
|
||||
approx_tokens=approx_tokens,
|
||||
task_id=state.session_id,
|
||||
force=True,
|
||||
)
|
||||
finally:
|
||||
agent._session_db = original_session_db
|
||||
|
||||
@@ -1,16 +0,0 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.19.0",
|
||||
"description": "Self-improving open-source AI agent by Nous Research with ACP editor integration, persistent memory, skills, and rich tool support.",
|
||||
"repository": "https://github.com/NousResearch/hermes-agent",
|
||||
"website": "https://hermes-agent.nousresearch.com/docs/user-guide/features/acp",
|
||||
"authors": ["Nous Research"],
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.19.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16" fill="none">
|
||||
<path d="M8 1.5v13" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
|
||||
<path d="M8 3.25c-2.35-1.4-4.7-.95-6.25.35 1.85-.2 3.8.2 5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 3.25c2.35-1.4 4.7-.95 6.25.35-1.85-.2-3.8.2-5.55 1.55" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c-2.3-1-3.05-2.65-1.35-4.15-2 .8-2.35 2.95-.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<path d="M8 13.25c2.3-1 3.05-2.65 1.35-4.15 2 .8 2.35 2.95.35 4" stroke="currentColor" stroke-width="1.1" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
<circle cx="8" cy="1.8" r="1.1" fill="currentColor"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 882 B |
@@ -701,6 +701,18 @@ def redeem_codex_reset_credit(
|
||||
remaining = max(0, available - 1)
|
||||
plural = "s" if remaining != 1 else ""
|
||||
if code == "reset":
|
||||
# The redeemed reset restores the account's quota upstream — lift any
|
||||
# persisted pool cooldowns so Hermes doesn't keep the credential
|
||||
# frozen behind the now-stale ``last_error_reset_at`` (issue #43747).
|
||||
try:
|
||||
from hermes_cli.auth import clear_codex_pool_quota_cooldowns
|
||||
|
||||
clear_codex_pool_quota_cooldowns()
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to clear Codex pool cooldowns after reset redemption",
|
||||
exc_info=True,
|
||||
)
|
||||
return CodexResetRedeemResult(
|
||||
status="reset",
|
||||
message=(
|
||||
|
||||
@@ -720,6 +720,8 @@ def init_agent(
|
||||
agent._execution_thread_id: int | None = None # Set at run_conversation() start
|
||||
agent._interrupt_thread_signal_pending = False
|
||||
agent._client_lock = threading.RLock()
|
||||
agent._model_request_active = threading.Event()
|
||||
agent._supports_active_turn_redirect = True
|
||||
|
||||
# /steer mechanism — inject a user note into the next tool result
|
||||
# without interrupting the agent. Unlike interrupt(), steer() does
|
||||
@@ -731,6 +733,13 @@ def init_agent(
|
||||
agent._pending_steer: Optional[str] = None
|
||||
agent._pending_steer_lock = threading.Lock()
|
||||
|
||||
# Active-turn redirect mechanism. A regular follow-up sent while the model
|
||||
# is generating is different from a hard /stop: preserve the valid turn
|
||||
# prefix, cancel only the in-flight model request, and rebuild its tail with
|
||||
# the correction. The loop drains this slot at a role-safe boundary.
|
||||
agent._pending_redirect: Optional[str] = None
|
||||
agent._pending_redirect_lock = threading.Lock()
|
||||
|
||||
# Concurrent-tool worker thread tracking. `_execute_tool_calls_concurrent`
|
||||
# runs each tool on its own ThreadPoolExecutor worker — those worker
|
||||
# threads have tids distinct from `_execution_thread_id`, so
|
||||
@@ -897,6 +906,12 @@ def init_agent(
|
||||
agent._stream_writer_tls = threading.local()
|
||||
agent._stream_writer_dropped = 0
|
||||
|
||||
# Displayed reasoning text streamed during the current model response,
|
||||
# captured only when a surface consumed it via a reasoning callback. Used
|
||||
# by active-turn redirect to checkpoint what the user actually saw without
|
||||
# ever persisting hidden provider reasoning.
|
||||
agent._current_streamed_reasoning_text = ""
|
||||
|
||||
# Optional current-turn user-message override used when the API-facing
|
||||
# user message intentionally differs from the persisted transcript
|
||||
# (e.g. CLI voice mode adds a temporary prefix for the live call only).
|
||||
@@ -1869,6 +1884,12 @@ def init_agent(
|
||||
codex_app_server_auto_compaction,
|
||||
)
|
||||
codex_app_server_auto_compaction = "native"
|
||||
# Opt-in idle compaction: compact a session up front when it resumes after
|
||||
# this many seconds of inactivity (0 = disabled). Time-based, so it
|
||||
# complements the size-based threshold above. Consumed by build_turn_context().
|
||||
compression_idle_compact_after_seconds = max(
|
||||
0, int(_compression_cfg.get("idle_compact_after_seconds", 0))
|
||||
)
|
||||
|
||||
# Read optional explicit context_length override for the auxiliary
|
||||
# compression model. Custom endpoints often cannot report this via
|
||||
@@ -2295,6 +2316,9 @@ def init_agent(
|
||||
agent.compression_in_place = compression_in_place
|
||||
agent.codex_app_server_auto_compaction = codex_app_server_auto_compaction
|
||||
agent.max_compression_attempts = compression_max_attempts
|
||||
agent.compression_idle_compact_after_seconds = (
|
||||
compression_idle_compact_after_seconds
|
||||
)
|
||||
|
||||
# Reject models whose context window is below the minimum required
|
||||
# for reliable tool-calling workflows (64K tokens).
|
||||
|
||||
@@ -702,6 +702,16 @@ def run_codex_app_server_turn(
|
||||
except Exception:
|
||||
pass
|
||||
agent._codex_session = None
|
||||
_user_interrupted = bool(
|
||||
getattr(agent, "_interrupt_requested", False)
|
||||
)
|
||||
_interrupt_message = (
|
||||
getattr(agent, "_interrupt_message", None)
|
||||
if _user_interrupted
|
||||
else None
|
||||
)
|
||||
if _user_interrupted:
|
||||
agent.clear_interrupt()
|
||||
return {
|
||||
"final_response": (
|
||||
f"Codex app-server turn failed: {exc}. "
|
||||
@@ -711,9 +721,27 @@ def run_codex_app_server_turn(
|
||||
"api_calls": 0,
|
||||
"completed": False,
|
||||
"partial": True,
|
||||
"interrupted": _user_interrupted,
|
||||
**(
|
||||
{"interrupt_message": _interrupt_message}
|
||||
if _interrupt_message
|
||||
else {}
|
||||
),
|
||||
"error": str(exc),
|
||||
}
|
||||
|
||||
# This runtime bypasses the normal conversation-loop finalizer. Mirror its
|
||||
# interrupt handoff/cleanup so a hard stop cannot poison the next turn and a
|
||||
# message-bearing compatibility interrupt can still be replayed by callers.
|
||||
_user_interrupted = bool(
|
||||
turn.interrupted and getattr(agent, "_interrupt_requested", False)
|
||||
)
|
||||
_interrupt_message = (
|
||||
getattr(agent, "_interrupt_message", None) if _user_interrupted else None
|
||||
)
|
||||
if _user_interrupted:
|
||||
agent.clear_interrupt()
|
||||
|
||||
# If the turn signalled the underlying client is wedged (deadline
|
||||
# blown, post-tool watchdog tripped, OAuth refresh died, subprocess
|
||||
# exited), retire the session so the next turn respawns codex
|
||||
@@ -819,6 +847,12 @@ def run_codex_app_server_turn(
|
||||
"api_calls": api_calls,
|
||||
"completed": not turn.interrupted and turn.error is None,
|
||||
"partial": turn.interrupted or turn.error is not None,
|
||||
"interrupted": _user_interrupted,
|
||||
**(
|
||||
{"interrupt_message": _interrupt_message}
|
||||
if _interrupt_message
|
||||
else {}
|
||||
),
|
||||
"error": turn.error,
|
||||
# The codex app-server runtime IS an early-return path that bypasses
|
||||
# conversation_loop, but we flush the projected assistant/tool messages
|
||||
|
||||
+245
-57
@@ -288,6 +288,12 @@ _HISTORICAL_SUMMARY_PREFIXES = (
|
||||
"config, etc.) may reflect work described here — avoid repeating it:",
|
||||
)
|
||||
|
||||
# Restart handoff detection should be early and bounded: it needs to catch the
|
||||
# restored protected head plus a small cluster of already-stacked handoff/ack
|
||||
# turns, but it must not treat arbitrary summary-looking live-tail messages as
|
||||
# proof that this is a resumed compacted session.
|
||||
_RESTART_HANDOFF_PROBE_EXTRA_MESSAGES = 4
|
||||
|
||||
# Minimum tokens for the summary output
|
||||
_MIN_SUMMARY_TOKENS = 2000
|
||||
# Proportion of compressed content to allocate for summary
|
||||
@@ -318,6 +324,7 @@ _SUMMARY_FAILURE_COOLDOWN_SECONDS = 600
|
||||
# only meant to preserve continuity anchors from the dropped window, not to
|
||||
# become another unbounded transcript copy after the LLM summarizer failed.
|
||||
_FALLBACK_SUMMARY_MAX_CHARS = 8_000
|
||||
_FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS = 3_000
|
||||
_FALLBACK_TURN_MAX_CHARS = 700
|
||||
_AUTO_FOCUS_MAX_TURNS = 3
|
||||
_AUTO_FOCUS_TURN_MAX_CHARS = 260
|
||||
@@ -2305,9 +2312,17 @@ class ContextCompressor(ContextEngine):
|
||||
)
|
||||
previous_summary_note = ""
|
||||
if self._previous_summary:
|
||||
previous_summary = redact_sensitive_text(self._previous_summary.strip())
|
||||
if len(previous_summary) > _FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS:
|
||||
previous_summary = (
|
||||
previous_summary[: _FALLBACK_PREVIOUS_SUMMARY_MAX_CHARS - 45].rstrip()
|
||||
+ "\n...[previous summary snapshot truncated]"
|
||||
)
|
||||
previous_summary_note = (
|
||||
"\n\nPrevious compaction summary was present and should still be treated as "
|
||||
"background continuity context, but the latest LLM summary update failed."
|
||||
"\n\n## Previous Summary Snapshot\n"
|
||||
f"{previous_summary}\n\n"
|
||||
"The previous compaction summary above remains background "
|
||||
"continuity context because the latest LLM summary update failed."
|
||||
)
|
||||
|
||||
reason_text = f" Summary failure reason: {reason}." if reason else ""
|
||||
@@ -2978,11 +2993,15 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
if text.startswith(prefix):
|
||||
text = text[len(prefix):].lstrip()
|
||||
break
|
||||
# Strip the trailing end marker too — a rehydrated handoff body that
|
||||
# keeps it would leak the boundary directive into the iterative-update
|
||||
# Strip the end marker too — a rehydrated handoff body that keeps it
|
||||
# would leak the boundary directive into the iterative-update
|
||||
# summarizer prompt (and the marker is re-appended on insertion anyway).
|
||||
if text.endswith(_SUMMARY_END_MARKER):
|
||||
text = text[: -len(_SUMMARY_END_MARKER)].rstrip()
|
||||
# Forced user-leading merged summaries keep the live tail request after
|
||||
# this marker, so truncate at the marker even when it is not the final
|
||||
# content.
|
||||
marker_idx = text.find(_SUMMARY_END_MARKER)
|
||||
if marker_idx >= 0:
|
||||
text = text[:marker_idx].rstrip()
|
||||
return text
|
||||
|
||||
@classmethod
|
||||
@@ -2992,7 +3011,29 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
return f"{SUMMARY_PREFIX}\n{text}" if text else SUMMARY_PREFIX
|
||||
|
||||
@staticmethod
|
||||
def _is_context_summary_content(content: Any) -> bool:
|
||||
def _starts_with_summary_prefix(text: str) -> bool:
|
||||
"""Return True if *text* begins with any known handoff prefix."""
|
||||
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
|
||||
@classmethod
|
||||
def classify_summary_content(cls, content: Any) -> Optional[str]:
|
||||
"""Classify how *content* relates to a compaction summary.
|
||||
|
||||
Returns:
|
||||
``"standalone"``: the entire message IS a compaction handoff
|
||||
(current, legacy, or historical prefix at the start). Frontends
|
||||
may restyle/collapse the whole message as a summary.
|
||||
|
||||
``"merged"``: a merge-into-tail message — real preserved turn
|
||||
content wrapped under ``_MERGED_PRIOR_CONTEXT_HEADER``, followed by
|
||||
``_MERGED_SUMMARY_DELIMITER`` and the summary body. The message
|
||||
*contains* a summary but is not only a summary; collapsing the
|
||||
whole message would hide the preserved content.
|
||||
|
||||
``None``: no compaction summary detected.
|
||||
"""
|
||||
text = _content_text_for_contains(content).lstrip()
|
||||
# Merge-into-tail summaries wrap prior tail content before the summary,
|
||||
# so the handoff prefix lands after _MERGED_SUMMARY_DELIMITER rather than
|
||||
@@ -3000,10 +3041,13 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# (auto-focus skip, carry-forward summary find, last-real-user anchor)
|
||||
# mistake a merged summary message for a real user turn.
|
||||
if _MERGED_SUMMARY_DELIMITER in text:
|
||||
text = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip()
|
||||
if text.startswith(SUMMARY_PREFIX) or text.startswith(LEGACY_SUMMARY_PREFIX):
|
||||
return True
|
||||
return any(text.startswith(p) for p in _HISTORICAL_SUMMARY_PREFIXES)
|
||||
after = text.split(_MERGED_SUMMARY_DELIMITER, 1)[1].lstrip()
|
||||
return "merged" if cls._starts_with_summary_prefix(after) else None
|
||||
return "standalone" if cls._starts_with_summary_prefix(text) else None
|
||||
|
||||
@classmethod
|
||||
def _is_context_summary_content(cls, content: Any) -> bool:
|
||||
return cls.classify_summary_content(content) is not None
|
||||
|
||||
@staticmethod
|
||||
def _has_compressed_summary_metadata(message: Any) -> bool:
|
||||
@@ -3081,6 +3125,15 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
"session with no user-authored turns"
|
||||
)
|
||||
|
||||
@classmethod
|
||||
def _is_context_summary_message(cls, message: Any) -> bool:
|
||||
"""Return True for summary handoff messages by metadata or content."""
|
||||
if not isinstance(message, dict):
|
||||
return False
|
||||
return cls._has_compressed_summary_metadata(
|
||||
message
|
||||
) or cls._is_context_summary_content(message.get("content"))
|
||||
|
||||
@classmethod
|
||||
def _is_blank_user_turn(cls, message: Any) -> bool:
|
||||
"""Return whether *message* is an empty, non-summary user-role echo."""
|
||||
@@ -3239,6 +3292,24 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
return grounded.strip()
|
||||
return f"{replacement}{body}".strip()
|
||||
|
||||
@classmethod
|
||||
def _find_context_summaries(
|
||||
cls,
|
||||
messages: List[Dict[str, Any]],
|
||||
start: int,
|
||||
end: int,
|
||||
) -> list[tuple[int, str]]:
|
||||
"""Find handoff summaries inside a compression window."""
|
||||
summaries: list[tuple[int, str]] = []
|
||||
for idx in range(start, end):
|
||||
content = messages[idx].get("content")
|
||||
if cls._is_context_summary_message(messages[idx]):
|
||||
summaries.append((
|
||||
idx,
|
||||
cls._strip_summary_prefix(_content_text_for_contains(content)),
|
||||
))
|
||||
return summaries
|
||||
|
||||
@classmethod
|
||||
def _find_latest_context_summary(
|
||||
cls,
|
||||
@@ -3247,10 +3318,9 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
end: int,
|
||||
) -> tuple[Optional[int], str]:
|
||||
"""Find the newest handoff summary inside a compression window."""
|
||||
for idx in range(end - 1, start - 1, -1):
|
||||
content = messages[idx].get("content")
|
||||
if cls._is_context_summary_content(content):
|
||||
return idx, cls._strip_summary_prefix(_content_text_for_contains(content))
|
||||
summaries = cls._find_context_summaries(messages, start, end)
|
||||
if summaries:
|
||||
return summaries[-1]
|
||||
return None, ""
|
||||
|
||||
@classmethod
|
||||
@@ -3471,7 +3541,25 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
idx += 1
|
||||
return idx
|
||||
|
||||
def _effective_protect_first_n(self) -> int:
|
||||
def _restart_handoff_probe_bounds(
|
||||
self,
|
||||
messages: List[Dict[str, Any]],
|
||||
) -> tuple[int, int]:
|
||||
"""Return the bounded transcript region that can indicate restart decay."""
|
||||
if not messages or self.protect_first_n <= 0:
|
||||
return 0, 0
|
||||
first_non_system = 1 if messages[0].get("role") == "system" else 0
|
||||
return first_non_system, min(
|
||||
len(messages),
|
||||
first_non_system
|
||||
+ self.protect_first_n
|
||||
+ _RESTART_HANDOFF_PROBE_EXTRA_MESSAGES,
|
||||
)
|
||||
|
||||
def _effective_protect_first_n(
|
||||
self,
|
||||
messages: Optional[List[Dict[str, Any]]] = None,
|
||||
) -> int:
|
||||
"""``protect_first_n`` decayed across compression cycles.
|
||||
|
||||
``protect_first_n`` keeps the first N non-system messages verbatim so
|
||||
@@ -3483,9 +3571,24 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
once, the early turns are already captured in the handoff summary, so
|
||||
there's no need to keep re-protecting them: decay to 0 (the system
|
||||
prompt is still always protected separately by _protect_head_size).
|
||||
After a restart, infer that decayed state from handoff summaries in the
|
||||
resumed-head region; disk-persisted restarts rely on the content prefix,
|
||||
while the metadata branch covers in-process handoff messages.
|
||||
"""
|
||||
if self.compression_count >= 1 or self._previous_summary:
|
||||
return 0
|
||||
if messages and self.protect_first_n > 0:
|
||||
# Probe only the early handoff shape created by a resumed compacted
|
||||
# session. Summary-looking tail content should keep normal tail
|
||||
# semantics and not decay the initial first-compaction protection.
|
||||
first_non_system, restart_probe_end = self._restart_handoff_probe_bounds(
|
||||
messages
|
||||
)
|
||||
if any(
|
||||
self._is_context_summary_message(msg)
|
||||
for msg in messages[first_non_system:restart_probe_end]
|
||||
):
|
||||
return 0
|
||||
return self.protect_first_n
|
||||
|
||||
def _protect_head_size(self, messages: List[Dict[str, Any]]) -> int:
|
||||
@@ -3511,7 +3614,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
head = 0
|
||||
if messages and messages[0].get("role") == "system":
|
||||
head = 1
|
||||
return head + self._effective_protect_first_n()
|
||||
return head + self._effective_protect_first_n(messages)
|
||||
|
||||
def _align_boundary_backward(self, messages: List[Dict[str, Any]], idx: int) -> int:
|
||||
"""Pull a compress-end boundary backward to avoid splitting a
|
||||
@@ -3593,6 +3696,8 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
msg.get("content")
|
||||
):
|
||||
continue
|
||||
if self._is_context_summary_message(msg):
|
||||
continue
|
||||
if last_any < 0:
|
||||
last_any = i
|
||||
content = msg.get("content")
|
||||
@@ -4059,23 +4164,45 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
return messages
|
||||
|
||||
turns_to_summarize = messages[compress_start:compress_end]
|
||||
# Snapshot the rehydration state so an aborted attempt below can roll
|
||||
# it back. The self-heal scan mutates ``_previous_summary`` (populating
|
||||
# it from a fossil, or discarding a stale cross-session one); if
|
||||
# summary generation then aborts and returns the transcript unchanged,
|
||||
# leaving that mutation behind would make the retry — still
|
||||
# ``compression_count == 0`` but now with a truthy ``_previous_summary``
|
||||
# — take the narrow rescan, miss a beyond-window fossil, and discard the
|
||||
# rehydrated state as cross-session leakage (#57835).
|
||||
_previous_summary_before_scan = self._previous_summary
|
||||
# A persisted handoff summary can sit in the protected head after a
|
||||
# resume (commonly immediately after the system prompt). Search from
|
||||
# the first non-system message through the compression window so we can
|
||||
# rehydrate iterative-summary state without serializing that handoff as
|
||||
# a new turn. Protected messages after the handoff remain live context,
|
||||
# so only summarize messages that are both after the handoff and inside
|
||||
# the current compression window.
|
||||
# the first non-system message through the compression window. On the
|
||||
# first compaction after a restart, extend through the full transcript
|
||||
# so summaries that landed in the protected tail or drifted past the
|
||||
# decay probe still rehydrate iterative-summary state instead of being
|
||||
# copied forward as stacked fossils.
|
||||
summary_search_start = 1 if messages and messages[0].get("role") == "system" else 0
|
||||
summary_idx, summary_body = self._find_latest_context_summary(
|
||||
summary_search_end = compress_end
|
||||
if self.compression_count < 1 and not self._previous_summary:
|
||||
summary_search_end = len(messages)
|
||||
summary_search_end = min(len(messages), summary_search_end)
|
||||
summary_indices: set[int] = set()
|
||||
summary_idx = None
|
||||
summary_body = None
|
||||
tail_start = compress_end
|
||||
summary_hits = self._find_context_summaries(
|
||||
messages,
|
||||
summary_search_start,
|
||||
compress_end,
|
||||
summary_search_end,
|
||||
)
|
||||
real_user_present = self._transcript_has_real_user_turn(messages)
|
||||
if summary_idx is not None:
|
||||
if summary_body and not self._previous_summary:
|
||||
self._previous_summary = summary_body
|
||||
if summary_hits:
|
||||
summary_idx = summary_hits[-1][0]
|
||||
summary_body = summary_hits[-1][1]
|
||||
if not self._previous_summary:
|
||||
summary_bodies = [body for _, body in summary_hits if body]
|
||||
if summary_bodies:
|
||||
self._previous_summary = "\n\n".join(summary_bodies)
|
||||
# Zero-user provenance (#64650) rides on the newest handoff hit.
|
||||
provenance = messages[summary_idx].get(
|
||||
COMPRESSED_SUMMARY_HAS_USER_TURN_KEY
|
||||
)
|
||||
@@ -4090,7 +4217,42 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
self._summary_has_user_turn = not (
|
||||
summary_body and _NO_USER_TASK_SENTINEL in summary_body
|
||||
)
|
||||
turns_to_summarize = messages[max(compress_start, summary_idx + 1):compress_end]
|
||||
summary_indices = {idx for idx, _ in summary_hits}
|
||||
# Summary rows are excluded from the summarizer input (their
|
||||
# bodies already ride _previous_summary), BUT a merged handoff
|
||||
# carries genuine prior-tail user content before the delimiter —
|
||||
# unwrap it (via the strip helper) into the window instead of
|
||||
# dropping it (#47274 interplay with the multi-fossil scan).
|
||||
def _window_row(idx: int, msg: Dict[str, Any]):
|
||||
if idx not in summary_indices:
|
||||
return msg
|
||||
stripped = self._strip_context_summary_handoff_message(
|
||||
_fresh_compaction_message_copy(msg)
|
||||
)
|
||||
return stripped # None for standalone handoffs → dropped
|
||||
pre_summary_turns = [
|
||||
row for idx, msg in enumerate(
|
||||
messages[compress_start:summary_idx],
|
||||
start=compress_start,
|
||||
)
|
||||
if (row := _window_row(idx, msg)) is not None
|
||||
]
|
||||
turns_to_summarize = (
|
||||
pre_summary_turns + messages[summary_idx + 1:compress_end]
|
||||
)
|
||||
# The newest hit itself may be a merged handoff too — recover its
|
||||
# prior-tail content the same way.
|
||||
_newest_stripped = self._strip_context_summary_handoff_message(
|
||||
_fresh_compaction_message_copy(messages[summary_idx])
|
||||
)
|
||||
if _newest_stripped is not None:
|
||||
turns_to_summarize = (
|
||||
pre_summary_turns
|
||||
+ [_newest_stripped]
|
||||
+ messages[summary_idx + 1:compress_end]
|
||||
)
|
||||
if summary_idx >= compress_end:
|
||||
tail_start = summary_idx + 1
|
||||
elif self._previous_summary:
|
||||
# No handoff summary found in the current messages, but
|
||||
# _previous_summary is non-empty — it was set by a different
|
||||
@@ -4121,7 +4283,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
self.threshold_percent * 100,
|
||||
self.threshold_tokens,
|
||||
)
|
||||
tail_msgs = n_messages - compress_end
|
||||
tail_msgs = n_messages - tail_start
|
||||
logger.info(
|
||||
"Summarizing turns %d-%d (%d turns), protecting %d head + %d tail messages",
|
||||
compress_start + 1,
|
||||
@@ -4174,6 +4336,11 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
telemetry["failure_class"] = "summary_network_failure"
|
||||
else:
|
||||
telemetry["failure_class"] = "summary_generation_aborted"
|
||||
# Roll back the self-heal rehydration so this aborted attempt is a
|
||||
# true no-op: the next attempt must re-run the full first-compaction
|
||||
# scan instead of narrow-rescanning against a half-populated state
|
||||
# and discarding a legitimately rehydrated fossil (#57835).
|
||||
self._previous_summary = _previous_summary_before_scan
|
||||
if not self.quiet_mode:
|
||||
if self._last_summary_auth_failure:
|
||||
logger.warning(
|
||||
@@ -4214,8 +4381,8 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# _strip_context_summary_handoff_message() handles both shapes:
|
||||
# standalone handoffs strip to None (dropped), merged handoffs
|
||||
# unwrap to their genuine prior-tail content (preserved). Do NOT
|
||||
# short-circuit on summary_idx here: a merged handoff carries real
|
||||
# user content that a blanket skip would silently delete.
|
||||
# short-circuit on summary_indices here: a merged handoff carries
|
||||
# real user content that a blanket skip would silently delete.
|
||||
msg = _fresh_compaction_message_copy(messages[i])
|
||||
if i == 0 and msg.get("role") == "system":
|
||||
existing = msg.get("content")
|
||||
@@ -4246,17 +4413,26 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
)
|
||||
|
||||
tail_messages: List[Dict[str, Any]] = []
|
||||
for i in range(compress_end, n_messages):
|
||||
# Start at tail_start (not compress_end): the restart-decay scan may
|
||||
# have advanced it past a summary that sat beyond compress_end
|
||||
# (#57835). summary_indices rows are already rehydrated; the strip
|
||||
# helper handles any that remain (standalone → dropped, merged →
|
||||
# unwrapped to genuine prior-tail content, #47274).
|
||||
for i in range(max(compress_end, tail_start), n_messages):
|
||||
if i in summary_indices and i >= tail_start:
|
||||
# A summary at/after tail_start was already folded into
|
||||
# _previous_summary; don't re-emit it verbatim.
|
||||
continue
|
||||
msg = _fresh_compaction_message_copy(messages[i])
|
||||
stripped = self._strip_context_summary_handoff_message(msg)
|
||||
if stripped is not None:
|
||||
tail_messages.append(stripped)
|
||||
|
||||
_merge_summary_into_tail = False
|
||||
last_head_role = compressed[-1].get("role", "user") if compressed else "user"
|
||||
# NOTE: derive the tail's leading role from tail_messages (post
|
||||
# handoff-strip), not messages[compress_end] — a stripped stale
|
||||
# last_head_role reads the assembled (post-strip) head; first_tail_role
|
||||
# reads the assembled (post-strip) tail_messages — a stripped stale
|
||||
# handoff must not influence alternation-safe role selection.
|
||||
last_head_role = compressed[-1].get("role", "user") if compressed else "user"
|
||||
first_tail_role = tail_messages[0].get("role", "user") if tail_messages else None
|
||||
# When the only protected head message is the system prompt, the
|
||||
# summary becomes the first *visible* message in the API request
|
||||
@@ -4265,7 +4441,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
# Anthropic unconditionally rejects requests whose first message
|
||||
# is not role=user, so we must pin the summary to "user" and
|
||||
# prevent the flip logic below from reverting it (#52160).
|
||||
_force_user_leading = last_head_role == "system"
|
||||
_force_user_leading = compress_start == 0 or last_head_role == "system"
|
||||
# Zero-user-turn guard (#58753). The #52160 guard above only fires
|
||||
# when the system prompt sits *inside* ``messages`` (the gateway
|
||||
# ``/compress`` path). The main auto-compression path passes the
|
||||
@@ -4299,7 +4475,7 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
summary_role = "assistant"
|
||||
# If the chosen role collides with the tail AND flipping wouldn't
|
||||
# collide with the head, flip it.
|
||||
if first_tail_role and summary_role == first_tail_role:
|
||||
if first_tail_role is not None and summary_role == first_tail_role:
|
||||
flipped = "assistant" if summary_role == "user" else "user"
|
||||
if flipped != last_head_role and not _force_user_leading:
|
||||
summary_role = flipped
|
||||
@@ -4332,27 +4508,39 @@ This compaction should PRIORITISE preserving all information related to the focu
|
||||
|
||||
for tail_idx, msg in enumerate(tail_messages):
|
||||
if _merge_summary_into_tail and tail_idx == 0:
|
||||
# Merge the summary into the first tail message, but place
|
||||
# the END MARKER at the very end so the model sees an
|
||||
# unambiguous boundary. Old tail content is preserved as
|
||||
# reference material BEFORE the summary, clearly delimited
|
||||
# so it is not mistaken for a new message to respond to.
|
||||
# Uses _append_text_to_content to safely handle both
|
||||
# string and multimodal-list content types.
|
||||
# Fixes ghost-message leakage across compaction boundaries
|
||||
# where old head messages survived verbatim and appeared
|
||||
# before the summary.
|
||||
# Merge the summary into the first (post-strip) tail message.
|
||||
old_content = msg.get("content", "")
|
||||
suffix = (
|
||||
"\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n"
|
||||
+ summary + "\n\n"
|
||||
+ _SUMMARY_END_MARKER
|
||||
)
|
||||
msg["content"] = _append_text_to_content(
|
||||
_append_text_to_content(old_content, suffix, prepend=False),
|
||||
_MERGED_PRIOR_CONTEXT_HEADER + "\n",
|
||||
prepend=True,
|
||||
)
|
||||
if _force_user_leading and summary_role == "user":
|
||||
# The summary must be part of the first user-visible
|
||||
# message for Anthropic/Bedrock, but the real tail request
|
||||
# still has to appear *after* the summary boundary.
|
||||
prefix = summary + "\n\n" + _SUMMARY_END_MARKER + "\n\n"
|
||||
msg["content"] = _append_text_to_content(
|
||||
old_content,
|
||||
prefix,
|
||||
prepend=True,
|
||||
)
|
||||
else:
|
||||
# Merge the summary into the first tail message, but place
|
||||
# the END MARKER at the very end so the model sees an
|
||||
# unambiguous boundary. Old tail content is preserved as
|
||||
# reference material BEFORE the summary, clearly delimited
|
||||
# so it is not mistaken for a new message to respond to.
|
||||
# Uses _append_text_to_content to safely handle both
|
||||
# string and multimodal-list content types.
|
||||
# Fixes ghost-message leakage across compaction boundaries
|
||||
# where old head messages survived verbatim and appeared
|
||||
# before the summary.
|
||||
suffix = (
|
||||
"\n\n" + _MERGED_SUMMARY_DELIMITER + "\n\n"
|
||||
+ summary + "\n\n"
|
||||
+ _SUMMARY_END_MARKER
|
||||
)
|
||||
msg["content"] = _append_text_to_content(
|
||||
_append_text_to_content(old_content, suffix, prepend=False),
|
||||
_MERGED_PRIOR_CONTEXT_HEADER + "\n",
|
||||
prepend=True,
|
||||
)
|
||||
# Mark the merged message so frontends can identify it as
|
||||
# containing a compression summary prefix.
|
||||
msg[COMPRESSED_SUMMARY_METADATA_KEY] = True
|
||||
|
||||
@@ -57,6 +57,19 @@ 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)
|
||||
|
||||
|
||||
def _builtin_memory_prompt_snapshot(agent: Any) -> Optional[Tuple[str, str]]:
|
||||
"""Return the built-in memory text that can affect a system prompt.
|
||||
@@ -1025,6 +1038,14 @@ def compress_context(
|
||||
focus_topic,
|
||||
)
|
||||
agent._emit_status(COMPACTION_STATUS)
|
||||
_compaction_done_emitted = False
|
||||
|
||||
def _complete_compaction_lifecycle() -> None:
|
||||
nonlocal _compaction_done_emitted
|
||||
if _compaction_done_emitted:
|
||||
return
|
||||
_compaction_done_emitted = True
|
||||
_emit_compaction_done(agent)
|
||||
|
||||
# ── Compression lock ────────────────────────────────────────────────
|
||||
# Atomic, state.db-backed lock per session_id. Without this, two
|
||||
@@ -1174,12 +1195,14 @@ def compress_context(
|
||||
split_status="aborted",
|
||||
failure_class="lock_contended",
|
||||
)
|
||||
_complete_compaction_lifecycle()
|
||||
return messages, _existing_sp
|
||||
_lock_released = False
|
||||
|
||||
def _release_lock() -> None:
|
||||
"""Release the lock keyed on the OLD session_id (before rotation)."""
|
||||
nonlocal _lock_released
|
||||
_complete_compaction_lifecycle()
|
||||
if _lock_released:
|
||||
return
|
||||
_lock_released = True
|
||||
@@ -1876,6 +1899,15 @@ def _compress_context_via_codex_app_server(
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
_compaction_done_emitted = False
|
||||
|
||||
def _complete_compaction_lifecycle() -> None:
|
||||
nonlocal _compaction_done_emitted
|
||||
if _compaction_done_emitted:
|
||||
return
|
||||
_compaction_done_emitted = True
|
||||
_emit_compaction_done(agent)
|
||||
|
||||
_activity_heartbeat: Optional[_CompressionActivityHeartbeat] = None
|
||||
try:
|
||||
_activity_heartbeat = _CompressionActivityHeartbeat(agent).start()
|
||||
@@ -1883,6 +1915,7 @@ def _compress_context_via_codex_app_server(
|
||||
except BaseException:
|
||||
if _activity_heartbeat is not None:
|
||||
_activity_heartbeat.stop("context compression failed")
|
||||
_complete_compaction_lifecycle()
|
||||
raise
|
||||
|
||||
if getattr(result, "interrupted", False) or getattr(result, "error", None):
|
||||
@@ -1907,6 +1940,7 @@ def _compress_context_via_codex_app_server(
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
_complete_compaction_lifecycle()
|
||||
return messages, existing_prompt
|
||||
|
||||
try:
|
||||
@@ -1946,6 +1980,7 @@ def _compress_context_via_codex_app_server(
|
||||
existing_prompt = getattr(agent, "_cached_system_prompt", None)
|
||||
if not existing_prompt:
|
||||
existing_prompt = agent._build_system_prompt(system_message)
|
||||
_complete_compaction_lifecycle()
|
||||
return messages, existing_prompt
|
||||
|
||||
|
||||
@@ -2206,6 +2241,7 @@ def try_shrink_image_parts_in_messages(
|
||||
|
||||
__all__ = [
|
||||
"COMPACTION_STATUS",
|
||||
"COMPACTION_DONE_STATUS",
|
||||
"COMPACTION_STATUS_MARKER",
|
||||
"check_compression_model_feasibility",
|
||||
"replay_compression_warning",
|
||||
|
||||
+128
-16
@@ -103,6 +103,53 @@ _API_CALL_MODULES = frozenset({
|
||||
})
|
||||
|
||||
|
||||
def _apply_active_turn_redirect(agent: Any, messages: List[Dict[str, Any]], text: str) -> None:
|
||||
"""Append a provider-safe checkpoint and correction to the live turn.
|
||||
|
||||
Incomplete provider reasoning blocks are not valid replay items (Anthropic
|
||||
signs them; Responses reasoning items require their following output).
|
||||
Preserve only what Hermes actually displayed, demoted to ordinary text,
|
||||
then add the correction as a real user message. This keeps role alternation
|
||||
valid and leaves every previously cached message byte-for-byte unchanged.
|
||||
"""
|
||||
reasoning = str(
|
||||
getattr(agent, "_current_streamed_reasoning_text", "") or ""
|
||||
).strip()
|
||||
visible = agent._strip_think_blocks(
|
||||
getattr(agent, "_current_streamed_assistant_text", "") or ""
|
||||
).strip()
|
||||
|
||||
checkpoint_parts = ["[This response was interrupted by a user correction.]"]
|
||||
if reasoning:
|
||||
checkpoint_parts.extend(
|
||||
["Reasoning shown before the interruption:", reasoning]
|
||||
)
|
||||
if visible:
|
||||
checkpoint_parts.extend(
|
||||
["Visible response before the interruption:", visible]
|
||||
)
|
||||
checkpoint = "\n\n".join(checkpoint_parts)
|
||||
|
||||
# The normal live tail is user or tool, so an assistant checkpoint followed
|
||||
# by the correction preserves strict alternation. If a transport already
|
||||
# committed an assistant item, attribute the checkpoint inside the user
|
||||
# correction instead of creating assistant→assistant.
|
||||
if messages and messages[-1].get("role") == "assistant":
|
||||
correction = (
|
||||
"[Context from the interrupted assistant response]\n"
|
||||
f"{checkpoint}\n\n"
|
||||
f"{text}"
|
||||
)
|
||||
messages.append({"role": "user", "content": correction})
|
||||
else:
|
||||
messages.append({"role": "assistant", "content": checkpoint})
|
||||
messages.append({"role": "user", "content": text})
|
||||
|
||||
agent._current_streamed_assistant_text = ""
|
||||
agent._current_streamed_reasoning_text = ""
|
||||
agent._stream_needs_break = True
|
||||
|
||||
|
||||
def _image_error_max_dimension(error: Exception) -> Optional[int]:
|
||||
"""Extract a provider-reported image dimension ceiling, if present."""
|
||||
parts = []
|
||||
@@ -733,6 +780,16 @@ def run_conversation(
|
||||
)
|
||||
|
||||
while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call:
|
||||
_redirect_text = agent._drain_pending_redirect()
|
||||
if _redirect_text:
|
||||
_apply_active_turn_redirect(agent, messages, _redirect_text)
|
||||
if isinstance(original_user_message, str):
|
||||
original_user_message = (
|
||||
f"{original_user_message}\n\n"
|
||||
f"User correction during the turn: {_redirect_text}"
|
||||
)
|
||||
agent._persist_session(messages, conversation_history)
|
||||
|
||||
# Reset per-turn checkpoint dedup so each iteration can take one snapshot
|
||||
agent._checkpoint_mgr.new_turn()
|
||||
|
||||
@@ -1532,22 +1589,59 @@ def run_conversation(
|
||||
|
||||
from hermes_cli.middleware import run_llm_execution_middleware
|
||||
|
||||
response = run_llm_execution_middleware(
|
||||
api_kwargs,
|
||||
_perform_api_call,
|
||||
original_request=_original_api_kwargs,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
session_id=agent.session_id or "",
|
||||
platform=agent.platform or "",
|
||||
model=agent.model,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
middleware_trace=list(_llm_middleware_trace),
|
||||
)
|
||||
_model_request_active = getattr(agent, "_model_request_active", None)
|
||||
_redirect_lock = getattr(agent, "_pending_redirect_lock", None)
|
||||
if _redirect_lock is not None:
|
||||
with _redirect_lock:
|
||||
if _model_request_active is not None:
|
||||
_model_request_active.set()
|
||||
elif _model_request_active is not None:
|
||||
_model_request_active.set()
|
||||
_redirect_crossed_response = False
|
||||
try:
|
||||
response = run_llm_execution_middleware(
|
||||
api_kwargs,
|
||||
_perform_api_call,
|
||||
original_request=_original_api_kwargs,
|
||||
task_id=effective_task_id,
|
||||
turn_id=turn_id,
|
||||
api_request_id=api_request_id,
|
||||
session_id=agent.session_id or "",
|
||||
platform=agent.platform or "",
|
||||
model=agent.model,
|
||||
provider=agent.provider,
|
||||
base_url=agent.base_url,
|
||||
api_mode=agent.api_mode,
|
||||
api_call_count=api_call_count,
|
||||
middleware_trace=list(_llm_middleware_trace),
|
||||
)
|
||||
finally:
|
||||
if _redirect_lock is not None:
|
||||
with _redirect_lock:
|
||||
if _model_request_active is not None:
|
||||
_model_request_active.clear()
|
||||
_redirect_crossed_response = bool(
|
||||
agent._pending_redirect
|
||||
)
|
||||
else:
|
||||
if _model_request_active is not None:
|
||||
_model_request_active.clear()
|
||||
_redirect_crossed_response = agent._has_pending_redirect()
|
||||
if _redirect_crossed_response:
|
||||
# The response and redirect can cross on different threads:
|
||||
# redirect() observed the request as active just before this
|
||||
# call returned. Discard that now-stale response and rebuild
|
||||
# from the correction rather than silently losing it.
|
||||
if thinking_spinner:
|
||||
thinking_spinner.stop("")
|
||||
thinking_spinner = None
|
||||
if agent.thinking_callback:
|
||||
agent.thinking_callback("")
|
||||
if agent.clear_interrupt(preserve_redirect=True):
|
||||
_retry.restart_with_redirected_messages = True
|
||||
else:
|
||||
interrupted = True
|
||||
break
|
||||
|
||||
api_duration = time.time() - api_start_time
|
||||
|
||||
@@ -2509,6 +2603,15 @@ def run_conversation(
|
||||
thinking_spinner = None
|
||||
if agent.thinking_callback:
|
||||
agent.thinking_callback("")
|
||||
if agent._has_pending_redirect():
|
||||
# redirect() deliberately used the interrupt machinery to
|
||||
# cancel only this provider request. Keep its correction
|
||||
# queued, clear the cancellation bit, and let the outer
|
||||
# loop rebuild a clean request tail. Never materialize
|
||||
# incomplete signed/encrypted reasoning items.
|
||||
if agent.clear_interrupt(preserve_redirect=True):
|
||||
_retry.restart_with_redirected_messages = True
|
||||
break
|
||||
api_elapsed = time.time() - api_start_time
|
||||
agent._vprint(f"{agent.log_prefix}⚡ Interrupted during API call.", force=True)
|
||||
interrupted = True
|
||||
@@ -4457,6 +4560,15 @@ def run_conversation(
|
||||
f"{int(sleep_end - time.time())}s remaining"
|
||||
)
|
||||
|
||||
if _retry.restart_with_redirected_messages:
|
||||
# The cancelled request produced no valid assistant item. Reuse the
|
||||
# same logical iteration after the outer loop appends the displayed
|
||||
# partial context and correction to ``messages``.
|
||||
api_call_count -= 1
|
||||
agent.iteration_budget.refund()
|
||||
_retry.restart_with_redirected_messages = False
|
||||
continue
|
||||
|
||||
# If the API call was interrupted, skip response processing
|
||||
if interrupted:
|
||||
_turn_exit_reason = "interrupted_during_api_call"
|
||||
|
||||
@@ -1484,6 +1484,43 @@ class CredentialPool:
|
||||
self._sync_device_code_entry_to_auth_store(updated)
|
||||
return updated
|
||||
|
||||
def _codex_quota_restored_upstream(self, entry: PooledCredential) -> bool:
|
||||
"""Live-check whether an exhausted Codex entry's quota reset early.
|
||||
|
||||
A Codex 429 persists a ``last_error_reset_at`` that can be days in
|
||||
the future (weekly windows), but the upstream window can reopen
|
||||
before then — the user redeems a banked rate-limit reset via the
|
||||
Codex CLI / ChatGPT UI, upgrades their plan, or OpenAI resets the
|
||||
window. Without this check the pool keeps the credential frozen
|
||||
until the stale timestamp elapses even though the account is
|
||||
usable (issue #43747).
|
||||
|
||||
Only fires for openai-codex entries frozen by a 429/quota-shaped
|
||||
error. The underlying probe is throttled per token (5 min) so this
|
||||
is safe on the hot selection path.
|
||||
"""
|
||||
if self.provider != "openai-codex" or entry.last_status != STATUS_EXHAUSTED:
|
||||
return False
|
||||
if not auth_mod._is_codex_rate_limit_shaped(
|
||||
entry.last_error_code,
|
||||
entry.last_error_reason,
|
||||
entry.last_error_message,
|
||||
):
|
||||
return False
|
||||
token = entry.access_token or ""
|
||||
if not token:
|
||||
return False
|
||||
try:
|
||||
return bool(
|
||||
auth_mod._probe_codex_quota_restored(
|
||||
token,
|
||||
base_url=entry.base_url,
|
||||
)
|
||||
)
|
||||
except Exception:
|
||||
logger.debug("Codex quota-restored probe failed", exc_info=True)
|
||||
return False
|
||||
|
||||
def _entry_needs_refresh(self, entry: PooledCredential) -> bool:
|
||||
if entry.auth_type != AUTH_TYPE_OAUTH:
|
||||
return False
|
||||
@@ -1605,7 +1642,18 @@ class CredentialPool:
|
||||
if entry.last_status == STATUS_EXHAUSTED:
|
||||
exhausted_until = _exhausted_until(entry)
|
||||
if exhausted_until is not None and now < exhausted_until:
|
||||
continue
|
||||
# Codex quota windows can reopen EARLY: the user redeems a
|
||||
# banked rate-limit reset (Codex CLI / ChatGPT UI), upgrades
|
||||
# their plan, or OpenAI resets the window. The persisted
|
||||
# ``last_error_reset_at`` can then be days in the future
|
||||
# while the account is already usable again — a throttled
|
||||
# live probe of the Codex usage endpoint detects that and
|
||||
# lifts the stale cooldown (issue #43747).
|
||||
if not (
|
||||
clear_expired
|
||||
and self._codex_quota_restored_upstream(entry)
|
||||
):
|
||||
continue
|
||||
if clear_expired:
|
||||
cleared = replace(
|
||||
entry,
|
||||
|
||||
+1
-25
@@ -32,7 +32,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sysconfig
|
||||
import threading
|
||||
from functools import lru_cache
|
||||
from pathlib import Path
|
||||
@@ -92,12 +91,8 @@ def _locales_dir() -> Path:
|
||||
|
||||
1. ``HERMES_BUNDLED_LOCALES`` env var -- set by the Nix wrapper (or any
|
||||
sealed-packaging system) to point at the installed catalog directory.
|
||||
2. ``<repo-root>/locales`` -- source checkouts and ``pip install -e .``,
|
||||
2. ``<repo-root>/locales`` -- source checkouts and editable installs,
|
||||
where the working tree sits next to ``agent/``.
|
||||
3. ``<sysconfig data|purelib|platlib>/locales`` -- pip wheel installs.
|
||||
setuptools ``data-files`` extracts ``locales/*.yaml`` under the
|
||||
interpreter's ``data`` scheme; the other schemes are checked as a
|
||||
safety net for nonstandard layouts.
|
||||
|
||||
Falling through to the source-style path (even when missing) keeps
|
||||
``_load_catalog`` error messages informative -- it logs the path it
|
||||
@@ -116,25 +111,6 @@ def _locales_dir() -> Path:
|
||||
|
||||
# agent/i18n.py -> agent/ -> repo root (source checkout, editable install)
|
||||
source_dir = Path(__file__).resolve().parent.parent / "locales"
|
||||
if source_dir.is_dir():
|
||||
return source_dir
|
||||
|
||||
# pip wheel install: data-files lands under the interpreter data scheme.
|
||||
# ``data`` (== sys.prefix in a venv) is where setuptools data-files extract
|
||||
# and is checked first. ``purelib``/``platlib`` (site-packages) are a safety
|
||||
# net for nonstandard layouts. NOTE: this does NOT cover ``pip install
|
||||
# --user`` (user scheme, ~/.local/locales) or ``pip install --target`` --
|
||||
# both are out of scope; see the plan header.
|
||||
for scheme in ("data", "purelib", "platlib"):
|
||||
raw = sysconfig.get_path(scheme)
|
||||
if not raw:
|
||||
continue
|
||||
candidate = Path(raw) / "locales"
|
||||
if candidate.is_dir():
|
||||
return candidate
|
||||
|
||||
# Last resort: return the source-style path so _load_catalog's catalog-missing
|
||||
# log (logger.debug "i18n catalog missing for %s at %s") stays informative.
|
||||
return source_dir
|
||||
|
||||
|
||||
|
||||
@@ -52,6 +52,13 @@ def busy_input_hint_gateway(mode: str) -> str:
|
||||
"Send `/busy interrupt` or `/busy queue` to change this, or "
|
||||
"`/busy status` to check. This notice won't appear again."
|
||||
)
|
||||
if mode == "redirect":
|
||||
return (
|
||||
"💡 First-time tip — I redirected the current run using your message. "
|
||||
"Completed work stays in context, and `/stop` still cancels the task. "
|
||||
"Send `/busy queue` to wait for a separate turn, or `/busy status` "
|
||||
"to check. This notice won't appear again."
|
||||
)
|
||||
return (
|
||||
"💡 First-time tip — I just interrupted my current task to answer you. "
|
||||
"Send `/busy queue` to queue follow-ups for after the current task instead, "
|
||||
@@ -74,6 +81,12 @@ def busy_input_hint_cli(mode: str) -> str:
|
||||
"after the next tool call. Use /busy interrupt or /busy queue to "
|
||||
"change this. This tip only shows once."
|
||||
)
|
||||
if mode == "redirect":
|
||||
return (
|
||||
"(tip) Your correction redirected the current run without discarding "
|
||||
"completed work. Use /stop to cancel or /busy queue to wait for a "
|
||||
"separate turn. This tip only shows once."
|
||||
)
|
||||
return (
|
||||
"(tip) Your message interrupted the current run. "
|
||||
"Use /busy queue to queue messages for the next turn instead, "
|
||||
|
||||
@@ -38,6 +38,14 @@ IDEMPOTENT_TOOL_NAMES = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Cyclic tool-call loop detection bounds. Cycles of length 1 (the same call
|
||||
# repeated back-to-back) are intentionally excluded: pure self-repeats are
|
||||
# already covered by the exact-failure and idempotent-no-progress guards, and
|
||||
# successful self-repeats of mutating tools (e.g. polling a background process)
|
||||
# are legitimate. Ported/adapted from google-gemini/gemini-cli#28429.
|
||||
CYCLE_MIN_LENGTH = 2
|
||||
CYCLE_MAX_LENGTH = 5
|
||||
|
||||
MUTATING_TOOL_NAMES = frozenset(
|
||||
{
|
||||
"terminal",
|
||||
@@ -77,6 +85,8 @@ class ToolCallGuardrailConfig:
|
||||
same_tool_failure_halt_after: int = 8
|
||||
no_progress_warn_after: int = 2
|
||||
no_progress_block_after: int = 5
|
||||
cycle_warn_after: int = 3
|
||||
cycle_block_after: int = 5
|
||||
idempotent_tools: frozenset[str] = field(default_factory=lambda: IDEMPOTENT_TOOL_NAMES)
|
||||
mutating_tools: frozenset[str] = field(default_factory=lambda: MUTATING_TOOL_NAMES)
|
||||
|
||||
@@ -121,6 +131,14 @@ class ToolCallGuardrailConfig:
|
||||
hard_stop_after.get("idempotent_no_progress", data.get("no_progress_block_after")),
|
||||
defaults.no_progress_block_after,
|
||||
),
|
||||
cycle_warn_after=_positive_int(
|
||||
warn_after.get("cycle", data.get("cycle_warn_after")),
|
||||
defaults.cycle_warn_after,
|
||||
),
|
||||
cycle_block_after=_positive_int(
|
||||
hard_stop_after.get("cycle", data.get("cycle_block_after")),
|
||||
defaults.cycle_block_after,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -232,6 +250,7 @@ class ToolCallGuardrailController:
|
||||
self._exact_failure_counts: dict[ToolCallSignature, int] = {}
|
||||
self._same_tool_failure_counts: dict[str, int] = {}
|
||||
self._no_progress: dict[ToolCallSignature, tuple[str, int]] = {}
|
||||
self._call_sequence: list[tuple[str, str]] = []
|
||||
self._halt_decision: ToolGuardrailDecision | None = None
|
||||
|
||||
@property
|
||||
@@ -295,6 +314,94 @@ class ToolCallGuardrailController:
|
||||
if failed is None:
|
||||
failed, _ = classify_tool_failure(tool_name, result)
|
||||
|
||||
cycle_decision = self._record_and_check_cycle(tool_name, signature)
|
||||
if cycle_decision is not None and cycle_decision.should_halt:
|
||||
self._halt_decision = cycle_decision
|
||||
return cycle_decision
|
||||
|
||||
decision = self._check_repetition(tool_name, signature, result, failed)
|
||||
if decision.action == "allow" and cycle_decision is not None:
|
||||
return cycle_decision
|
||||
return decision
|
||||
|
||||
def _record_and_check_cycle(
|
||||
self, tool_name: str, signature: ToolCallSignature
|
||||
) -> ToolGuardrailDecision | None:
|
||||
"""Detect repeating tool-call cycles of length 2..CYCLE_MAX_LENGTH.
|
||||
|
||||
Tracks the per-turn sequence of exact call signatures (success or
|
||||
failure alike) and looks for the trailing k-call window repeating
|
||||
consecutively — the A→B→A→B and A→B→C→A→B→C patterns that the
|
||||
per-signature counters structurally cannot see because every
|
||||
individual signature differs from its predecessor.
|
||||
Adapted from gemini-cli's LoopDetectionService (google-gemini/gemini-cli#28429).
|
||||
"""
|
||||
key = f"{signature.tool_name}\x00{signature.args_hash}"
|
||||
self._call_sequence.append((key, signature.tool_name))
|
||||
max_needed = CYCLE_MAX_LENGTH * max(
|
||||
self.config.cycle_warn_after, self.config.cycle_block_after
|
||||
)
|
||||
if len(self._call_sequence) > max_needed:
|
||||
del self._call_sequence[: len(self._call_sequence) - max_needed]
|
||||
|
||||
keys = [entry[0] for entry in self._call_sequence]
|
||||
n = len(keys)
|
||||
min_reps = min(self.config.cycle_warn_after, self.config.cycle_block_after)
|
||||
best: tuple[int, int] | None = None # (repetitions, cycle_length)
|
||||
for k in range(CYCLE_MIN_LENGTH, CYCLE_MAX_LENGTH + 1):
|
||||
if n < k * 2:
|
||||
break
|
||||
cycle = keys[-k:]
|
||||
if len(set(cycle)) < 2:
|
||||
continue # uniform run: covered by the exact-repeat guards
|
||||
reps = 1
|
||||
i = n - k
|
||||
while i - k >= 0 and keys[i - k : i] == cycle:
|
||||
reps += 1
|
||||
i -= k
|
||||
if reps >= min_reps and (best is None or reps > best[0]):
|
||||
best = (reps, k)
|
||||
|
||||
if best is None:
|
||||
return None
|
||||
reps, k = best
|
||||
cycle_desc = " -> ".join(name for _, name in self._call_sequence[-k:])
|
||||
if self.config.hard_stop_enabled and reps >= self.config.cycle_block_after:
|
||||
return ToolGuardrailDecision(
|
||||
action="halt",
|
||||
code="tool_call_cycle_halt",
|
||||
message=(
|
||||
f"Stopped {tool_name}: the tool-call cycle [{cycle_desc}] has "
|
||||
f"repeated {reps} times in a row with identical arguments. "
|
||||
"This is a loop; change strategy instead of repeating the cycle."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=reps,
|
||||
signature=signature,
|
||||
)
|
||||
if self.config.warnings_enabled and reps >= self.config.cycle_warn_after:
|
||||
return ToolGuardrailDecision(
|
||||
action="warn",
|
||||
code="tool_call_cycle_warning",
|
||||
message=(
|
||||
f"The tool-call cycle [{cycle_desc}] has repeated {reps} times "
|
||||
"in a row with identical arguments. If you are intentionally "
|
||||
"polling, proceed deliberately; otherwise this looks like a "
|
||||
"loop — change strategy instead of repeating the cycle."
|
||||
),
|
||||
tool_name=tool_name,
|
||||
count=reps,
|
||||
signature=signature,
|
||||
)
|
||||
return None
|
||||
|
||||
def _check_repetition(
|
||||
self,
|
||||
tool_name: str,
|
||||
signature: ToolCallSignature,
|
||||
result: str | None,
|
||||
failed: bool,
|
||||
) -> ToolGuardrailDecision:
|
||||
if failed:
|
||||
exact_count = self._exact_failure_counts.get(signature, 0) + 1
|
||||
self._exact_failure_counts[signature] = exact_count
|
||||
|
||||
@@ -300,6 +300,8 @@ class CodexAppServerSession:
|
||||
self._client: Optional[CodexAppServerClient] = None
|
||||
self._thread_id: Optional[str] = None
|
||||
self._interrupt_event = threading.Event()
|
||||
self._active_turn_id: Optional[str] = None
|
||||
self._active_turn_lock = threading.Lock()
|
||||
# Pending file-change items, keyed by item id. Populated on
|
||||
# item/started for fileChange items; consumed by the approval
|
||||
# bridge when codex sends item/fileChange/requestApproval. The
|
||||
@@ -374,6 +376,8 @@ class CodexAppServerSession:
|
||||
if self._closed:
|
||||
return
|
||||
self._closed = True
|
||||
with self._active_turn_lock:
|
||||
self._active_turn_id = None
|
||||
if self._client is not None:
|
||||
try:
|
||||
self._client.close()
|
||||
@@ -395,6 +399,33 @@ class CodexAppServerSession:
|
||||
and unwind. Called by AIAgent's _interrupt_requested path."""
|
||||
self._interrupt_event.set()
|
||||
|
||||
def request_steer(self, text: str) -> bool:
|
||||
"""Append user guidance to the active Codex turn via ``turn/steer``."""
|
||||
cleaned = str(text or "").strip()
|
||||
if not cleaned:
|
||||
return False
|
||||
with self._active_turn_lock:
|
||||
turn_id = self._active_turn_id
|
||||
thread_id = self._thread_id
|
||||
client = self._client
|
||||
if not turn_id or not thread_id or client is None:
|
||||
return False
|
||||
try:
|
||||
response = client.request(
|
||||
"turn/steer",
|
||||
{
|
||||
"threadId": thread_id,
|
||||
"input": [{"type": "text", "text": cleaned}],
|
||||
"expectedTurnId": turn_id,
|
||||
},
|
||||
timeout=10,
|
||||
)
|
||||
except (CodexAppServerError, TimeoutError):
|
||||
logger.debug("turn/steer rejected for active Codex turn", exc_info=True)
|
||||
return False
|
||||
accepted_turn_id = response.get("turnId") if isinstance(response, dict) else None
|
||||
return accepted_turn_id in {None, turn_id}
|
||||
|
||||
# ---------- diagnostics ----------
|
||||
|
||||
def _format_error_with_stderr(
|
||||
@@ -469,11 +500,18 @@ class CodexAppServerSession:
|
||||
# Subprocess almost certainly unhealthy — retire so the next
|
||||
# turn re-spawns cleanly.
|
||||
result.should_retire = True
|
||||
self._interrupt_event.clear()
|
||||
return result
|
||||
assert self._client is not None and self._thread_id is not None
|
||||
result.thread_id = self._thread_id
|
||||
|
||||
self._interrupt_event.clear()
|
||||
# Do not clear here: a hard stop can arrive while ensure_started() is
|
||||
# spawning/initializing the subprocess. Honor it before launching a
|
||||
# Codex turn instead of erasing the signal.
|
||||
if self._interrupt_event.is_set():
|
||||
result.interrupted = True
|
||||
self._interrupt_event.clear()
|
||||
return result
|
||||
projector = CodexEventProjector()
|
||||
|
||||
user_input_text = _coerce_turn_input_text(user_input)
|
||||
@@ -505,6 +543,7 @@ class CodexAppServerSession:
|
||||
result.error = self._format_error_with_stderr(
|
||||
"turn/start failed", exc
|
||||
)
|
||||
self._interrupt_event.clear()
|
||||
return result
|
||||
except TimeoutError as exc:
|
||||
# turn/start hanging is a strong signal the subprocess is wedged.
|
||||
@@ -514,9 +553,12 @@ class CodexAppServerSession:
|
||||
"turn/start timed out", exc
|
||||
)
|
||||
result.should_retire = True
|
||||
self._interrupt_event.clear()
|
||||
return result
|
||||
|
||||
result.turn_id = (ts.get("turn") or {}).get("id")
|
||||
with self._active_turn_lock:
|
||||
self._active_turn_id = result.turn_id
|
||||
deadline = time.monotonic() + turn_timeout
|
||||
turn_complete = False
|
||||
# Post-tool watchdog state. last_tool_completion_at is set whenever
|
||||
@@ -741,6 +783,9 @@ class CodexAppServerSession:
|
||||
)
|
||||
result.should_retire = True
|
||||
|
||||
with self._active_turn_lock:
|
||||
self._active_turn_id = None
|
||||
self._interrupt_event.clear()
|
||||
return result
|
||||
|
||||
def compact_thread(
|
||||
|
||||
@@ -26,6 +26,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, Dict, List, Mapping, Optional
|
||||
@@ -255,6 +256,40 @@ def _should_run_preflight_estimate(
|
||||
return estimate_messages_tokens_rough(messages) >= threshold_tokens
|
||||
|
||||
|
||||
def _should_idle_compact(
|
||||
*,
|
||||
enabled: bool,
|
||||
idle_after_seconds: int,
|
||||
idle_gap_seconds: float,
|
||||
tokens: int,
|
||||
floor_tokens: int,
|
||||
cooldown_active: bool,
|
||||
) -> bool:
|
||||
"""Decide whether an idle-triggered compaction should run this turn.
|
||||
|
||||
Idle compaction is opt-in (``idle_after_seconds <= 0`` disables it). It
|
||||
fires when a session resumes after a wall-clock gap of at least
|
||||
``idle_after_seconds`` since its last activity, so a long-lived thread
|
||||
that is paused and later resumed compacts its accumulated history up
|
||||
front instead of re-reading it on every subsequent turn.
|
||||
|
||||
It is orthogonal to the token-threshold trigger: it does NOT require the
|
||||
context to exceed ``threshold_tokens``. It still skips work when the
|
||||
context is at or below ``floor_tokens`` (the size compaction would reduce
|
||||
*to*), so a small idle thread never pays for a summarisation that saves
|
||||
nothing, and it defers to an active compression-failure cooldown.
|
||||
|
||||
Pure predicate so the policy is unit-testable without a live agent.
|
||||
"""
|
||||
if not enabled or idle_after_seconds <= 0:
|
||||
return False
|
||||
if idle_gap_seconds < idle_after_seconds:
|
||||
return False
|
||||
if cooldown_active:
|
||||
return False
|
||||
return tokens > floor_tokens
|
||||
|
||||
|
||||
@dataclass
|
||||
class TurnContext:
|
||||
"""Values produced by the turn prologue and consumed by the turn loop."""
|
||||
@@ -579,6 +614,77 @@ 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(
|
||||
f"💤 Resumed after {int(_idle_gap)}s idle — compacting "
|
||||
f"~{_idle_tokens:,} tokens before continuing."
|
||||
)
|
||||
_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
|
||||
|
||||
@@ -73,6 +73,10 @@ class TurnRetryState:
|
||||
# was rolled back off ``messages`` and the loop should re-issue the API
|
||||
# call against the newly-activated provider (#32421).
|
||||
restart_with_rebuilt_messages: bool = False
|
||||
# A user correction cancelled the in-flight provider request. The outer
|
||||
# loop must append a role-safe checkpoint + user message, rebuild the API
|
||||
# payload, and retry the same logical iteration.
|
||||
restart_with_redirected_messages: bool = False
|
||||
|
||||
def __iter__(self):
|
||||
# Convenience for debugging / tests: iterate (name, value) pairs.
|
||||
|
||||
@@ -8,13 +8,10 @@
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { test } from './test'
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { type MockBackendFixture, setupMockBackend, waitForAppReady } from './fixtures'
|
||||
import { BLOCKING_CLARIFY_QUESTION, BLOCKING_CLARIFY_TRIGGER } from './mock-server'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
@@ -61,7 +58,7 @@ test.describe('chat interaction with mock backend', () => {
|
||||
return (body.textContent ?? '').includes('Hello, can you hear me?')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
{ timeout: 15_000 }
|
||||
)
|
||||
|
||||
// Wait for the mock response to appear. The canned reply is:
|
||||
@@ -81,11 +78,61 @@ test.describe('chat interaction with mock backend', () => {
|
||||
return text.includes('mock inference server') || text.includes('boot chain is working')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
{ timeout: 60_000 }
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot of chat with messages', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
|
||||
})
|
||||
|
||||
test('offers stop, steer, and queue actions while busy', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
const primary = page.locator('[data-slot="composer-root"] button[type="submit"]')
|
||||
const queue = page.locator('[data-slot="composer-root"] button[aria-label="Queue message"]')
|
||||
const dictation = page.locator('[data-slot="composer-root"] button[aria-label="Voice dictation"]')
|
||||
const speakReplies = page.locator(
|
||||
'[data-slot="composer-root"] button[aria-label="Read replies aloud"], [data-slot="composer-root"] button[aria-label="Stop reading replies aloud"]'
|
||||
)
|
||||
|
||||
await composer.click()
|
||||
await composer.type(BLOCKING_CLARIFY_TRIGGER)
|
||||
await page.keyboard.press('Enter')
|
||||
await page.getByText(BLOCKING_CLARIFY_QUESTION).waitFor({ state: 'visible', timeout: 30_000 })
|
||||
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
await expect(primary.locator('span')).toHaveClass(/bg-current/)
|
||||
|
||||
await composer.click()
|
||||
await composer.type('please answer tersely')
|
||||
await expect(primary).toHaveAttribute('aria-label', /Steer/)
|
||||
await expect(dictation).toBeVisible()
|
||||
await expect(speakReplies).toBeVisible()
|
||||
await expect(queue).toBeVisible()
|
||||
await expect(queue.locator('svg.tabler-icon-layers-intersect-2')).toBeVisible()
|
||||
const controlLabels = await page
|
||||
.locator('[data-slot="composer-root"] button')
|
||||
.evaluateAll(buttons => buttons.map(button => button.getAttribute('aria-label')))
|
||||
const speakRepliesIndex = controlLabels.findIndex(
|
||||
label => label === 'Read replies aloud' || label === 'Stop reading replies aloud'
|
||||
)
|
||||
expect(controlLabels.indexOf('Voice dictation')).toBeLessThan(speakRepliesIndex)
|
||||
expect(speakRepliesIndex).toBeLessThan(controlLabels.indexOf('Queue message'))
|
||||
expect(controlLabels.indexOf('Queue message')).toBeLessThan(
|
||||
controlLabels.findIndex(label => label?.startsWith('Steer'))
|
||||
)
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-steer.png') })
|
||||
await expect(primary.locator('svg.tabler-icon-steering-wheel')).toBeVisible()
|
||||
|
||||
await queue.click()
|
||||
await expect(primary).toHaveAttribute('aria-label', 'Stop')
|
||||
await expect(queue).toHaveCount(0)
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue.png') })
|
||||
await expect(page.getByText('1 Queued')).toBeVisible()
|
||||
|
||||
await primary.click()
|
||||
await expect(page.getByText('1 Queued — paused')).toBeVisible()
|
||||
await page.screenshot({ path: testInfo.outputPath('busy-composer-queue-paused.png') })
|
||||
})
|
||||
})
|
||||
|
||||
@@ -27,7 +27,7 @@ import * as path from 'node:path'
|
||||
|
||||
import { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
|
||||
import { startMockServer } from './mock-server'
|
||||
import { startMockServer, type MockServerOptions } from './mock-server'
|
||||
import { installErrorBannerGuard } from './test'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
@@ -140,10 +140,18 @@ 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): void {
|
||||
export function writeMockProviderConfig(hermesHome: string, mockUrl: string, extraConfig?: 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
|
||||
@@ -157,7 +165,7 @@ providers:
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`
|
||||
${displaySection}`
|
||||
|
||||
fs.writeFileSync(configPath, config, 'utf8')
|
||||
}
|
||||
@@ -318,11 +326,21 @@ 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
|
||||
@@ -330,13 +348,17 @@ export interface MockBackendFixture {
|
||||
* 3. Launch the desktop app
|
||||
* 4. Return handles for test interaction
|
||||
*/
|
||||
export async function setupMockBackend(): Promise<MockBackendFixture> {
|
||||
export interface MockBackendOptions {
|
||||
mockServer?: MockServerOptions
|
||||
}
|
||||
|
||||
export async function setupMockBackend(options: MockBackendOptions = {}): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer()
|
||||
const mock = await startMockServer(options.mockServer)
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('mock')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url, options.extraDisplayConfig)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Build env + launch
|
||||
@@ -346,6 +368,7 @@ export async function setupMockBackend(): Promise<MockBackendFixture> {
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
|
||||
@@ -0,0 +1,215 @@
|
||||
/**
|
||||
* E2E test for the interim-assistant-message preservation fix (#65919).
|
||||
*
|
||||
* Reproduces the bug across all three layers (agent core → tui_gateway →
|
||||
* desktop renderer): when the agent emits assistant text alongside a tool
|
||||
* call, then completes the turn with a *different* final answer, the
|
||||
* interim text must survive in the transcript — not be wiped when
|
||||
* message.complete replaces the streaming bubble.
|
||||
*
|
||||
* The mock server walks through a multi-turn script when it sees the
|
||||
* trigger keyword:
|
||||
*
|
||||
* Turn 1: "Let me start by planning the approach." + todo tool_call
|
||||
* Turn 2: "Now checking the details before answering." + todo tool_call
|
||||
* Turn 3: (no text) + todo tool_call → NO interim (no visible text)
|
||||
* Turn 4: "Found something interesting worth noting." + todo tool_call
|
||||
* Turn 5: "All done! Here is the complete summary..." (final, stop)
|
||||
*
|
||||
* Two describe blocks exercise the config flag both ways:
|
||||
*
|
||||
* display.interim_assistant_messages: true (default)
|
||||
* → ALL interim texts AND the final text must be visible in the
|
||||
* transcript.
|
||||
*
|
||||
* display.interim_assistant_messages: false
|
||||
* → only the final text is visible (no message.interim events emitted,
|
||||
* so all streamed interim text is replaced at message.complete).
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { INTERIM_TEXTS, restartMockServer } from './mock-server'
|
||||
|
||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||
|
||||
/** Unique trigger keyword the mock server detects to switch to the script. */
|
||||
const TRIGGER = 'E2E_INTERIM_TRIGGER'
|
||||
|
||||
/**
|
||||
* Send a message and wait for BOTH the user's message and the agent's
|
||||
* final response to appear in the transcript. Returns when the final text
|
||||
* is visible, which means message.complete has fired and the transcript
|
||||
* has settled.
|
||||
*/
|
||||
async function sendInterimMessage(page: Page): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type(TRIGGER, { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's trigger message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_INTERIM_TRIGGER'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// Wait for the agent's FINAL response (last turn). This means
|
||||
// message.complete has fired and the transcript is settled.
|
||||
await page.waitForFunction(
|
||||
(finalText) => (document.body.textContent ?? '').includes(finalText),
|
||||
INTERIM_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
|
||||
// Give the renderer a moment to settle any final state updates
|
||||
// (hydration, session refresh) before asserting.
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
/**
|
||||
* Count how many times `text` appears as distinct text in the chat transcript
|
||||
* (excluding the session sidebar, whose session-preview label shows the
|
||||
* first streamed text as a title).
|
||||
*
|
||||
* The desktop app renders the transcript inside a
|
||||
* `[data-slot="aui_thread-viewport"]` container (from @assistant-ui/react).
|
||||
* The session sidebar's preview labels live outside that container, so
|
||||
* scoping the DOM walk to the viewport cleanly excludes them.
|
||||
*/
|
||||
async function countTranscriptMessagesContaining(page: Page, text: string): Promise<number> {
|
||||
return page.evaluate(
|
||||
(search) => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) {
|
||||
return 0
|
||||
}
|
||||
|
||||
let count = 0
|
||||
const walker = document.createTreeWalker(
|
||||
viewport,
|
||||
NodeFilter.SHOW_ELEMENT,
|
||||
{
|
||||
acceptNode: (node) => {
|
||||
const el = node as HTMLElement
|
||||
const directText = el.textContent ?? ''
|
||||
if (!directText.includes(search)) {
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
// Only count leaf-ish elements to avoid double-counting.
|
||||
const hasChildWithText = Array.from(el.children).some(
|
||||
(child) => (child.textContent ?? '').includes(search),
|
||||
)
|
||||
if (hasChildWithText) {
|
||||
return NodeFilter.FILTER_SKIP
|
||||
}
|
||||
return NodeFilter.FILTER_ACCEPT
|
||||
},
|
||||
},
|
||||
)
|
||||
while (walker.nextNode()) {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
},
|
||||
text,
|
||||
)
|
||||
}
|
||||
|
||||
// ─── Flag ON: interim_assistant_messages = true (default) ─────────────
|
||||
|
||||
test.describe('interim assistant messages — flag ON (default)', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('all interim texts survive alongside the final response', async () => {
|
||||
const page = fixture.page
|
||||
await sendInterimMessage(page)
|
||||
|
||||
// Every interim text (turns with visible text + tool calls) must be
|
||||
// present in the transcript as its own sealed message — NOT wiped by
|
||||
// message.complete.
|
||||
for (const interimText of INTERIM_TEXTS.interims) {
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, interimText),
|
||||
{ timeout: 15_000, message: `interim text "${interimText}" should be visible` },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
}
|
||||
|
||||
// The final text must also be visible.
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
|
||||
{ timeout: 15_000, message: 'final text should be visible' },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
})
|
||||
})
|
||||
|
||||
// ─── Flag OFF: interim_assistant_messages = false ────────────────────
|
||||
|
||||
test.describe('interim assistant messages — flag OFF', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend({
|
||||
extraDisplayConfig: ' interim_assistant_messages: false',
|
||||
})
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('only the final response is visible; all interim texts are wiped', async () => {
|
||||
const page = fixture.page
|
||||
await sendInterimMessage(page)
|
||||
|
||||
// The final text must be visible.
|
||||
await expect
|
||||
.poll(
|
||||
() => countTranscriptMessagesContaining(page, INTERIM_TEXTS.finalText),
|
||||
{ timeout: 15_000, message: 'final text should be visible' },
|
||||
)
|
||||
.toBeGreaterThanOrEqual(1)
|
||||
|
||||
// NONE of the interim texts should be visible — with the flag off,
|
||||
// the tui_gateway never installs interim_assistant_callback, so no
|
||||
// message.interim events are emitted. All streamed interim text is
|
||||
// accumulated into the streaming bubble and replaced by
|
||||
// message.complete.
|
||||
for (const interimText of INTERIM_TEXTS.interims) {
|
||||
const count = await countTranscriptMessagesContaining(page, interimText)
|
||||
expect(
|
||||
count,
|
||||
`interim text "${interimText}" should NOT be visible when flag is off`,
|
||||
).toBe(0)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,235 @@
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { type TestInfo } from '@playwright/test'
|
||||
|
||||
import { expect, test, type ElectronApplication, type Page } from './test'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
type Sandbox,
|
||||
waitForAppReady,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
} from './fixtures'
|
||||
import { MOCK_REPLY, startMockServer, type MockServer, type MockServerOptions } from './mock-server'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const SEED_SCRIPT = path.resolve(import.meta.dirname, 'scripts', 'seed_large_session.py')
|
||||
const SESSION_TITLE = 'E2E large persisted session'
|
||||
const EXPECTED_TEXT = 'E2E persisted user message 52'
|
||||
const BACKGROUND_PROMPT = 'E2E background inference must remain attached across resume'
|
||||
|
||||
interface SeededFixture {
|
||||
app: ElectronApplication
|
||||
mock: MockServer
|
||||
mockUrl: string
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
interface PaintState {
|
||||
bursts: number
|
||||
timeline: Array<{ mutations: number; time: number }>
|
||||
}
|
||||
|
||||
async function setupSeededDesktop(mockServer?: MockServerOptions): Promise<SeededFixture> {
|
||||
const mock = await startMockServer(mockServer)
|
||||
const sandbox = createSandbox('large-session')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const seeded = spawnSync('python3', [SEED_SCRIPT, path.join(sandbox.hermesHome, 'state.db')], {
|
||||
cwd: REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
env: { ...process.env, PYTHONPATH: REPO_ROOT },
|
||||
})
|
||||
if (seeded.status !== 0) {
|
||||
throw new Error(`large-session seed failed:\n${seeded.stdout}\n${seeded.stderr}`)
|
||||
}
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
|
||||
return {
|
||||
app,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function sessionRow(page: Page) {
|
||||
return page.locator('[data-slot="sidebar"] button').filter({ hasText: SESSION_TITLE }).first()
|
||||
}
|
||||
|
||||
async function openSeededSession(page: Page): Promise<void> {
|
||||
const row = sessionRow(page)
|
||||
await row.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
await row.click()
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
EXPECTED_TEXT,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function openNewSession(page: Page): Promise<void> {
|
||||
const button = page.locator('[data-slot="sidebar"] button').filter({ hasText: 'New session' }).first()
|
||||
await button.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await button.click()
|
||||
await page.waitForFunction(
|
||||
expected => !(document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
EXPECTED_TEXT,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function submitPrompt(page: Page, prompt: string): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 15_000 })
|
||||
await composer.click()
|
||||
await composer.type(prompt, { delay: 2 })
|
||||
await page.keyboard.press('Enter')
|
||||
await page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
prompt,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
}
|
||||
|
||||
async function startPaintObserver(page: Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
const state = { bursts: 0, timeline: [] as Array<{ mutations: number; time: number }> }
|
||||
;(window as Window & { __largeSessionPaints?: typeof state }).__largeSessionPaints = state
|
||||
if (!viewport) return
|
||||
|
||||
let additions = 0
|
||||
let flushTimer: ReturnType<typeof setTimeout> | undefined
|
||||
new MutationObserver(records => {
|
||||
additions += records.reduce(
|
||||
(count, record) => count + (record.type === 'childList' && record.addedNodes.length > 0 ? 1 : 0),
|
||||
0,
|
||||
)
|
||||
if (additions === 0) return
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
flushTimer = setTimeout(() => {
|
||||
state.bursts += 1
|
||||
state.timeline.push({ mutations: additions, time: Date.now() })
|
||||
additions = 0
|
||||
}, 30)
|
||||
}).observe(viewport, { childList: true, subtree: true })
|
||||
})
|
||||
}
|
||||
|
||||
async function paintState(page: Page): Promise<PaintState> {
|
||||
const state = await page.evaluate(() => (window as Window & { __largeSessionPaints?: PaintState }).__largeSessionPaints)
|
||||
expect(state, 'paint observer should attach to the thread viewport').toBeDefined()
|
||||
return state!
|
||||
}
|
||||
|
||||
async function textNodeOccurrences(page: Page, expected: string): Promise<number> {
|
||||
return page.evaluate(text => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return 0
|
||||
|
||||
const walker = document.createTreeWalker(viewport, NodeFilter.SHOW_TEXT)
|
||||
let count = 0
|
||||
while (walker.nextNode()) {
|
||||
if (walker.currentNode.textContent?.includes(text)) {
|
||||
count += 1
|
||||
}
|
||||
}
|
||||
return count
|
||||
}, expected)
|
||||
}
|
||||
|
||||
async function reloadIntoColdRenderer(fixture: SeededFixture): Promise<void> {
|
||||
await fixture.page.reload()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
await openNewSession(fixture.page)
|
||||
}
|
||||
|
||||
async function assertUnchangedResume(page: Page, testInfo: TestInfo): Promise<void> {
|
||||
await openSeededSession(page)
|
||||
await page.waitForTimeout(1_000)
|
||||
await page.screenshot({ path: testInfo.outputPath('unchanged-session-resume.png'), fullPage: false })
|
||||
|
||||
const paints = await paintState(page)
|
||||
expect(await textNodeOccurrences(page, EXPECTED_TEXT), 'the resumed user message should appear once').toBe(1)
|
||||
// A warm session first restores its retained view, then reconciles it with the
|
||||
// authoritative transcript. That is bounded at two builds; a third paint was
|
||||
// the old eager-prefetch + runtime-rebuild regression. A cold restore has one.
|
||||
expect(paints.bursts, `unexpected transcript paint count: ${JSON.stringify(paints.timeline)}`).toBeLessThanOrEqual(2)
|
||||
}
|
||||
|
||||
test.describe('large session resume', () => {
|
||||
let fixture: SeededFixture | null = null
|
||||
|
||||
test.afterEach(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('cold resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
|
||||
fixture = await setupSeededDesktop()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await startPaintObserver(fixture.page)
|
||||
await assertUnchangedResume(fixture.page, testInfo)
|
||||
})
|
||||
|
||||
test('fast resume of an unchanged session has one user row and bounded transcript paints', async ({}, testInfo) => {
|
||||
// Known RED: a rapid warm resume rebuilds the transcript three times
|
||||
// (28 → 53 → 53 DOM additions) instead of the two-paint budget. Keep the
|
||||
// regression visible without making unrelated desktop work fail CI.
|
||||
test.fixme(true, 'Fast warm resume has an unresolved third transcript rebuild')
|
||||
|
||||
fixture = await setupSeededDesktop()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
await openNewSession(fixture.page)
|
||||
await startPaintObserver(fixture.page)
|
||||
await assertUnchangedResume(fixture.page, testInfo)
|
||||
})
|
||||
|
||||
for (const resumeKind of ['fast', 'cold'] as const) {
|
||||
test(`${resumeKind} resume keeps background inference attached without duplicate messages`, async ({}, testInfo) => {
|
||||
fixture = await setupSeededDesktop({ holdFirstStreamForPrompt: BACKGROUND_PROMPT })
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
await submitPrompt(fixture.page, BACKGROUND_PROMPT)
|
||||
await fixture.mock.waitForHeldStream()
|
||||
await openNewSession(fixture.page)
|
||||
|
||||
if (resumeKind === 'cold') {
|
||||
await reloadIntoColdRenderer(fixture)
|
||||
}
|
||||
|
||||
await openSeededSession(fixture.page)
|
||||
fixture.mock.releaseHeldStream()
|
||||
await fixture.page.waitForFunction(
|
||||
expected => (document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent ?? '').includes(expected),
|
||||
MOCK_REPLY,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
await fixture.page.waitForTimeout(300)
|
||||
await fixture.page.screenshot({ path: testInfo.outputPath(`${resumeKind}-background-inference-resume.png`), fullPage: false })
|
||||
|
||||
expect(await textNodeOccurrences(fixture.page, BACKGROUND_PROMPT), 'the running user prompt should appear once').toBe(1)
|
||||
expect(await textNodeOccurrences(fixture.page, MOCK_REPLY), 'the completed assistant reply should appear once').toBe(1)
|
||||
})
|
||||
}
|
||||
})
|
||||
+539
-78
@@ -15,17 +15,228 @@
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import type { ServerResponse } from 'node:http'
|
||||
|
||||
/** A canned assistant reply used for every chat completion request. */
|
||||
const CANNED_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
export const MOCK_REPLY = 'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
export interface MockServerOptions {
|
||||
/** Pause the matching stream after its first token for session-switch E2E coverage. */
|
||||
holdFirstStreamForPrompt?: string
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
/** 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
|
||||
_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.' },
|
||||
]
|
||||
|
||||
/**
|
||||
* A marker that makes the mock emit a real blocking clarify tool call. Tests
|
||||
* use it to hold a turn open while exercising busy-composer interactions.
|
||||
*/
|
||||
export const BLOCKING_CLARIFY_TRIGGER = 'E2E_BLOCKING_CLARIFY_TRIGGER'
|
||||
export const BLOCKING_CLARIFY_QUESTION = 'Keep this test turn running?'
|
||||
|
||||
const BLOCKING_CLARIFY_TURN: ScriptedTurn = {
|
||||
text: '',
|
||||
toolCalls: [{ name: 'clarify', args: { question: BLOCKING_CLARIFY_QUESTION, choices: ['Yes', 'No'] } }],
|
||||
}
|
||||
|
||||
function includesBlockingClarifyTrigger(value: unknown): boolean {
|
||||
if (typeof value === 'string') {
|
||||
return value.includes(BLOCKING_CLARIFY_TRIGGER)
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
return value.some(includesBlockingClarifyTrigger)
|
||||
}
|
||||
|
||||
if (value && typeof value === 'object') {
|
||||
return Object.values(value).some(includesBlockingClarifyTrigger)
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the mock server on an ephemeral port.
|
||||
*
|
||||
* @returns a handle with `port`, `url`, and `close()`.
|
||||
* @returns a handle with `port`, `url`, received user prompts, and `close()`.
|
||||
*/
|
||||
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
|
||||
export function startMockServer(options: MockServerOptions = {}): Promise<MockServer> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const receivedPrompts: string[] = []
|
||||
let resolveHeldStreamStarted: (() => void) | null = null
|
||||
let releaseHeldStream: (() => void) | null = null
|
||||
const heldStreamStarted = new Promise<void>(resolveHeld => {
|
||||
resolveHeldStreamStarted = resolveHeld
|
||||
})
|
||||
const heldStreamReleased = new Promise<void>(resolveRelease => {
|
||||
releaseHeldStream = resolveRelease
|
||||
})
|
||||
const server = http.createServer((req, res) => {
|
||||
// CORS headers — the Electron renderer doesn't need them, but they
|
||||
// don't hurt and make the server usable from a browser context too.
|
||||
@@ -75,88 +286,100 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
|
||||
// malformed JSON — treat as non-streaming with defaults
|
||||
}
|
||||
|
||||
const lastUserMessage = [...(parsed.messages ?? [])]
|
||||
.reverse()
|
||||
.find((message: { role?: unknown }) => message?.role === 'user')
|
||||
|
||||
if (typeof lastUserMessage?.content === 'string') {
|
||||
receivedPrompts.push(lastUserMessage.content)
|
||||
}
|
||||
|
||||
const stream = parsed.stream === true
|
||||
const model = parsed.model || 'mock-model'
|
||||
|
||||
if (stream) {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
// 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')
|
||||
|
||||
// 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)
|
||||
if (includesBlockingClarifyTrigger(parsed.messages)) {
|
||||
if (stream) {
|
||||
streamScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
|
||||
} else {
|
||||
nonStreamingScriptedTurn(res, model, BLOCKING_CLARIFY_TURN)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
} else {
|
||||
// Non-streaming response
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: CANNED_REPLY },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 30,
|
||||
},
|
||||
}),
|
||||
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 (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)
|
||||
} else {
|
||||
nonStreamingTextResponse(res, model, MOCK_REPLY)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -187,6 +410,9 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
|
||||
resolve({
|
||||
port,
|
||||
url,
|
||||
receivedPrompts,
|
||||
waitForHeldStream: () => heldStreamStarted,
|
||||
releaseHeldStream: () => releaseHeldStream?.(),
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
@@ -201,3 +427,238 @@ export function startMockServer(): Promise<{ port: number; url: string; close: (
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ─── Response helpers ──────────────────────────────────────────────────
|
||||
|
||||
/** SSE chunk shape for a streaming chat completion. */
|
||||
function sseChunk(model: string, delta: Record<string, unknown>, finishReason: string | null = null): string {
|
||||
return `data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [{ index: 0, delta, finish_reason: finishReason }],
|
||||
})}\n\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a plain text response (no tool calls) as SSE, finishing with
|
||||
* `finish_reason: "stop"`. This is the default canned-reply path.
|
||||
*/
|
||||
function streamTextResponse(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
text: string,
|
||||
waitForRelease?: () => Promise<void>,
|
||||
): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
const words = text.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = (): void => {
|
||||
if (i >= words.length) {
|
||||
res.write(sseChunk(model, {}, 'stop'))
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(sseChunk(model, { content: word }))
|
||||
i++
|
||||
if (waitForRelease && i === 1) {
|
||||
waitForRelease().then(() => setTimeout(sendChunk, 20))
|
||||
return
|
||||
}
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
}
|
||||
|
||||
/** Non-streaming plain text response. */
|
||||
function nonStreamingTextResponse(res: ServerResponse, model: string, text: string): void {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: text },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Stream a single scripted turn: first the text content (word by word),
|
||||
* then a chunk carrying the tool_calls (if any), with the appropriate
|
||||
* finish_reason.
|
||||
*
|
||||
* If the turn has no text and no tool calls, it's an empty final response.
|
||||
* If it has text but no tool calls, it's a final answer (finish_reason: stop).
|
||||
* If it has tool calls (with or without text), finish_reason is "tool_calls".
|
||||
*/
|
||||
function streamScriptedTurn(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
turn: ScriptedTurn,
|
||||
): void {
|
||||
res.writeHead(200, {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
})
|
||||
|
||||
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
|
||||
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
|
||||
|
||||
// If there's no text to stream, go straight to the tool_calls / finish.
|
||||
if (!turn.text) {
|
||||
if (hasToolCalls) {
|
||||
res.write(
|
||||
sseChunk(model, {
|
||||
tool_calls: turn.toolCalls!.map((tc, idx) => ({
|
||||
index: idx,
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
})),
|
||||
}, finishReason),
|
||||
)
|
||||
} else {
|
||||
res.write(sseChunk(model, {}, finishReason))
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// Stream the text word by word, then emit tool_calls if present.
|
||||
const words = turn.text.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = (): void => {
|
||||
if (i >= words.length) {
|
||||
// All text streamed — emit tool_calls if present, then finish.
|
||||
if (hasToolCalls) {
|
||||
res.write(
|
||||
sseChunk(model, {
|
||||
tool_calls: turn.toolCalls!.map((tc, idx) => ({
|
||||
index: idx,
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
})),
|
||||
}, finishReason),
|
||||
)
|
||||
} else {
|
||||
res.write(sseChunk(model, {}, finishReason))
|
||||
}
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(sseChunk(model, { content: word }))
|
||||
i++
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
}
|
||||
|
||||
/** Non-streaming version of a scripted turn. */
|
||||
function nonStreamingScriptedTurn(
|
||||
res: ServerResponse,
|
||||
model: string,
|
||||
turn: ScriptedTurn,
|
||||
): void {
|
||||
const hasToolCalls = turn.toolCalls && turn.toolCalls.length > 0
|
||||
const finishReason = hasToolCalls ? 'tool_calls' : 'stop'
|
||||
|
||||
const message: Record<string, unknown> = { role: 'assistant' }
|
||||
if (turn.text) {
|
||||
message.content = turn.text
|
||||
}
|
||||
if (hasToolCalls) {
|
||||
message.tool_calls = turn.toolCalls!.map((tc, idx) => ({
|
||||
id: `call_e2e_${_scriptIndex}_${idx}`,
|
||||
type: 'function',
|
||||
function: { name: tc.name, arguments: JSON.stringify(tc.args) },
|
||||
}))
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [{ index: 0, message, finish_reason: finishReason }],
|
||||
usage: { prompt_tokens: 10, completion_tokens: 20, total_tokens: 30 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Restart the mock server's script index so each test starts from turn 0.
|
||||
* Call this between tests that use the interim trigger.
|
||||
*/
|
||||
export function restartMockServer(): void {
|
||||
resetScriptIndex()
|
||||
}
|
||||
|
||||
/**
|
||||
* The interim script's text constants, exported for test assertions.
|
||||
* Each entry is the visible text of one turn. Turns with empty text
|
||||
* produce no interim message and are excluded from this list.
|
||||
*/
|
||||
export const INTERIM_TEXTS = {
|
||||
/** All interim texts that should appear as sealed messages when the flag is ON. */
|
||||
interims: INTERIM_SCRIPT
|
||||
.filter((t) => t.text && t.toolCalls)
|
||||
.map((t) => t.text),
|
||||
/** The final answer text. */
|
||||
finalText: INTERIM_SCRIPT[INTERIM_SCRIPT.length - 1].text,
|
||||
/** Text that should NOT produce an interim (empty-text tool turn). */
|
||||
silentTurnIndex: INTERIM_SCRIPT.findIndex((t) => !t.text && t.toolCalls),
|
||||
} as const
|
||||
|
||||
/** The sidebar-states script's text constants, exported for test assertions. */
|
||||
export const SIDEBAR_TEXTS = {
|
||||
/** The interim text from turn 1 (alongside tool calls). */
|
||||
interimText: SIDEBAR_SCRIPT[0].text,
|
||||
/** The final answer text. */
|
||||
finalText: SIDEBAR_SCRIPT[SIDEBAR_SCRIPT.length - 1].text,
|
||||
/** The background process command (for asserting process.list entries). */
|
||||
bgCommand: 'echo "background process output" && sleep 1 && echo "done"',
|
||||
/** The subagent's goal (for asserting subagent panel state). */
|
||||
subagentGoal: 'Summarize the test results',
|
||||
} as const
|
||||
|
||||
/** The cross-session sidebar script's text constants. */
|
||||
export const SIDEBAR_CROSS_TEXTS = {
|
||||
/** The interim text from turn 1. */
|
||||
interimText: SIDEBAR_CROSS_SCRIPT[0].text,
|
||||
/** The final answer text. */
|
||||
finalText: SIDEBAR_CROSS_SCRIPT[SIDEBAR_CROSS_SCRIPT.length - 1].text,
|
||||
/** The longer background process command (sleep 5). */
|
||||
bgCommand: 'echo "long bg output" && sleep 5 && echo "finished"',
|
||||
/** The subagent's goal. */
|
||||
subagentGoal: 'Analyze cross-session state',
|
||||
} as const
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Seed a deterministic, tool-free large session into an isolated state.db."""
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
repo_root = Path(__file__).resolve().parents[3]
|
||||
sys.path.insert(0, str(repo_root))
|
||||
|
||||
from hermes_state import SessionDB # noqa: E402
|
||||
|
||||
SESSION_ID = "e2e-large-session"
|
||||
SESSION_TITLE = "E2E large persisted session"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
if len(sys.argv) != 2:
|
||||
raise SystemExit(f"usage: {sys.argv[0]} <state.db>")
|
||||
|
||||
messages = []
|
||||
for index in range(53):
|
||||
role = "user" if index % 2 == 0 else "assistant"
|
||||
content = (
|
||||
f"E2E persisted user message {index}: audit the compatibility matrix"
|
||||
if role == "user"
|
||||
else f"E2E persisted assistant reply {index}: recorded the audit result"
|
||||
)
|
||||
messages.append({"role": role, "content": content, "timestamp": 1_700_000_000 + index})
|
||||
|
||||
database = SessionDB(db_path=Path(sys.argv[1]))
|
||||
result = database.import_sessions(
|
||||
[
|
||||
{
|
||||
"id": SESSION_ID,
|
||||
"source": "desktop",
|
||||
"model": "mock-model",
|
||||
"started_at": 1_700_000_000,
|
||||
"title": SESSION_TITLE,
|
||||
"cwd": str(repo_root),
|
||||
"system_prompt": "",
|
||||
"messages": messages,
|
||||
}
|
||||
]
|
||||
)
|
||||
database.close()
|
||||
|
||||
if not result.get("ok") or result.get("imported") != 1:
|
||||
raise SystemExit(f"failed to seed large session: {result}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,83 @@
|
||||
/**
|
||||
* 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' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,252 @@
|
||||
/**
|
||||
* E2E tests for desktop sidebar states — background processes, subagents,
|
||||
* and session dot transitions.
|
||||
*
|
||||
* The mock server returns scripted tool_calls that the agent executes for
|
||||
* real (trivial commands + real subagent delegations). The tests assert the
|
||||
* sidebar states driven by real gateway events.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test, type Page } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { SIDEBAR_CROSS_TEXTS, SIDEBAR_TEXTS, restartMockServer } from './mock-server'
|
||||
|
||||
/** Background-running dot aria-label (from i18n en.ts). */
|
||||
const BG_DOT_LABEL = 'Background task running'
|
||||
/** Finished-unread dot aria-label. */
|
||||
const UNREAD_DOT_LABEL = 'Finished — unread'
|
||||
|
||||
/** Send a message and wait for the final response to appear. */
|
||||
async function sendMessageAndWait(
|
||||
page: Page,
|
||||
trigger: string,
|
||||
finalText: string,
|
||||
timeout = 90_000,
|
||||
): Promise<void> {
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type(trigger, { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
finalText,
|
||||
{ timeout },
|
||||
)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 1: background process + subagent appear in sidebar during turn
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — background process and subagent', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('background process dot appears and disappears, subagent runs, final answer visible', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await sendMessageAndWait(page, 'E2E_SIDEBAR_TRIGGER', SIDEBAR_TEXTS.finalText)
|
||||
|
||||
// The background process (sleep 1) should have shown a "Background task
|
||||
// running" dot at some point during the turn. We try to catch it; if
|
||||
// the process was too fast, that's OK — the real assertion is that the
|
||||
// final answer appeared and the dot is gone afterward.
|
||||
try {
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 15_000, message: 'background dot should appear' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
} catch {
|
||||
// sleep 1 may have finished before we polled — not a failure.
|
||||
}
|
||||
|
||||
// After the turn completes and auto-dismiss fires, the background dot
|
||||
// should be gone.
|
||||
await page.waitForTimeout(8000)
|
||||
const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
|
||||
expect(bgCount, 'background dot should be gone after auto-dismiss').toBe(0)
|
||||
|
||||
// Evidence: capture the final state — no background dot, final answer visible.
|
||||
await page.screenshot({ path: 'test-results/bg-dot-gone-after-dismiss.png' })
|
||||
|
||||
// The final answer text must be in the transcript.
|
||||
const viewportText = await page
|
||||
.locator('[data-slot="aui_thread-viewport"]')
|
||||
.textContent()
|
||||
expect(viewportText).toContain(SIDEBAR_TEXTS.finalText)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 2: subagent running shows background dot too (longer bg process)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — subagent and background dot coexist', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('background dot visible while subagent runs', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
// Start the turn but DON'T wait for the final answer yet — we want
|
||||
// to assert the background dot is visible WHILE the subagent runs.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// The background process (sleep 5) should show a "Background task
|
||||
// running" dot while the subagent is also running.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should appear while subagent runs' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Evidence: the background dot is visible while the subagent runs.
|
||||
await page.screenshot({ path: 'test-results/bg-dot-while-subagent-runs.png' })
|
||||
|
||||
// Now wait for the final answer to appear.
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
|
||||
// After the turn + auto-dismiss, the background dot should be gone.
|
||||
await page.waitForTimeout(8000)
|
||||
const bgCount = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
|
||||
expect(bgCount, 'background dot should be gone after process exits').toBe(0)
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 3: cross-session — dot updates when viewing a different session
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — cross-session dot transition', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('background dot transitions to finished when viewing another session', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
// Start a turn with a long background process (sleep 5).
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the background dot to appear.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should appear' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Wait for the final answer (turn completes, but bg process still running).
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
|
||||
// The background dot should still be visible (sleep 5 hasn't finished yet,
|
||||
// or auto-dismiss hasn't fired).
|
||||
const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
|
||||
expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0)
|
||||
|
||||
// Evidence: bg dot visible on session A while its turn is done but the
|
||||
// background process hasn't exited yet.
|
||||
await page.screenshot({ path: 'test-results/cross-session-bg-dot-before-switch.png' })
|
||||
|
||||
// Create a new session (click "New session" button).
|
||||
await page.locator('button:has-text("New session")').first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Now wait for the background process to finish (sleep 5 + auto-dismiss).
|
||||
// The session A dot should transition away from "background running".
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
|
||||
)
|
||||
.toBe(0)
|
||||
|
||||
// The original session should show a "finished unread" indicator (green dot)
|
||||
// since its turn completed while we were in a different session. This is an
|
||||
// event-driven transition, so wait for it instead of sampling the DOM right
|
||||
// after the running dot disappears.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'original session should show finished-unread dot' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Evidence: the green "finished unread" dot on the original session after
|
||||
// switching to a new session — the cross-session dot transition.
|
||||
await page.screenshot({ path: 'test-results/cross-session-unread-dot-after-switch.png' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* Regression coverage for #69578: harmless route-token churn during a send
|
||||
* must not make the desktop silently drop the prompt before prompt.submit.
|
||||
*/
|
||||
|
||||
import { test, expect } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
|
||||
const PROMPT = 'E2E route token drift must still submit this prompt.'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('submits while same-chat search tokens churn during new-session creation', async ({}, testInfo) => {
|
||||
const { page, mock } = fixture!
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await composer.click()
|
||||
await composer.type(PROMPT, { delay: 10 })
|
||||
|
||||
// The submit pipeline snapshots the route synchronously, then awaits session
|
||||
// creation. Keep changing only the query string of whichever chat route is
|
||||
// current. Before #69578, comparing the raw route token treated this as a
|
||||
// user chat switch and aborted before prompt.submit.
|
||||
await page.evaluate(() => {
|
||||
let revision = 0
|
||||
const interval = window.setInterval(() => {
|
||||
const pathname = window.location.hash.slice(1).split(/[?#]/, 1)[0] || '/new'
|
||||
window.location.hash = `${pathname}?e2e-route-churn=${revision++}`
|
||||
}, 1)
|
||||
|
||||
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn = () => {
|
||||
window.clearInterval(interval)
|
||||
}
|
||||
})
|
||||
|
||||
try {
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
await expect
|
||||
.poll(() => mock.receivedPrompts.includes(PROMPT), { timeout: 60_000 })
|
||||
.toBe(true)
|
||||
|
||||
await page.waitForFunction(
|
||||
prompt => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(prompt) ?? false,
|
||||
PROMPT,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
await page.waitForFunction(
|
||||
() => document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes('mock inference server') ?? false,
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
} finally {
|
||||
await page.evaluate(() => {
|
||||
;(window as typeof window & { __e2eStopRouteChurn?: () => void }).__e2eStopRouteChurn?.()
|
||||
})
|
||||
}
|
||||
|
||||
await page.screenshot({ path: testInfo.outputPath('same-chat-route-churn-submitted.png') })
|
||||
})
|
||||
@@ -0,0 +1,210 @@
|
||||
/**
|
||||
* E2E tests for the tile-unread bug — two scenarios:
|
||||
*
|
||||
* 1. TAB (stacked, not visible) — a session opened as a tab via ⌃-click is
|
||||
* NOT visible on screen. When it finishes, the green "unread" dot IS
|
||||
* correct — the user isn't looking at it. This test PASSES.
|
||||
*
|
||||
* 2. SPLIT (side-by-side, visible) — a session dragged to the edge of the
|
||||
* workspace zone opens as a split tile, visible on screen at the same time
|
||||
* as the main session. When it finishes, it should NOT get the green
|
||||
* "unread" dot — the user is looking right at it. This test FAILS until
|
||||
* the fix in session-states.ts:174 lands (the unread check only compares
|
||||
* against $selectedStoredSessionId and ignores $sessionTiles).
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { SIDEBAR_CROSS_TEXTS, restartMockServer } from './mock-server'
|
||||
|
||||
/** Finished-unread dot aria-label. */
|
||||
const UNREAD_DOT_LABEL = 'Finished — unread'
|
||||
/** Background-running dot aria-label. */
|
||||
const BG_DOT_LABEL = 'Background task running'
|
||||
|
||||
/** Locate a session's sidebar row by its preview text. */
|
||||
function sessionRow(page: import('@playwright/test').Page, text: string) {
|
||||
return page.locator('[data-slot="sidebar"] button').filter({ hasText: text }).first()
|
||||
}
|
||||
|
||||
/** Common setup: start a turn with a sleep 5 bg process + subagent, wait for
|
||||
* the turn to complete, then switch to a new session so the first session is
|
||||
* no longer $selectedStoredSessionId (required before opening a tile). */
|
||||
async function startTurnAndSwitchAway(page: import('@playwright/test').Page) {
|
||||
// Send E2E_SIDEBAR_CROSS — starts a turn with sleep 5 + subagent.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
await composer.click()
|
||||
await composer.type('E2E_SIDEBAR_CROSS', { delay: 20 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('E2E_SIDEBAR_CROSS'),
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// Wait for the background dot — confirms the turn is running.
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should appear' },
|
||||
)
|
||||
.toBeGreaterThan(0)
|
||||
|
||||
// Wait for the turn to complete (final answer visible).
|
||||
await page.waitForFunction(
|
||||
(text) => (document.body.textContent ?? '').includes(text),
|
||||
SIDEBAR_CROSS_TEXTS.finalText,
|
||||
{ timeout: 90_000 },
|
||||
)
|
||||
|
||||
// The background dot should still be visible (sleep 5 hasn't finished).
|
||||
const bgDuringTurn = await page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count()
|
||||
expect(bgDuringTurn, 'background dot should still be visible after turn completes').toBeGreaterThan(0)
|
||||
|
||||
// Switch to a new session — session A is no longer $selectedStoredSessionId.
|
||||
// This is required: openSessionTile bails if the session is already selected.
|
||||
await page.locator('button:has-text("New session")').first().click()
|
||||
await page.waitForTimeout(2000)
|
||||
}
|
||||
|
||||
/** Wait for the background process to finish (sleep 5 + auto-dismiss). */
|
||||
async function waitForBgProcessToFinish(page: import('@playwright/test').Page) {
|
||||
await expect
|
||||
.poll(
|
||||
() => page.locator(`[aria-label="${BG_DOT_LABEL}"]`).count(),
|
||||
{ timeout: 30_000, message: 'background dot should disappear after process finishes' },
|
||||
)
|
||||
.toBe(0)
|
||||
}
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 1: TAB (not visible) — unread dot IS correct (PASSES)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe('sidebar states — tab (hidden) unread is correct', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('session opened as a tab (not visible) correctly gets unread dot', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await startTurnAndSwitchAway(page)
|
||||
|
||||
// Evidence: session A is in the background (bg dot in sidebar).
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-switched-away.png' })
|
||||
|
||||
// ⌃-click opens the session as a TAB (center dock = stacked, not visible
|
||||
// unless it's the active tab). The session is NOT on screen.
|
||||
const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText)
|
||||
await row.click({ modifiers: ['Control'] })
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Evidence: the tab is open but the session is not visible on screen.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-opened.png' })
|
||||
|
||||
await waitForBgProcessToFinish(page)
|
||||
|
||||
// A tab that's not the active tab IS hidden — the unread dot is correct.
|
||||
// The user is NOT looking at it, so marking it "unread" is right.
|
||||
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
|
||||
expect(unreadCount, 'hidden tab should be marked unread').toBeGreaterThan(0)
|
||||
|
||||
await page.screenshot({ path: 'test-results/tile-bug-tab-unread-correct.png' })
|
||||
})
|
||||
})
|
||||
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
// Test 2: SPLIT (visible) — unread dot is WRONG (FAILS until fix)
|
||||
// ────────────────────────────────────────────────────────────────────────
|
||||
|
||||
test.describe.skip('sidebar states — split (visible) unread bug (RED)', () => {
|
||||
test.describe.configure({ mode: 'serial' })
|
||||
|
||||
let fixture: MockBackendFixture
|
||||
|
||||
test.beforeAll(async () => {
|
||||
restartMockServer()
|
||||
fixture = await setupMockBackend()
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
})
|
||||
|
||||
test('session visible in a split tile does NOT get unread dot when it finishes', async () => {
|
||||
const page = fixture.page
|
||||
|
||||
await startTurnAndSwitchAway(page)
|
||||
|
||||
// Evidence: session A is in the background (bg dot in sidebar).
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-switched-away.png' })
|
||||
|
||||
// Drag the session row from the sidebar to the right edge of the workspace
|
||||
// zone to create a SPLIT (side-by-side) tile. This triggers the real
|
||||
// startSessionDrag → onCommit → openSessionTile(id, 'right', anchor) path.
|
||||
const row = sessionRow(page, SIDEBAR_CROSS_TEXTS.finalText)
|
||||
const rowBox = await row.boundingBox()
|
||||
expect(rowBox, 'session row must be visible').not.toBeNull()
|
||||
|
||||
// Find the workspace zone — the main chat area. We drop on its right edge.
|
||||
const workspace = page.locator('[data-session-anchor="workspace"]')
|
||||
const wsBox = await workspace.boundingBox()
|
||||
expect(wsBox, 'workspace zone must be visible').not.toBeNull()
|
||||
|
||||
// Drag from the session row to the right edge of the workspace.
|
||||
// The drag-session's subZonePosition resolves a right-edge drop as 'right'
|
||||
// (a split dock), not 'center' (which would be a composer link).
|
||||
await page.mouse.move(rowBox!.x + rowBox!.width / 2, rowBox!.y + rowBox!.height / 2)
|
||||
await page.mouse.down()
|
||||
// Move in steps so the drag-session's pointermove handler tracks the
|
||||
// position and resolves the drop zone (a single jump can miss the
|
||||
// threshold/engage logic).
|
||||
const targetX = wsBox!.x + wsBox!.width - 20
|
||||
const targetY = wsBox!.y + wsBox!.height / 2
|
||||
const steps = 10
|
||||
for (let i = 1; i <= steps; i++) {
|
||||
const x = rowBox!.x + rowBox!.width / 2 + (targetX - (rowBox!.x + rowBox!.width / 2)) * (i / steps)
|
||||
const y = rowBox!.y + rowBox!.height / 2 + (targetY - (rowBox!.y + rowBox!.height / 2)) * (i / steps)
|
||||
await page.mouse.move(x, y)
|
||||
await page.waitForTimeout(30)
|
||||
}
|
||||
await page.mouse.up()
|
||||
await page.waitForTimeout(2000)
|
||||
|
||||
// Evidence: the split tile is now open side-by-side — both sessions visible.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-opened.png' })
|
||||
|
||||
await waitForBgProcessToFinish(page)
|
||||
|
||||
// THE BUG: the session visible in the split tile should NOT have the green
|
||||
// "finished unread" dot — the user is looking right at it. This assertion
|
||||
// FAILS until the fix in session-states.ts:174 lands.
|
||||
const unreadCount = await page.locator(`[aria-label="${UNREAD_DOT_LABEL}"]`).count()
|
||||
expect(unreadCount, 'session visible in a split tile should NOT be marked unread').toBe(0)
|
||||
|
||||
// Evidence: the green dot should NOT be here — this screenshot shows the bug.
|
||||
await page.screenshot({ path: 'test-results/tile-bug-split-unread-should-not-exist.png' })
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,484 @@
|
||||
/**
|
||||
* E2E regression: warm-route resume must not re-render the transcript more
|
||||
* than once.
|
||||
*
|
||||
* When a session is already in the runtime-id cache (the "warm" path in
|
||||
* `resumeSession()`), clicking its sidebar row should paint the transcript
|
||||
* exactly once. Before the fix, the warm cache painted via
|
||||
* `syncSessionStateToView`, then the `session.activate` RPC returned a
|
||||
* reconciled message list with different message object references, causing
|
||||
* `syncSessionStateToView` to fire a second `setMessages` — a visual
|
||||
* flicker as the transcript DOM was updated.
|
||||
*
|
||||
* This test pre-seeds a 32-message session into state.db, boots the app,
|
||||
* clicks the session (cold resume — populates the warm cache), navigates
|
||||
* away to a new chat, then clicks back (warm resume). Two detectors run:
|
||||
*
|
||||
* 1. A MutationObserver counts additive DOM mutation bursts (childList
|
||||
* additions). More than 1 burst = the transcript was repainted.
|
||||
*
|
||||
* 2. A 2ms innerHTML-length poll counts "reconciles" — DOM content changes
|
||||
* that happen AFTER the initial paint, while messages are already on
|
||||
* screen. This catches the case where React reconciles by key without
|
||||
* adding/removing nodes (same keys → in-place prop update → no
|
||||
* MutationObserver burst), but `$messages` was still set twice.
|
||||
*
|
||||
* The test passes when bursts === 1 AND reconciles === 0.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as os from 'node:os'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { expect, test } from './test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
createSandbox,
|
||||
writeMockProviderConfig,
|
||||
writeEnvFile,
|
||||
buildAppEnv,
|
||||
launchDesktop,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const SEED_SCRIPT = path.join(DESKTOP_ROOT, 'e2e', 'scripts', 'seed_session_db.py')
|
||||
const SESSION_TITLE = 'E2E Warm Resume Jitter Test'
|
||||
const SESSION_ID = 'e2e-warm-resume-session'
|
||||
/** 32 messages (16 user/assistant pairs) — enough DOM churn for detection. */
|
||||
const MESSAGE_COUNT = 32
|
||||
/** Seeded PRNG so the generated content is deterministic across runs. */
|
||||
const RNG_SEED = 42
|
||||
|
||||
/** Mulberry32 — tiny deterministic PRNG. */
|
||||
function mulberry32(seed: number): () => number {
|
||||
let a = seed
|
||||
return () => {
|
||||
a |= 0
|
||||
a = (a + 0x6d2b79f5) | 0
|
||||
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
||||
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
||||
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
||||
}
|
||||
}
|
||||
|
||||
/** Generate ~40 chars of gibberish from a seeded PRNG. */
|
||||
function gibberish(rng: () => number): string {
|
||||
const len = 30 + Math.floor(rng() * 20)
|
||||
let s = ''
|
||||
for (let i = 0; i < len; i++) {
|
||||
s += String.fromCharCode(97 + Math.floor(rng() * 26))
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
/** First user message — used as a wait target in the test. */
|
||||
const FIRST_USER_MSG = gibberish(mulberry32(RNG_SEED))
|
||||
|
||||
/**
|
||||
* Generate a session fixture with MESSAGE_COUNT messages (user/assistant
|
||||
* pairs) of seeded gibberish — just role + content, enough for SessionDB
|
||||
* to import and the transcript to render. Written to a temp file for the
|
||||
* seed script.
|
||||
*/
|
||||
function generateSessionFixture(fixturePath: string): void {
|
||||
const rng = mulberry32(RNG_SEED)
|
||||
const messages: Array<{ role: string; content: string }> = []
|
||||
|
||||
for (let i = 0; i < MESSAGE_COUNT / 2; i++) {
|
||||
messages.push({ role: 'user', content: gibberish(rng) })
|
||||
messages.push({ role: 'assistant', content: gibberish(rng) })
|
||||
}
|
||||
|
||||
const session = {
|
||||
id: SESSION_ID,
|
||||
source: 'cli',
|
||||
model: 'mock-model',
|
||||
system_prompt: '',
|
||||
started_at: 1721692800.0,
|
||||
message_count: MESSAGE_COUNT,
|
||||
title: SESSION_TITLE,
|
||||
cwd: '/tmp',
|
||||
archived: 0,
|
||||
rewind_count: 0,
|
||||
compression_fallback_streak: 0,
|
||||
messages,
|
||||
}
|
||||
|
||||
fs.writeFileSync(fixturePath, JSON.stringify(session), 'utf8')
|
||||
}
|
||||
|
||||
/** Resolve the python binary from the nix devshell (falls back to python3). */
|
||||
function findPython(): string {
|
||||
const result = spawnSync('which', ['python'], { encoding: 'utf8' })
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim()
|
||||
}
|
||||
return 'python3'
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a mock-backend sandbox with a pre-seeded session in state.db.
|
||||
*
|
||||
* Unlike the shared `setupMockBackend()`, this variant seeds the DB
|
||||
* BEFORE launching the app so the session appears in the sidebar on first
|
||||
* load — exercising the real `resumeSession()` cold path without needing
|
||||
* to send a message first.
|
||||
*/
|
||||
async function setupSeededMockBackend(): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer()
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('warm-seed')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Pre-seed state.db: generate a fixture JSON to a temp file, then
|
||||
// run the seed script to import it into state.db BEFORE launching.
|
||||
const stateDbPath = path.join(sandbox.hermesHome, 'state.db')
|
||||
const fixturePath = path.join(os.tmpdir(), `hermes-e2e-warm-resume-${Date.now()}.json`)
|
||||
generateSessionFixture(fixturePath)
|
||||
const python = findPython()
|
||||
const seedResult = spawnSync(
|
||||
python,
|
||||
[SEED_SCRIPT, stateDbPath, fixturePath],
|
||||
{
|
||||
cwd: REPO_ROOT,
|
||||
env: { ...process.env, PYTHONPATH: REPO_ROOT },
|
||||
encoding: 'utf8',
|
||||
timeout: 30_000,
|
||||
},
|
||||
)
|
||||
fs.unlinkSync(fixturePath)
|
||||
|
||||
if (seedResult.status !== 0) {
|
||||
throw new Error(
|
||||
`Failed to seed state.db:\nstdout: ${seedResult.stdout}\nstderr: ${seedResult.stderr}`,
|
||||
)
|
||||
}
|
||||
|
||||
// 4. Build env + launch
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupSeededMockBackend()
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
/**
|
||||
* Install a MutationObserver + text-content poll on the thread viewport
|
||||
* to detect re-renders after the initial paint. Returns nothing — call
|
||||
* `readRenderCount` to stop and collect results.
|
||||
*
|
||||
* - MutationObserver: counts additive childList bursts (5ms coalescing).
|
||||
* - Text-content poll: counts "reconciles" — first-message text changes
|
||||
* after the initial paint, catching key-based reconciles that don't
|
||||
* add/remove nodes.
|
||||
*/
|
||||
async function installRenderCounter(page: import('@playwright/test').Page): Promise<void> {
|
||||
await page.evaluate(() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) {
|
||||
throw new Error('Thread viewport not found before warm resume')
|
||||
}
|
||||
|
||||
const state = { bursts: 0, mutations: 0, timeline: [] as number[], stopped: false, reconciles: 0 }
|
||||
;(window as unknown as { __RENDER_COUNT__: typeof state }).__RENDER_COUNT__ = state
|
||||
|
||||
let currentBatch = 0
|
||||
let flushTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
const flush = () => {
|
||||
flushTimer = null
|
||||
if (currentBatch > 0 && !state.stopped) {
|
||||
state.bursts += 1
|
||||
state.timeline.push(currentBatch)
|
||||
currentBatch = 0
|
||||
}
|
||||
}
|
||||
|
||||
const observer = new MutationObserver(records => {
|
||||
if (state.stopped) return
|
||||
let batchAdded = 0
|
||||
for (const record of records) {
|
||||
state.mutations += 1
|
||||
if (record.type === 'childList' && record.addedNodes.length > 0) {
|
||||
batchAdded += 1
|
||||
}
|
||||
}
|
||||
if (batchAdded > 0) {
|
||||
currentBatch += batchAdded
|
||||
if (flushTimer) clearTimeout(flushTimer)
|
||||
flushTimer = setTimeout(flush, 5)
|
||||
}
|
||||
})
|
||||
|
||||
observer.observe(viewport, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
attributes: false,
|
||||
characterData: false,
|
||||
})
|
||||
|
||||
// Poll the first message's text content every 2ms. The MutationObserver
|
||||
// only catches childList additions; React may reconcile by key without
|
||||
// adding/removing nodes (same keys → in-place prop update → no childList
|
||||
// mutation). The poll catches this by detecting text content changes in
|
||||
// the first message after the initial paint. Metadata-only changes (model
|
||||
// name, busy indicator) don't affect message text, so they don't produce
|
||||
// false positives.
|
||||
const contentEl = viewport.querySelector('[data-slot="aui_thread-content"]') ?? viewport
|
||||
let lastFirstMsgText = ''
|
||||
let hasMessages = false
|
||||
const pollInterval = setInterval(() => {
|
||||
if (state.stopped) {
|
||||
clearInterval(pollInterval)
|
||||
return
|
||||
}
|
||||
const firstMsg = contentEl.querySelector('[data-role="message"], [data-message-id]')
|
||||
const firstMsgText = firstMsg?.textContent ?? ''
|
||||
if (firstMsgText && firstMsgText !== lastFirstMsgText) {
|
||||
if (hasMessages) {
|
||||
state.reconciles = (state.reconciles ?? 0) + 1
|
||||
}
|
||||
lastFirstMsgText = firstMsgText
|
||||
hasMessages = true
|
||||
}
|
||||
}, 2)
|
||||
})
|
||||
}
|
||||
|
||||
/** Stop the render counter and return the recorded burst/reconcile counts. */
|
||||
async function readRenderCount(page: import('@playwright/test').Page): Promise<{
|
||||
bursts: number
|
||||
mutations: number
|
||||
timeline: number[]
|
||||
reconciles: number
|
||||
} | null> {
|
||||
return page.evaluate(() => {
|
||||
type RenderCount = { bursts: number; mutations: number; timeline: number[]; stopped: boolean; reconciles: number }
|
||||
const w = window as unknown as { __RENDER_COUNT__?: RenderCount }
|
||||
const rc = w.__RENDER_COUNT__
|
||||
if (rc) {
|
||||
rc.stopped = true
|
||||
}
|
||||
return rc ? { bursts: rc.bursts, mutations: rc.mutations, timeline: rc.timeline, reconciles: rc.reconciles } : null
|
||||
})
|
||||
}
|
||||
|
||||
/** Assert the render counter shows exactly one paint with no re-renders. */
|
||||
function assertNoJitter(result: { bursts: number; mutations: number; timeline: number[]; reconciles: number } | null): void {
|
||||
expect(result, 'MutationObserver should have recorded render data').toBeTruthy()
|
||||
expect(
|
||||
result!.bursts,
|
||||
`Expected 1 additive render burst (single paint), but got ${result!.bursts} bursts. ` +
|
||||
`Mutation timeline: ${JSON.stringify(result!.timeline)}.`,
|
||||
).toBe(1)
|
||||
expect(
|
||||
result!.reconciles,
|
||||
`Expected 0 reconciles (no re-render after initial paint), but got ${result!.reconciles}. ` +
|
||||
`This means the warm-route resume re-rendered the transcript after the initial paint ` +
|
||||
`— the "warm resume jitter" bug is present.`,
|
||||
).toBe(0)
|
||||
}
|
||||
|
||||
test('warm-route resume paints transcript exactly once (no jitter)', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
|
||||
// Wait for the sidebar to populate with our seeded session.
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
// Step 1: Cold resume — click the session row to load it.
|
||||
// This populates the warm cache (runtimeIdByStoredSessionId + sessionStateByRuntimeId).
|
||||
await sessionRow.click()
|
||||
|
||||
// Wait for the transcript to appear — the first user message text confirms
|
||||
// the cold-path prefetch painted.
|
||||
await page.waitForFunction(
|
||||
(text: string) =>
|
||||
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
|
||||
false,
|
||||
FIRST_USER_MSG,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
// Wait for the session to fully settle (cold-path RPC + reconciliation).
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Step 2: Navigate away to a new chat — this does NOT evict the warm cache.
|
||||
const newSessionButton = page
|
||||
.locator('[data-slot="sidebar"] button[aria-label="New session"]')
|
||||
.first()
|
||||
await newSessionButton.click()
|
||||
|
||||
// Wait for the new-chat empty state.
|
||||
await page.waitForFunction(
|
||||
(firstMsg: string) => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return false
|
||||
const text = viewport.textContent ?? ''
|
||||
return !text.includes(firstMsg)
|
||||
},
|
||||
FIRST_USER_MSG,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Step 3: Install render counter, click back (warm resume), wait, assert.
|
||||
await installRenderCounter(page)
|
||||
await sessionRow.click()
|
||||
|
||||
await page.waitForFunction(
|
||||
(text: string) =>
|
||||
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
|
||||
false,
|
||||
FIRST_USER_MSG,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
// Wait for at least 1 burst, then settle.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
|
||||
return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
const result = await readRenderCount(page)
|
||||
await page.screenshot({ path: testInfo.outputPath('warm-resume-idle.png') })
|
||||
assertNoJitter(result)
|
||||
})
|
||||
|
||||
test('warm-route resume after background inference completes (no jitter)', async ({}, testInfo) => {
|
||||
test.fixme(
|
||||
true,
|
||||
'Warm resume repaints after inference: expected one additive burst, got two ([18,1]).',
|
||||
)
|
||||
|
||||
const page = fixture!.page
|
||||
const { mock } = fixture!
|
||||
|
||||
// Wait for the sidebar to populate with our seeded session.
|
||||
const sessionRow = page
|
||||
.locator('[data-slot="sidebar"] button')
|
||||
.filter({ hasText: SESSION_TITLE })
|
||||
.first()
|
||||
await sessionRow.waitFor({ state: 'visible', timeout: 60_000 })
|
||||
|
||||
// Step 1: Cold resume — populate the warm cache.
|
||||
await sessionRow.click()
|
||||
await page.waitForFunction(
|
||||
(text: string) =>
|
||||
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
|
||||
false,
|
||||
FIRST_USER_MSG,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Step 2: Send a message — triggers inference via the mock server.
|
||||
const PROMPT = 'E2E post-inference warm resume test prompt'
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type(PROMPT, { delay: 10 })
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the mock response to appear in the transcript, confirming
|
||||
// the turn completed and message.complete fired (which updates the warm
|
||||
// cache via updateSessionState).
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
return viewport?.textContent?.includes('mock inference server') ?? false
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
// Extra settle for message.complete → updateSessionState → cache write.
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
// Verify the prompt was received by the mock server.
|
||||
expect(mock.receivedPrompts).toContain(PROMPT)
|
||||
|
||||
// Step 3: Navigate away — the warm cache retains the updated messages.
|
||||
const newSessionButton = page
|
||||
.locator('[data-slot="sidebar"] button[aria-label="New session"]')
|
||||
.first()
|
||||
await newSessionButton.click()
|
||||
await page.waitForFunction(
|
||||
(prompt: string) => {
|
||||
const viewport = document.querySelector('[data-slot="aui_thread-viewport"]')
|
||||
if (!viewport) return false
|
||||
return !(viewport.textContent ?? '').includes(prompt)
|
||||
},
|
||||
PROMPT,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Step 4: Install render counter, click back (warm resume), wait, assert.
|
||||
await installRenderCounter(page)
|
||||
await sessionRow.click()
|
||||
|
||||
// Wait for the transcript to reappear — the warm cache should already
|
||||
// have the completed turn (updated by message.complete events).
|
||||
await page.waitForFunction(
|
||||
(text: string) =>
|
||||
document.querySelector('[data-slot="aui_thread-viewport"]')?.textContent?.includes(text) ??
|
||||
false,
|
||||
FIRST_USER_MSG,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
|
||||
// Wait for at least 1 burst, then settle.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const w = window as unknown as { __RENDER_COUNT__?: { bursts: number } }
|
||||
return Boolean(w.__RENDER_COUNT__ && w.__RENDER_COUNT__.bursts > 0)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
await page.waitForTimeout(2_000)
|
||||
|
||||
const result = await readRenderCount(page)
|
||||
await page.screenshot({ path: testInfo.outputPath('warm-resume-post-inference.png') })
|
||||
assertNoJitter(result)
|
||||
})
|
||||
@@ -0,0 +1,94 @@
|
||||
import { execFileSync } from 'node:child_process'
|
||||
import * as fs from 'node:fs'
|
||||
import * as path from 'node:path'
|
||||
|
||||
import { test, expect } from './test'
|
||||
|
||||
import {
|
||||
buildAppEnv,
|
||||
createSandbox,
|
||||
launchDesktop,
|
||||
writeEnvFile,
|
||||
writeMockProviderConfig,
|
||||
type MockBackendFixture,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { startMockServer } from './mock-server'
|
||||
|
||||
const BRANCH_NAME = 'e2e-composer-branch'
|
||||
|
||||
function createGitRepo(root: string): string {
|
||||
const repo = path.join(root, 'repo')
|
||||
|
||||
fs.mkdirSync(repo, { recursive: true })
|
||||
execFileSync('git', ['init', '--initial-branch=main'], { cwd: repo })
|
||||
execFileSync('git', ['config', 'user.email', 'e2e@example.com'], { cwd: repo })
|
||||
execFileSync('git', ['config', 'user.name', 'Hermes E2E'], { cwd: repo })
|
||||
fs.writeFileSync(path.join(repo, 'README.md'), '# E2E repo\n', 'utf8')
|
||||
execFileSync('git', ['add', 'README.md'], { cwd: repo })
|
||||
execFileSync('git', ['commit', '-m', 'initial'], { cwd: repo })
|
||||
|
||||
return repo
|
||||
}
|
||||
|
||||
function configureRepoCwd(hermesHome: string, mockUrl: string, repo: string): void {
|
||||
writeMockProviderConfig(hermesHome, mockUrl)
|
||||
fs.appendFileSync(path.join(hermesHome, 'config.yaml'), `\nterminal:\n cwd: ${repo}\n`, 'utf8')
|
||||
writeEnvFile(hermesHome)
|
||||
}
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const sandbox = createSandbox('worktree-branch-status')
|
||||
const repo = createGitRepo(sandbox.root)
|
||||
const mock = await startMockServer()
|
||||
|
||||
configureRepoCwd(sandbox.hermesHome, mock.url, repo)
|
||||
|
||||
const { app, page } = await launchDesktop(buildAppEnv(sandbox))
|
||||
fixture = {
|
||||
app,
|
||||
page,
|
||||
mock,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
|
||||
await waitForAppReady(fixture, 120_000)
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('creating a branch with ctrl-shift-b updates the composer git-status branch', async ({}, testInfo) => {
|
||||
const page = fixture!.page
|
||||
const codingRow = page.locator('.coding-status-bar')
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
|
||||
await 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') })
|
||||
})
|
||||
@@ -12,7 +12,7 @@ import path from 'node:path'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, verifyHermesCli } from './backend-probes'
|
||||
import { canImportHermesCli, hermesRuntimeImportProbe, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
|
||||
// Resolve the host's own Node binary -- guaranteed to be on disk and
|
||||
// runnable. We use it as both a stand-in for "a python that doesn't
|
||||
@@ -51,6 +51,15 @@ test('hermes runtime import probe checks config dependencies', () => {
|
||||
assert.match(probe, /\bimport hermes_cli\.config\b/)
|
||||
})
|
||||
|
||||
test('explicit Hermes override is authoritative', () => {
|
||||
assert.equal(shouldTrustHermesOverride('/nix/store/abc/bin/hermes'), true)
|
||||
})
|
||||
|
||||
test('empty Hermes override is not authoritative', () => {
|
||||
assert.equal(shouldTrustHermesOverride(''), false)
|
||||
assert.equal(shouldTrustHermesOverride(undefined), false)
|
||||
})
|
||||
|
||||
test('verifyHermesCli returns false when command is falsy', () => {
|
||||
assert.equal(verifyHermesCli(''), false)
|
||||
assert.equal(verifyHermesCli(null), false)
|
||||
|
||||
@@ -104,6 +104,16 @@ function canImportHermesCli(pythonPath: string, opts: { env?: Record<string, str
|
||||
* in resolveHermesBackend.
|
||||
* @returns {boolean}
|
||||
*/
|
||||
/**
|
||||
* An explicit desktop backend command is a deployment contract, not a PATH
|
||||
* discovery candidate. In particular, the Nix desktop wrapper points this at
|
||||
* its immutable, matching Hermes package; it must never fall through to the
|
||||
* mutable install-script bootstrap path if a best-effort probe is slow.
|
||||
*/
|
||||
function shouldTrustHermesOverride(hermesOverride?: string) {
|
||||
return typeof hermesOverride === 'string' && hermesOverride.trim().length > 0
|
||||
}
|
||||
|
||||
function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
if (!hermesCommand) {
|
||||
return false
|
||||
@@ -123,4 +133,4 @@ function verifyHermesCli(hermesCommand: string, opts?: { shell?: boolean }) {
|
||||
}
|
||||
}
|
||||
|
||||
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, verifyHermesCli }
|
||||
export { canImportHermesCli, hermesRuntimeImportProbe, PROBE_TIMEOUT_MS, shouldTrustHermesOverride, verifyHermesCli }
|
||||
|
||||
@@ -34,7 +34,7 @@ import { stopBackendChild as stopBackendChildImpl } from './backend-child'
|
||||
import { dashboardFallbackArgs, sourceDeclaresServe } from './backend-command'
|
||||
import { createBackendConnectionState } from './backend-connection-state'
|
||||
import { buildDesktopBackendEnv, normalizeHermesHomeRoot } from './backend-env'
|
||||
import { canImportHermesCli, verifyHermesCli } from './backend-probes'
|
||||
import { canImportHermesCli, shouldTrustHermesOverride, verifyHermesCli } from './backend-probes'
|
||||
import { waitForDashboardPortAnnouncement } from './backend-ready'
|
||||
import { shouldLatchBackendStartFailure } from './backend-start-failure'
|
||||
import { detectRemoteDisplay, isWindowsBinaryPathInWsl, isWslEnvironment } from './bootstrap-platform'
|
||||
@@ -80,19 +80,6 @@ import { installEmbedReferer } from './embed-referer'
|
||||
import { createEventDeduper } from './event-dedupe'
|
||||
import { readDirForIpc } from './fs-read-dir'
|
||||
import { probeGatewayWebSocket } from './gateway-ws-probe'
|
||||
import { runNativeLogin } from './native-oauth-login'
|
||||
import {
|
||||
nativeRefreshUrl,
|
||||
parseTokenResponse,
|
||||
resolveLoginStrategy,
|
||||
tokenNeedsRefresh,
|
||||
type NativeTokenSet
|
||||
} from './native-oauth'
|
||||
import {
|
||||
oauthSessionIsLive,
|
||||
resolveJsonBody,
|
||||
resolveOauthRestAuth
|
||||
} from './native-auth-decisions'
|
||||
import { scanGitRepos } from './git-repo-scan'
|
||||
import {
|
||||
fileDiffVsHead,
|
||||
@@ -129,6 +116,15 @@ import {
|
||||
} from './hardening'
|
||||
import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window'
|
||||
import { ensureMainWindow } from './main-window-lifecycle'
|
||||
import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions'
|
||||
import {
|
||||
nativeRefreshUrl,
|
||||
type NativeTokenSet,
|
||||
parseTokenResponse,
|
||||
resolveLoginStrategy,
|
||||
tokenNeedsRefresh
|
||||
} from './native-oauth'
|
||||
import { runNativeLogin } from './native-oauth-login'
|
||||
import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request'
|
||||
import { createKeepAwake } from './power-save'
|
||||
import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing'
|
||||
@@ -3568,7 +3564,11 @@ function resolveHermesBackend(backendArgs) {
|
||||
// and lets the resolver fall through to step 6 / bootstrap.
|
||||
const shellForProbe = isCommandScript(hermesCommand)
|
||||
|
||||
if (verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||||
// HERMES_DESKTOP_HERMES is an explicit deployment override (used by
|
||||
// the Nix wrapper), not a discovered PATH candidate. It must not fall
|
||||
// through to the install-script bootstrap if the optional probe times
|
||||
// out under load; the pinned backend is the only valid runtime there.
|
||||
if (shouldTrustHermesOverride(hermesOverride) || verifyHermesCli(hermesCommand, { shell: shellForProbe })) {
|
||||
return (
|
||||
unwrapWindowsVenvHermesCommand(hermesCommand, backendArgs) || {
|
||||
label: `existing Hermes CLI at ${hermesCommand}`,
|
||||
@@ -5851,6 +5851,7 @@ async function ensureNativeAccessToken(baseUrl: string): Promise<string | null>
|
||||
{ refresh_token: tokens.refreshToken, provider: tokens.provider },
|
||||
{ timeoutMs: 10_000 }
|
||||
)
|
||||
|
||||
const rotated = parseTokenResponse(body)
|
||||
_storeNativeTokens(baseUrl, rotated)
|
||||
|
||||
@@ -5883,6 +5884,7 @@ async function mintGatewayWsTicket(baseUrl) {
|
||||
timeoutMs: 8_000,
|
||||
bearer: nativeAt
|
||||
})) as any
|
||||
|
||||
const ticket = body?.ticket
|
||||
|
||||
if (!ticket || typeof ticket !== 'string') {
|
||||
@@ -6459,10 +6461,7 @@ async function sanitizeDesktopConnectionConfig(config = readDesktopConnectionCon
|
||||
// RFC 8252 flow) counts as connected too — otherwise a completed native
|
||||
// sign-in shows "not connected" in Settings. The authoritative liveness
|
||||
// check is the ws-ticket mint in resolveRemoteBackend at actual connect time.
|
||||
remoteOauthConnected = oauthSessionIsLive(
|
||||
hasNativeSession(remoteUrl),
|
||||
await hasLiveOauthSession(remoteUrl)
|
||||
)
|
||||
remoteOauthConnected = oauthSessionIsLive(hasNativeSession(remoteUrl), await hasLiveOauthSession(remoteUrl))
|
||||
} catch {
|
||||
remoteOauthConnected = false
|
||||
}
|
||||
@@ -8955,6 +8954,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
|
||||
postJson: (url, body, opts) => postJsonNoAuth(url, body, opts),
|
||||
rememberLog
|
||||
})
|
||||
|
||||
_storeNativeTokens(baseUrl, tokens)
|
||||
|
||||
return { ok: true, baseUrl, connected: true }
|
||||
@@ -8977,6 +8977,7 @@ ipcMain.handle('hermes:connection-config:oauth-login', async (_event, rawUrl) =>
|
||||
ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) => {
|
||||
const baseUrl = rawUrl ? normalizeRemoteBaseUrl(rawUrl) : ''
|
||||
await clearOauthSession(baseUrl || undefined)
|
||||
|
||||
// Also drop any native (RFC 8252) bearer tokens for this gateway so a
|
||||
// logout clears BOTH auth shapes.
|
||||
if (baseUrl) {
|
||||
@@ -8986,9 +8987,7 @@ ipcMain.handle('hermes:connection-config:oauth-logout', async (_event, rawUrl) =
|
||||
// Report against the SAME liveness notion the Settings indicator uses
|
||||
// (AT-or-RT cookie, or a native token) so a logout that left any session
|
||||
// behind is reflected as still-connected rather than silently signed-out.
|
||||
const connected = baseUrl
|
||||
? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl)
|
||||
: false
|
||||
const connected = baseUrl ? (await hasLiveOauthSession(baseUrl)) || hasNativeSession(baseUrl) : false
|
||||
|
||||
return { ok: true, connected }
|
||||
})
|
||||
|
||||
@@ -10,11 +10,7 @@ import assert from 'node:assert/strict'
|
||||
|
||||
import { test } from 'vitest'
|
||||
|
||||
import {
|
||||
oauthSessionIsLive,
|
||||
resolveJsonBody,
|
||||
resolveOauthRestAuth
|
||||
} from './native-auth-decisions'
|
||||
import { oauthSessionIsLive, resolveJsonBody, resolveOauthRestAuth } from './native-auth-decisions'
|
||||
|
||||
// --- 1. body encoding (guards the double-JSON.stringify 422) ---
|
||||
|
||||
|
||||
@@ -44,9 +44,7 @@ export function oauthSessionIsLive(hasNativeToken: boolean, hasCookieSession: bo
|
||||
return hasNativeToken || hasCookieSession
|
||||
}
|
||||
|
||||
export type OauthRestAuth =
|
||||
| { kind: 'bearer'; token: string }
|
||||
| { kind: 'cookie' }
|
||||
export type OauthRestAuth = { kind: 'bearer'; token: string } | { kind: 'cookie' }
|
||||
|
||||
/**
|
||||
* Decide how an oauth-mode REST request authenticates: prefer the native
|
||||
|
||||
@@ -22,14 +22,18 @@ function makeFakeServerFactory(port = 51234) {
|
||||
const createServer: any = (handler: any) => {
|
||||
state.handler = handler
|
||||
const server: any = new EventEmitter()
|
||||
|
||||
server.listen = (_port: number, _host: string, cb: () => void) => {
|
||||
state.listening = true
|
||||
cb()
|
||||
}
|
||||
|
||||
server.address = () => ({ address: '127.0.0.1', family: 'IPv4', port })
|
||||
|
||||
server.close = () => {
|
||||
state.closed = true
|
||||
}
|
||||
|
||||
state.server = server
|
||||
|
||||
return server
|
||||
|
||||
@@ -32,10 +32,10 @@ import {
|
||||
buildNativeAuthorizeUrl,
|
||||
generatePkcePair,
|
||||
generateState,
|
||||
type NativeTokenSet,
|
||||
nativeTokenUrl,
|
||||
parseLoopbackCallback,
|
||||
parseTokenResponse,
|
||||
type NativeTokenSet
|
||||
parseTokenResponse
|
||||
} from './native-oauth'
|
||||
|
||||
// Loopback login must complete inside this window (user opens browser,
|
||||
@@ -90,6 +90,7 @@ export async function runNativeLogin(
|
||||
return new Promise<NativeTokenSet>((resolve, reject) => {
|
||||
let settled = false
|
||||
let timer: NodeJS.Timeout | null = null
|
||||
|
||||
const server = createServer((req, res) => {
|
||||
// Only the callback path carries the code; any other path (favicon,
|
||||
// etc.) still gets the friendly page so the browser tab looks sane.
|
||||
@@ -179,6 +180,7 @@ export async function runNativeLogin(
|
||||
}
|
||||
|
||||
const redirectUri = `http://127.0.0.1:${addr.port}/callback`
|
||||
|
||||
const authorizeUrl = buildNativeAuthorizeUrl(baseUrl, {
|
||||
challenge,
|
||||
redirectUri,
|
||||
|
||||
@@ -34,6 +34,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => {
|
||||
assert.equal(pair.method, 'S256')
|
||||
// Verifier length within RFC 7636 range (43–128).
|
||||
assert.ok(pair.verifier.length >= 43 && pair.verifier.length <= 128)
|
||||
|
||||
// Challenge must be the base64url SHA-256 of the verifier.
|
||||
const expected = createHash('sha256')
|
||||
.update(pair.verifier, 'ascii')
|
||||
@@ -41,6 +42,7 @@ test('generatePkcePair produces a valid S256 verifier/challenge', () => {
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '')
|
||||
|
||||
assert.equal(pair.challenge, expected)
|
||||
// No padding / URL-unsafe chars.
|
||||
assert.doesNotMatch(pair.verifier, /[+/=]/)
|
||||
@@ -91,6 +93,7 @@ test('buildNativeAuthorizeUrl encodes params and honours a path prefix', () => {
|
||||
state: 'STATE',
|
||||
provider: 'nous'
|
||||
})
|
||||
|
||||
const parsed = new URL(url)
|
||||
|
||||
assert.equal(parsed.origin, 'https://gw.example.com')
|
||||
@@ -108,6 +111,7 @@ test('buildNativeAuthorizeUrl omits provider when not given and preserves prefix
|
||||
redirectUri: 'http://127.0.0.1:1/cb',
|
||||
state: 'S'
|
||||
})
|
||||
|
||||
const parsed = new URL(url)
|
||||
|
||||
assert.equal(parsed.pathname, '/hermes/auth/native/authorize')
|
||||
@@ -128,10 +132,7 @@ test('parseLoopbackCallback returns the code on a state match', () => {
|
||||
})
|
||||
|
||||
test('parseLoopbackCallback throws on state mismatch (CSRF)', () => {
|
||||
assert.throws(
|
||||
() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'),
|
||||
/state mismatch/i
|
||||
)
|
||||
assert.throws(() => parseLoopbackCallback('/callback?code=abc&state=attacker', 'expected'), /state mismatch/i)
|
||||
})
|
||||
|
||||
test('parseLoopbackCallback surfaces a gateway error param', () => {
|
||||
|
||||
@@ -86,10 +86,7 @@ export function statusSupportsNativeFlow(statusBody: any): boolean {
|
||||
* (e.g. a corporate proxy that blocks loopback). Precedence written down here,
|
||||
* in one place, as a pure function — per the desktop "observable ladder" rule.
|
||||
*/
|
||||
export function resolveLoginStrategy(
|
||||
statusBody: any,
|
||||
opts: { forceEmbedded?: boolean } = {}
|
||||
): 'native' | 'embedded' {
|
||||
export function resolveLoginStrategy(statusBody: any, opts: { forceEmbedded?: boolean } = {}): 'native' | 'embedded' {
|
||||
if (opts.forceEmbedded) {
|
||||
return 'embedded'
|
||||
}
|
||||
@@ -109,6 +106,7 @@ export function buildNativeAuthorizeUrl(
|
||||
): string {
|
||||
const parsed = new URL(baseUrl)
|
||||
const prefix = parsed.pathname.replace(/\/+$/, '')
|
||||
|
||||
const q = new URLSearchParams({
|
||||
code_challenge: params.challenge,
|
||||
code_challenge_method: 'S256',
|
||||
@@ -145,10 +143,7 @@ export function nativeRefreshUrl(baseUrl: string): string {
|
||||
* `expectedState` MUST match (CSRF defense — RFC 6749 §10.12); a mismatch
|
||||
* throws rather than proceeding.
|
||||
*/
|
||||
export function parseLoopbackCallback(
|
||||
requestUrl: string,
|
||||
expectedState: string
|
||||
): { code: string } {
|
||||
export function parseLoopbackCallback(requestUrl: string, expectedState: string): { code: string } {
|
||||
// requestUrl is the path+query the loopback server received, e.g.
|
||||
// "/callback?code=...&state=...". Resolve against a dummy origin to parse.
|
||||
const parsed = new URL(requestUrl, 'http://127.0.0.1')
|
||||
@@ -203,7 +198,11 @@ export function parseTokenResponse(body: any): NativeTokenSet {
|
||||
* before use. `skewSeconds` refreshes slightly early to avoid a race where
|
||||
* the token expires in flight (mirrors the server's 60s cookie floor).
|
||||
*/
|
||||
export function tokenNeedsRefresh(tokens: Pick<NativeTokenSet, 'expiresAt'>, nowSeconds: number, skewSeconds = 60): boolean {
|
||||
export function tokenNeedsRefresh(
|
||||
tokens: Pick<NativeTokenSet, 'expiresAt'>,
|
||||
nowSeconds: number,
|
||||
skewSeconds = 60
|
||||
): boolean {
|
||||
if (!tokens || !Number.isFinite(tokens.expiresAt) || tokens.expiresAt <= 0) {
|
||||
// Unknown expiry ⇒ treat as needing refresh so we validate before use.
|
||||
return true
|
||||
|
||||
@@ -11,15 +11,19 @@
|
||||
"node": "^20.19.0 || >=22.12.0"
|
||||
},
|
||||
"scripts": {
|
||||
"clean": "npm run clean:e2e && npm run clean:renderer && npm run clean:electron",
|
||||
"clean:e2e":"tsc --build tsconfig.e2e.json --clean",
|
||||
"clean:renderer":"tsc --build tsconfig.json --clean ",
|
||||
"clean:electron":"tsc --build tsconfig.electron.json --clean",
|
||||
"dev": "concurrently -k \"npm:dev:renderer\" \"npm:dev:electron\"",
|
||||
"dev:fake-boot": "cross-env HERMES_DESKTOP_BOOT_FAKE=1 HERMES_DESKTOP_BOOT_FAKE_STEP_MS=650 npm run dev",
|
||||
"dev:mock": "node scripts/dev-mock.mjs",
|
||||
"dev:renderer": "node scripts/assert-root-install.mjs && vite --host 127.0.0.1 --port 5174",
|
||||
"dev:electron": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"profile:main": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"dev:renderer": "node scripts/assert-root-install.mjs && npm run clean:renderer && vite --host 127.0.0.1 --port 5174",
|
||||
"dev:electron": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"profile:main": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron --inspect=9229 .",
|
||||
"profile:main:cpu": "tsc --build tsconfig.electron.json && wait-on http://127.0.0.1:5174 && node scripts/bundle-electron-main.mjs --dev && cross-env XCURSOR_SIZE=24 NODE_OPTIONS=--cpu-prof HERMES_DESKTOP_DEV_SERVER=http://127.0.0.1:5174 electron .",
|
||||
"start": "npm run build && electron .",
|
||||
"prebuild": "tsc -b . --clean",
|
||||
"prebuild": "npm run clean",
|
||||
"build": "node scripts/assert-root-install.mjs && node scripts/write-build-stamp.mjs && vite build && node scripts/bundle-electron-main.mjs && node scripts/stage-native-deps.mjs",
|
||||
"postbuild": "node scripts/assert-dist-built.mjs",
|
||||
"prebuilder": "node scripts/patch-electron-builder-mac-binary.mjs",
|
||||
@@ -51,9 +55,9 @@
|
||||
"test": "vitest run",
|
||||
"preview": "node scripts/assert-root-install.mjs && vite preview --host 127.0.0.1 --port 4174",
|
||||
"check": "npm run typecheck && npm run test && npm run test:desktop:all",
|
||||
"test:e2e": "playwright test e2e/",
|
||||
"test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
|
||||
"test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
|
||||
"test:e2e": "npm run build && playwright test e2e/",
|
||||
"test:e2e:visual": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list",
|
||||
"test:e2e:update-snapshots": "npm run build && WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.14.23",
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Codicon } from '@/components/ui/codicon'
|
||||
import { KbdCombo } from '@/components/ui/kbd'
|
||||
import { Tip } from '@/components/ui/tooltip'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { AudioLines, iconSize, Layers3, Loader2, Square, SteeringWheel, Volume2, VolumeX } from '@/lib/icons'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import type { ConversationStatus } from './hooks/use-voice-conversation'
|
||||
@@ -42,7 +40,6 @@ export function ComposerControls({
|
||||
autoSpeak,
|
||||
busy,
|
||||
busyAction,
|
||||
canSteer,
|
||||
canSubmit,
|
||||
compactModelPill = false,
|
||||
conversation,
|
||||
@@ -51,13 +48,12 @@ export function ComposerControls({
|
||||
state,
|
||||
voiceStatus,
|
||||
onDictate,
|
||||
onSteer,
|
||||
onQueue,
|
||||
onToggleAutoSpeak
|
||||
}: {
|
||||
autoSpeak: boolean
|
||||
busy: boolean
|
||||
busyAction: 'queue' | 'stop'
|
||||
canSteer: boolean
|
||||
busyAction: 'steer' | 'queue' | 'stop'
|
||||
canSubmit: boolean
|
||||
compactModelPill?: boolean
|
||||
conversation: ConversationProps
|
||||
@@ -66,50 +62,39 @@ export function ComposerControls({
|
||||
state: ChatBarState
|
||||
voiceStatus: VoiceStatus
|
||||
onDictate: () => void
|
||||
onSteer: () => void
|
||||
onQueue: () => void
|
||||
onToggleAutoSpeak: () => void
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const c = t.composer
|
||||
const steerCombo = formatCombo('mod+enter')
|
||||
const steerLabel = `${c.steer} (${steerCombo})`
|
||||
|
||||
const steerTip = (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{c.steer}
|
||||
<KbdCombo combo="mod+enter" size="sm" variant="inverted" />
|
||||
</span>
|
||||
)
|
||||
|
||||
if (conversation.active) {
|
||||
return <ConversationPill {...conversation} disabled={disabled} />
|
||||
}
|
||||
|
||||
const showVoicePrimary = !busy && !hasComposerPayload
|
||||
const busyLabel = busyAction === 'queue' ? c.queueMessage : busyAction === 'steer' ? c.steer : c.stop
|
||||
|
||||
return (
|
||||
<div className="ml-auto flex shrink-0 items-center gap-(--composer-control-gap)">
|
||||
<ModelPill compact={compactModelPill} disabled={disabled} model={state.model} />
|
||||
{/* While the agent runs and the user is typing, steer takes over the mic's
|
||||
slot rather than crowding the row with an extra button. */}
|
||||
{canSteer ? (
|
||||
<Tip label={steerTip}>
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
{busyAction === 'steer' ? (
|
||||
<Tip label={c.queueMessage}>
|
||||
<Button
|
||||
aria-label={steerLabel}
|
||||
aria-label={c.queueMessage}
|
||||
className={GHOST_ICON_BTN}
|
||||
disabled={disabled}
|
||||
onClick={onSteer}
|
||||
onClick={onQueue}
|
||||
size="icon"
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
<SteeringWheel className={iconSize.sm} />
|
||||
<Layers3 className={iconSize.sm} />
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<DictationButton disabled={disabled} onToggle={onDictate} state={state.voice} status={voiceStatus} />
|
||||
)}
|
||||
<AutoSpeakButton active={autoSpeak} disabled={disabled} onToggle={onToggleAutoSpeak} />
|
||||
) : null}
|
||||
{showVoicePrimary ? (
|
||||
<Tip label={c.startVoice}>
|
||||
<Button
|
||||
@@ -127,9 +112,9 @@ export function ComposerControls({
|
||||
</Button>
|
||||
</Tip>
|
||||
) : (
|
||||
<Tip label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}>
|
||||
<Tip label={busy ? busyLabel : c.send}>
|
||||
<Button
|
||||
aria-label={busy ? (busyAction === 'queue' ? c.queueMessage : c.stop) : c.send}
|
||||
aria-label={busy ? busyLabel : c.send}
|
||||
className={PRIMARY_ICON_BTN}
|
||||
disabled={disabled || !canSubmit}
|
||||
type="submit"
|
||||
@@ -137,6 +122,8 @@ export function ComposerControls({
|
||||
{busy ? (
|
||||
busyAction === 'queue' ? (
|
||||
<Layers3 className={iconSize.sm} />
|
||||
) : busyAction === 'steer' ? (
|
||||
<SteeringWheel className={iconSize.sm} />
|
||||
) : (
|
||||
<span className="block size-2.5 rounded-[0.1875rem] bg-current" />
|
||||
)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
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,7 +17,6 @@ interface UseComposerSubmitArgs {
|
||||
activeQueueSessionKeyRef: RefObject<string | null>
|
||||
attachments: ComposerAttachment[]
|
||||
busy: boolean
|
||||
canSteer: boolean
|
||||
clearDraft: () => void
|
||||
disabled: boolean
|
||||
draftRef: RefObject<string>
|
||||
@@ -43,7 +42,7 @@ interface UseComposerSubmitArgs {
|
||||
* queue meet. `submitDraft` is the one decision tree (queue-edit save · slash-
|
||||
* now-while-busy · queue · drain · send · stop); `dispatchSubmit` is the shared
|
||||
* send-with-restore primitive (re-loads + re-stashes the draft if the gateway
|
||||
* rejects, so nothing is ever lost); `steerDraft` nudges the live turn. Reads
|
||||
* rejects, so nothing is ever lost); `steerDraft` redirects the live turn. Reads
|
||||
* the draft + queue APIs; owns no state of its own beyond the stable
|
||||
* external-submit listener ref.
|
||||
*/
|
||||
@@ -52,7 +51,6 @@ export function useComposerSubmit({
|
||||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
canSteer,
|
||||
clearDraft,
|
||||
disabled,
|
||||
draftRef,
|
||||
@@ -151,7 +149,14 @@ 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
|
||||
@@ -173,16 +178,18 @@ export function useComposerSubmit({
|
||||
focusInput()
|
||||
}
|
||||
|
||||
// Steer the live turn (nudge without interrupting). Clears the draft up front
|
||||
// for snappy feedback; if the gateway rejects (no live tool window) the words
|
||||
// are re-queued so nothing is lost — same safety net as a plain queue.
|
||||
// Redirect the live turn with a correction. The gateway either restarts the
|
||||
// active model request with its displayed context or waits for the current
|
||||
// tool boundary. If the turn already ended, queue the words instead.
|
||||
const steerDraft = () => {
|
||||
if (!onSteer || !canSteer) {
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
// Guard on live editor state, not the render-lagged `canSteer`: a redirect
|
||||
// fired on a fast Enter must not be dropped because state hasn't synced.
|
||||
if (!onSteer || !text || attachments.length > 0 || SLASH_COMMAND_RE.test(text)) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = draftRef.current.trim()
|
||||
|
||||
triggerHaptic('submit')
|
||||
clearDraft()
|
||||
|
||||
@@ -193,5 +200,14 @@ export function useComposerSubmit({
|
||||
})
|
||||
}
|
||||
|
||||
return { dispatchSubmit, steerDraft, submitDraft }
|
||||
const queueDraft = () => {
|
||||
if (disabled || !busy) {
|
||||
return
|
||||
}
|
||||
|
||||
queueCurrentDraft()
|
||||
focusInput()
|
||||
}
|
||||
|
||||
return { dispatchSubmit, queueDraft, steerDraft, submitDraft }
|
||||
}
|
||||
|
||||
@@ -1,7 +1,14 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { playSpeechText, stopVoicePlayback } from '@/lib/voice-playback'
|
||||
import { monitorSpeechDuringPlayback } from '@/lib/voice-barge-in'
|
||||
import {
|
||||
markVoicePlaybackInterrupted,
|
||||
playSpeechText,
|
||||
type SpeechStreamSession,
|
||||
startSpeechStream,
|
||||
stopVoicePlayback
|
||||
} from '@/lib/voice-playback'
|
||||
import { notify, notifyError } from '@/store/notifications'
|
||||
|
||||
import { useMicRecorder } from './use-mic-recorder'
|
||||
@@ -44,7 +51,9 @@ export function useVoiceConversation({
|
||||
const awaitingSpokenResponseRef = useRef(false)
|
||||
const responseIdRef = useRef<string | null>(null)
|
||||
const spokenSourceLengthRef = useRef(0)
|
||||
const speechBufferRef = useRef('')
|
||||
const speechSessionRef = useRef<null | SpeechStreamSession>(null)
|
||||
const stopBargeMonitorRef = useRef<(() => void) | null>(null)
|
||||
const bargeCapturePendingRef = useRef(false)
|
||||
const enabledRef = useRef(enabled)
|
||||
const mutedRef = useRef(muted)
|
||||
const busyRef = useRef(busy)
|
||||
@@ -74,60 +83,13 @@ export function useVoiceConversation({
|
||||
}
|
||||
}
|
||||
|
||||
const resetSpeechBuffer = () => {
|
||||
const dropSpeechSession = () => {
|
||||
stopBargeMonitorRef.current?.()
|
||||
stopBargeMonitorRef.current = null
|
||||
bargeCapturePendingRef.current = false
|
||||
speechSessionRef.current = null
|
||||
responseIdRef.current = null
|
||||
spokenSourceLengthRef.current = 0
|
||||
speechBufferRef.current = ''
|
||||
}
|
||||
|
||||
const appendSpeechText = (text: string) => {
|
||||
if (!text) {
|
||||
return
|
||||
}
|
||||
|
||||
speechBufferRef.current = `${speechBufferRef.current}${text}`
|
||||
}
|
||||
|
||||
const takeSpeechChunk = (force = false): string | null => {
|
||||
const buffer = speechBufferRef.current.replace(/\s+/g, ' ').trim()
|
||||
|
||||
if (!buffer) {
|
||||
speechBufferRef.current = ''
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
const sentence = buffer.match(/^(.+?[.!?。!?])(?:\s+|$)/)
|
||||
|
||||
if (sentence?.[1] && (sentence[1].length >= 8 || force)) {
|
||||
const chunk = sentence[1].trim()
|
||||
speechBufferRef.current = buffer.slice(sentence[1].length).trim()
|
||||
|
||||
return chunk
|
||||
}
|
||||
|
||||
if (!force && buffer.length > 220) {
|
||||
const softBoundary = Math.max(
|
||||
buffer.lastIndexOf(', ', 180),
|
||||
buffer.lastIndexOf('; ', 180),
|
||||
buffer.lastIndexOf(': ', 180)
|
||||
)
|
||||
|
||||
if (softBoundary > 80) {
|
||||
const chunk = buffer.slice(0, softBoundary + 1).trim()
|
||||
speechBufferRef.current = buffer.slice(softBoundary + 1).trim()
|
||||
|
||||
return chunk
|
||||
}
|
||||
}
|
||||
|
||||
if (!force) {
|
||||
return null
|
||||
}
|
||||
|
||||
speechBufferRef.current = ''
|
||||
|
||||
return buffer
|
||||
}
|
||||
|
||||
const handleTurn = useCallback(
|
||||
@@ -167,7 +129,7 @@ export function useVoiceConversation({
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = true
|
||||
resetSpeechBuffer()
|
||||
dropSpeechSession()
|
||||
await onSubmit(transcript)
|
||||
setStatus('thinking')
|
||||
} catch (error) {
|
||||
@@ -193,6 +155,10 @@ export function useVoiceConversation({
|
||||
return
|
||||
}
|
||||
|
||||
if (bargeCapturePendingRef.current) {
|
||||
return // the barge monitor is mid-capture and owns the mic
|
||||
}
|
||||
|
||||
if (statusRef.current !== 'idle') {
|
||||
return
|
||||
}
|
||||
@@ -220,24 +186,238 @@ export function useVoiceConversation({
|
||||
}
|
||||
}, [handle, handleTurn, onFatalError, voiceCopy.couldNotStartSession, voiceCopy.microphoneFailed])
|
||||
|
||||
const speak = useCallback(
|
||||
async (text: string) => {
|
||||
setStatus('speaking')
|
||||
const settleAfterSpeech = useCallback(
|
||||
(barged: boolean) => {
|
||||
if (barged || !awaitingSpokenResponseRef.current) {
|
||||
awaitingSpokenResponseRef.current = false
|
||||
consumePendingResponse()
|
||||
}
|
||||
|
||||
if (bargeCapturePendingRef.current) {
|
||||
// The barge monitor is still capturing the user's interruption — it
|
||||
// owns the next turn. Keep it alive and don't re-open the mic; the
|
||||
// utterance callback transcribes and submits when they go quiet.
|
||||
speechSessionRef.current = null
|
||||
responseIdRef.current = null
|
||||
spokenSourceLengthRef.current = 0
|
||||
setStatus('listening')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
dropSpeechSession()
|
||||
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
},
|
||||
[consumePendingResponse]
|
||||
)
|
||||
|
||||
/**
|
||||
* Submit the utterance the barge monitor captured — the user's interruption
|
||||
* from its first syllable, no re-listen round trip. Empty/failed captures
|
||||
* fall back to normal listening.
|
||||
*/
|
||||
const submitCapturedUtterance = useCallback(
|
||||
async (audio: Blob | null) => {
|
||||
const resumeListening = () => {
|
||||
if (enabledRef.current && !mutedRef.current) {
|
||||
pendingStartRef.current = true
|
||||
}
|
||||
|
||||
setStatus('idle')
|
||||
}
|
||||
|
||||
if (!audio || !onTranscribeAudio) {
|
||||
resumeListening()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
setStatus('transcribing')
|
||||
|
||||
try {
|
||||
await playSpeechText(text, { source: 'voice-conversation' })
|
||||
} catch (error) {
|
||||
notifyError(error, voiceCopy.playbackFailed)
|
||||
} finally {
|
||||
if (enabledRef.current) {
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
} else {
|
||||
setStatus('idle')
|
||||
const transcript = (await onTranscribeAudio(audio)).trim()
|
||||
|
||||
if (!transcript) {
|
||||
resumeListening()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = true
|
||||
dropSpeechSession()
|
||||
consumePendingResponse()
|
||||
await onSubmit(transcript)
|
||||
setStatus('thinking')
|
||||
} catch (error) {
|
||||
notifyError(error, voiceCopy.transcriptionFailed)
|
||||
resumeListening()
|
||||
}
|
||||
},
|
||||
[voiceCopy.playbackFailed]
|
||||
[consumePendingResponse, onSubmit, onTranscribeAudio, voiceCopy.transcriptionFailed]
|
||||
)
|
||||
|
||||
/** Barge-in monitor wiring shared by the live and fallback speech paths. */
|
||||
const openBargeMonitor = useCallback(
|
||||
(onBarge: () => void) =>
|
||||
monitorSpeechDuringPlayback({
|
||||
onSpeech: () => {
|
||||
bargeCapturePendingRef.current = true
|
||||
onBarge()
|
||||
markVoicePlaybackInterrupted()
|
||||
stopVoicePlayback()
|
||||
},
|
||||
onUtterance: audio => {
|
||||
bargeCapturePendingRef.current = false
|
||||
stopBargeMonitorRef.current = null
|
||||
void submitCapturedUtterance(audio)
|
||||
}
|
||||
}),
|
||||
[submitCapturedUtterance]
|
||||
)
|
||||
|
||||
/** Push any new reply text into the live session; finish when complete. */
|
||||
const feedSpeechSession = useCallback(
|
||||
(responseId: string) => {
|
||||
const session = speechSessionRef.current
|
||||
|
||||
if (!session || responseIdRef.current !== responseId) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = pendingResponse()
|
||||
|
||||
if (response && response.id === responseId) {
|
||||
if (response.text.length > spokenSourceLengthRef.current) {
|
||||
session.append(response.text.slice(spokenSourceLengthRef.current))
|
||||
spokenSourceLengthRef.current = response.text.length
|
||||
}
|
||||
|
||||
if (!response.pending && !busyRef.current) {
|
||||
session.finish()
|
||||
}
|
||||
} else if (!busyRef.current) {
|
||||
// Reply consumed/vanished while we were speaking — close out the turn.
|
||||
session.finish()
|
||||
}
|
||||
},
|
||||
[pendingResponse]
|
||||
)
|
||||
|
||||
/** Whole-text fallback: wait for the reply to complete, then speak it. */
|
||||
const awaitFallbackSpeech = useCallback(
|
||||
(responseId: string) => {
|
||||
const poll = () => {
|
||||
if (responseIdRef.current !== responseId) {
|
||||
return
|
||||
}
|
||||
|
||||
const response = pendingResponse()
|
||||
|
||||
if (!response || response.id !== responseId) {
|
||||
settleAfterSpeech(false)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (response.pending || busyRef.current) {
|
||||
window.setTimeout(poll, 250)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let barged = false
|
||||
|
||||
stopBargeMonitorRef.current?.()
|
||||
stopBargeMonitorRef.current = openBargeMonitor(() => {
|
||||
barged = true
|
||||
})
|
||||
|
||||
void playSpeechText(response.text, { source: 'voice-conversation' })
|
||||
.catch(error => notifyError(error, voiceCopy.playbackFailed))
|
||||
.finally(() => {
|
||||
if (responseIdRef.current === responseId) {
|
||||
awaitingSpokenResponseRef.current = false
|
||||
settleAfterSpeech(barged)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
poll()
|
||||
},
|
||||
[openBargeMonitor, pendingResponse, settleAfterSpeech, voiceCopy.playbackFailed]
|
||||
)
|
||||
|
||||
/**
|
||||
* Live-speak the streaming reply: one speech session per response, fed
|
||||
* incremental text as the assistant generates it. Audio overlaps generation
|
||||
* — no wait for the full reply, no per-sentence gaps.
|
||||
*/
|
||||
const openLiveSpeech = useCallback(
|
||||
(responseId: string) => {
|
||||
responseIdRef.current = responseId
|
||||
spokenSourceLengthRef.current = 0
|
||||
setStatus('speaking')
|
||||
|
||||
let barged = false
|
||||
|
||||
// VAD barge-in: the user talking over the reply cuts playback, drops
|
||||
// the not-yet-spoken remainder, AND keeps capturing — the interruption
|
||||
// is transcribed from its first syllable instead of losing the opening
|
||||
// words to a mic re-open.
|
||||
stopBargeMonitorRef.current = openBargeMonitor(() => {
|
||||
barged = true
|
||||
})
|
||||
|
||||
void (async () => {
|
||||
const session = await startSpeechStream({ source: 'voice-conversation' })
|
||||
|
||||
// The session may resolve after the loop moved on (barge, disable).
|
||||
if (responseIdRef.current !== responseId) {
|
||||
if (session) {
|
||||
stopVoicePlayback()
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!session) {
|
||||
// No streaming backend/provider: speak the whole reply once it lands.
|
||||
speechSessionRef.current = null
|
||||
awaitFallbackSpeech(responseId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
speechSessionRef.current = session
|
||||
|
||||
// Timer-driven feed: reply text flows into the session at delta rate
|
||||
// regardless of React render cadence.
|
||||
const feedTimer = window.setInterval(() => feedSpeechSession(responseId), 150)
|
||||
feedSpeechSession(responseId)
|
||||
|
||||
const outcome = await session.done
|
||||
window.clearInterval(feedTimer)
|
||||
|
||||
if (responseIdRef.current !== responseId) {
|
||||
return
|
||||
}
|
||||
|
||||
if (outcome === 'fallback') {
|
||||
awaitFallbackSpeech(responseId)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
awaitingSpokenResponseRef.current = false
|
||||
settleAfterSpeech(barged)
|
||||
})()
|
||||
},
|
||||
[awaitFallbackSpeech, feedSpeechSession, openBargeMonitor, settleAfterSpeech]
|
||||
)
|
||||
|
||||
const start = useCallback(async () => {
|
||||
@@ -254,7 +434,7 @@ export function useVoiceConversation({
|
||||
|
||||
setMuted(false)
|
||||
awaitingSpokenResponseRef.current = false
|
||||
resetSpeechBuffer()
|
||||
dropSpeechSession()
|
||||
consumePendingResponse()
|
||||
pendingStartRef.current = true
|
||||
await startListening()
|
||||
@@ -274,7 +454,7 @@ export function useVoiceConversation({
|
||||
handle.cancel()
|
||||
turnClosingRef.current = false
|
||||
awaitingSpokenResponseRef.current = false
|
||||
resetSpeechBuffer()
|
||||
dropSpeechSession()
|
||||
consumePendingResponse()
|
||||
setMuted(false)
|
||||
setStatus('idle')
|
||||
@@ -325,8 +505,9 @@ export function useVoiceConversation({
|
||||
return () => window.removeEventListener('keydown', onKeyDown, { capture: true })
|
||||
}, [enabled, stopTurn])
|
||||
|
||||
// Drive the loop: after a voice-submitted turn, speak stable chunks as the
|
||||
// assistant stream grows. Otherwise start listening when idle between turns.
|
||||
// Drive the loop: when a voice-submitted reply appears, open a live speech
|
||||
// session (which feeds itself from then on). Otherwise start listening when
|
||||
// idle between turns.
|
||||
useEffect(() => {
|
||||
if (!enabled || muted) {
|
||||
return
|
||||
@@ -336,38 +517,15 @@ export function useVoiceConversation({
|
||||
const response = pendingResponse()
|
||||
|
||||
if (response) {
|
||||
if (response.id !== responseIdRef.current) {
|
||||
resetSpeechBuffer()
|
||||
responseIdRef.current = response.id
|
||||
}
|
||||
openLiveSpeech(response.id)
|
||||
|
||||
if (response.text.length > spokenSourceLengthRef.current) {
|
||||
appendSpeechText(response.text.slice(spokenSourceLengthRef.current))
|
||||
spokenSourceLengthRef.current = response.text.length
|
||||
}
|
||||
|
||||
const chunk = takeSpeechChunk(!response.pending && !busy)
|
||||
|
||||
if (chunk) {
|
||||
void speak(chunk)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.pending && !busy) {
|
||||
awaitingSpokenResponseRef.current = false
|
||||
consumePendingResponse()
|
||||
resetSpeechBuffer()
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if (!busy && status === 'thinking') {
|
||||
// Turn finished without any speakable reply (tool-only, error).
|
||||
awaitingSpokenResponseRef.current = false
|
||||
resetSpeechBuffer()
|
||||
dropSpeechSession()
|
||||
pendingStartRef.current = true
|
||||
setStatus('idle')
|
||||
|
||||
@@ -382,7 +540,7 @@ export function useVoiceConversation({
|
||||
if (pendingStartRef.current) {
|
||||
void startListening()
|
||||
}
|
||||
}, [busy, consumePendingResponse, enabled, muted, pendingResponse, speak, startListening, status])
|
||||
}, [busy, enabled, muted, openLiveSpeech, pendingResponse, startListening, status])
|
||||
|
||||
useEffect(() => {
|
||||
if (enabled && !wasEnabledRef.current) {
|
||||
|
||||
@@ -227,20 +227,22 @@ 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 { steerDraft, submitDraft } = useComposerSubmit({
|
||||
const { queueDraft, steerDraft, submitDraft } = useComposerSubmit({
|
||||
activeQueueSessionKey,
|
||||
activeQueueSessionKeyRef,
|
||||
attachments,
|
||||
busy,
|
||||
canSteer,
|
||||
clearDraft,
|
||||
disabled,
|
||||
draftRef,
|
||||
@@ -721,7 +723,6 @@ export function ChatBar({
|
||||
autoSpeak={autoSpeak}
|
||||
busy={busy}
|
||||
busyAction={busyAction}
|
||||
canSteer={canSteer}
|
||||
canSubmit={canSubmit}
|
||||
compactModelPill={poppedOut || compactPill}
|
||||
conversation={{
|
||||
@@ -737,7 +738,7 @@ export function ChatBar({
|
||||
disabled={disabled}
|
||||
hasComposerPayload={hasComposerPayload}
|
||||
onDictate={dictate}
|
||||
onSteer={steerDraft}
|
||||
onQueue={queueDraft}
|
||||
onToggleAutoSpeak={handleToggleAutoSpeak}
|
||||
state={state}
|
||||
voiceStatus={voiceStatus}
|
||||
|
||||
@@ -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, resolveNewSessionCwd } from '@/store/projects'
|
||||
import { $startWorkSessionRequest, followActiveSessionCwd } from '@/store/projects'
|
||||
import {
|
||||
$activeSessionId,
|
||||
$connection,
|
||||
@@ -48,8 +48,6 @@ import {
|
||||
sessionPinId,
|
||||
setAwaitingResponse,
|
||||
setBusy,
|
||||
setCurrentBranch,
|
||||
setCurrentCwd,
|
||||
setCurrentModel,
|
||||
setCurrentModelSource,
|
||||
setCurrentProvider,
|
||||
@@ -89,6 +87,7 @@ import { useRouteResume } from '../session/hooks/use-route-resume'
|
||||
import { useSessionActions } from '../session/hooks/use-session-actions'
|
||||
import { useSessionListActions } from '../session/hooks/use-session-list-actions'
|
||||
import { useSessionStateCache } from '../session/hooks/use-session-state-cache'
|
||||
import { startWorkspaceSession } from '../session/workspace-session-target'
|
||||
import { useOverlayRouting } from '../shell/hooks/use-overlay-routing'
|
||||
import { useWindowControlsOverlayWidth } from '../shell/hooks/use-window-controls-overlay-width'
|
||||
import { titlebarControlsPosition } from '../shell/titlebar'
|
||||
@@ -450,33 +449,16 @@ export function ContribWiring({ children }: { children: ReactNode }) {
|
||||
// also drills the sidebar into that project so the new lane is visible.
|
||||
const startSessionInWorkspace = useCallback(
|
||||
(path: null | string) => {
|
||||
startFreshSessionDraft()
|
||||
|
||||
// A worktree lane carries its own path; the trunk "+" can be path-less
|
||||
// (the main checkout is implicit), so fall back to the active project's
|
||||
// root instead of no-op'ing on null.
|
||||
const target = path?.trim() || resolveNewSessionCwd()
|
||||
|
||||
if (!target) {
|
||||
return
|
||||
}
|
||||
|
||||
setCurrentCwd(target)
|
||||
void requestGateway<{ branch?: string; cwd?: string }>('config.get', { key: 'project', cwd: target })
|
||||
.then(info => {
|
||||
const resolved = info.cwd || target
|
||||
|
||||
setCurrentCwd(resolved)
|
||||
setCurrentBranch(info.branch || '')
|
||||
|
||||
if (path?.trim()) {
|
||||
restoreWorktree(resolved)
|
||||
void followActiveSessionCwd(resolved)
|
||||
}
|
||||
})
|
||||
.catch(() => undefined)
|
||||
startWorkspaceSession({
|
||||
activeSessionIdRef,
|
||||
followActiveSessionCwd,
|
||||
onExplicitWorkspace: restoreWorktree,
|
||||
path,
|
||||
requestGateway,
|
||||
startFreshSessionDraft
|
||||
})
|
||||
},
|
||||
[requestGateway, startFreshSessionDraft]
|
||||
[activeSessionIdRef, requestGateway, startFreshSessionDraft]
|
||||
)
|
||||
|
||||
// Composer "branch off into a new worktree": open a fresh session anchored
|
||||
|
||||
@@ -0,0 +1,125 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { NEW_CHAT_ROUTE, sessionRoute, SETTINGS_ROUTE } from '../../routes'
|
||||
|
||||
import { routeTargetFromToken, sessionContextDrift } from './session-context-drift'
|
||||
|
||||
const SESS_A = 'sess-a'
|
||||
const SESS_B = 'sess-b'
|
||||
|
||||
// Build a route token the way desktop-controller does: pathname:search:hash.
|
||||
const routeToken = (pathname: string, search = '', hash = '') => `${pathname}:${search}:${hash}`
|
||||
|
||||
describe('routeTargetFromToken', () => {
|
||||
it('maps a session route to its session id, a non-chat route to null, and the new-chat route to __new__', () => {
|
||||
expect(routeTargetFromToken(routeToken(sessionRoute(SESS_A)))).toBe(SESS_A)
|
||||
expect(routeTargetFromToken(routeToken(SETTINGS_ROUTE))).toBeNull()
|
||||
expect(routeTargetFromToken(routeToken(NEW_CHAT_ROUTE))).toBe('__new__')
|
||||
})
|
||||
|
||||
it('ignores search and hash — only the pathname selects the chat', () => {
|
||||
expect(routeTargetFromToken(routeToken(sessionRoute(SESS_A), '?panel=preview', '#reply'))).toBe(SESS_A)
|
||||
})
|
||||
|
||||
it('treats a colon-free token as a bare pathname', () => {
|
||||
expect(routeTargetFromToken(sessionRoute(SESS_A))).toBe(SESS_A)
|
||||
})
|
||||
})
|
||||
|
||||
describe('sessionContextDrift', () => {
|
||||
it('does not drift on search/hash-only route churn', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_A), '?panel=preview', '#reply'),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBeNull()
|
||||
})
|
||||
|
||||
it('does not drift on a selection null-reset (gateway/profile switch, reconnect)', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: null,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBeNull()
|
||||
})
|
||||
|
||||
it('drifts when selection moves to a different non-null stored session', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_B,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBe('selection:sess-a->sess-b')
|
||||
})
|
||||
|
||||
it('drifts when the routed session id changes to another session', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_B)),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBe('route:sess-a->sess-b')
|
||||
})
|
||||
|
||||
it('drifts when the route moves to the new-chat route mid-submit', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(NEW_CHAT_ROUTE),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBe('route:sess-a->__new__')
|
||||
})
|
||||
|
||||
it('does not drift when the route moves to a non-chat route (null target)', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
nowRouteToken: routeToken(SETTINGS_ROUTE),
|
||||
startSelectedStoredId: SESS_A,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBeNull()
|
||||
})
|
||||
|
||||
it('does not drift when route and selection re-home onto the submit target (the create pipeline re-home)', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(NEW_CHAT_ROUTE),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_A)),
|
||||
startSelectedStoredId: null,
|
||||
nowSelectedStoredId: SESS_A,
|
||||
submitTargetStoredId: SESS_A
|
||||
})
|
||||
|
||||
expect(reason).toBeNull()
|
||||
})
|
||||
|
||||
it('drifts when a new-chat draft with no target yet is switched to an existing chat', () => {
|
||||
const reason = sessionContextDrift({
|
||||
startRouteToken: routeToken(NEW_CHAT_ROUTE),
|
||||
nowRouteToken: routeToken(sessionRoute(SESS_B)),
|
||||
startSelectedStoredId: null,
|
||||
nowSelectedStoredId: SESS_B,
|
||||
submitTargetStoredId: null
|
||||
})
|
||||
|
||||
expect(reason).toBe('route:__new__->sess-b')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
import { isNewChatRoute, routeSessionId } from '../../routes'
|
||||
|
||||
/**
|
||||
* The chat a route token points at: the stored/routed session id, `'__new__'`
|
||||
* for the new-chat route, or null for a route that isn't a chat (settings and
|
||||
* the other overlay routes). Used to compare two route tokens by their *chat*
|
||||
* rather than their raw string.
|
||||
*/
|
||||
export type RouteTarget = string | null
|
||||
|
||||
/**
|
||||
* Reduce a route token to the chat it targets. The token is
|
||||
* `${pathname}:${search}:${hash}` (desktop-controller's routeToken), and only
|
||||
* the pathname selects the chat — search/hash carry overlay/panel state, so a
|
||||
* change there must not read as a session switch. We take the substring before
|
||||
* the first ':' as the pathname; that is safe because `location.pathname` never
|
||||
* contains a raw ':' (sessionRoute encodeURIComponent's the id, so a ':' in an
|
||||
* id arrives as %3A, and the app's other routes are literal colon-free paths).
|
||||
*/
|
||||
export function routeTargetFromToken(token: string): RouteTarget {
|
||||
const separator = token.indexOf(':')
|
||||
const pathname = separator === -1 ? token : token.slice(0, separator)
|
||||
|
||||
return routeSessionId(pathname) ?? (isNewChatRoute(pathname) ? '__new__' : null)
|
||||
}
|
||||
|
||||
interface SessionContextDriftArgs {
|
||||
startRouteToken: string
|
||||
nowRouteToken: string
|
||||
startSelectedStoredId: string | null
|
||||
nowSelectedStoredId: string | null
|
||||
/**
|
||||
* The stored session this submit is bound to, when known. Drift ignores a
|
||||
* move *to* this id: the submit pipeline itself re-homes selection and route
|
||||
* onto its target (a fresh create, a resume), and that self-inflicted move is
|
||||
* not a user switch. Omit it (pre-create new-chat draft) to treat any move to
|
||||
* a real chat as drift.
|
||||
*/
|
||||
submitTargetStoredId?: string | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Decide whether the session context genuinely changed under an in-flight
|
||||
* submit — the user (or a real navigation) moved to a DIFFERENT chat — as
|
||||
* opposed to the programmatic churn a busy gateway produces constantly:
|
||||
* - selection null-resets on a gateway/profile switch or reconnect
|
||||
* (gateway-switch's `setSelectedStoredSessionId(null)`),
|
||||
* - search/hash-only route changes from overlays and side panels,
|
||||
* - background gateway events retargeting the active runtime id (#47709 class,
|
||||
* which is why the active ref is not a prong here at all).
|
||||
* Returns null when nothing genuinely drifted, or a short reason string
|
||||
* (`route:<from>-><to>` / `selection:<from>-><to>`) for the abort log.
|
||||
*/
|
||||
export function sessionContextDrift({
|
||||
startRouteToken,
|
||||
nowRouteToken,
|
||||
startSelectedStoredId,
|
||||
nowSelectedStoredId,
|
||||
submitTargetStoredId
|
||||
}: SessionContextDriftArgs): string | null {
|
||||
const targetStart = routeTargetFromToken(startRouteToken)
|
||||
const targetNow = routeTargetFromToken(nowRouteToken)
|
||||
|
||||
// Route prong: the routed chat moved to a different, real chat. A null target
|
||||
// (navigated to settings / a non-chat overlay route) or a search/hash-only
|
||||
// change (same target) is not drift, and neither is landing on the submit's
|
||||
// own target.
|
||||
if (targetNow !== targetStart && targetNow !== null && targetNow !== submitTargetStoredId) {
|
||||
return `route:${targetStart}->${targetNow}`
|
||||
}
|
||||
|
||||
// Selection prong: selection moved to a different, real stored session. A
|
||||
// null-reset (nowSelectedStoredId === null) or a move onto the submit's own
|
||||
// target is not drift.
|
||||
if (
|
||||
nowSelectedStoredId !== null &&
|
||||
nowSelectedStoredId !== startSelectedStoredId &&
|
||||
nowSelectedStoredId !== submitTargetStoredId
|
||||
) {
|
||||
return `selection:${startSelectedStoredId}->${nowSelectedStoredId}`
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -79,4 +79,14 @@ 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,6 +22,7 @@ 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'
|
||||
@@ -750,10 +751,21 @@ 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,6 +55,13 @@ 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,
|
||||
@@ -91,7 +98,7 @@ export function useMessageStream({
|
||||
return state
|
||||
}
|
||||
|
||||
const streamId = state.streamId ?? `assistant-stream-${Date.now()}`
|
||||
const streamId = state.streamId ?? nextStreamMessageId('assistant-stream')
|
||||
const groupId = state.pendingBranchGroup ?? undefined
|
||||
const prev = state.messages
|
||||
let nextMessages: ChatMessage[]
|
||||
@@ -397,19 +404,21 @@ export function useMessageStream({
|
||||
let nextMessages = state.messages
|
||||
|
||||
if (streamId && nextMessages.some(m => m.id === streamId)) {
|
||||
// Finalize the existing streaming bubble in place
|
||||
// Seal the streaming bubble in place, marked interim so it renders
|
||||
// without an action footer (see ChatMessage.interim).
|
||||
nextMessages = nextMessages.map(m =>
|
||||
m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false } : m
|
||||
m.id === streamId ? { ...m, parts: replaceTextPart(m.parts), pending: false, interim: true } : m
|
||||
)
|
||||
} else {
|
||||
// No streaming bubble — create a standalone interim message
|
||||
nextMessages = [
|
||||
...nextMessages,
|
||||
{
|
||||
id: `assistant-interim-${Date.now()}`,
|
||||
id: nextStreamMessageId('assistant-interim'),
|
||||
role: 'assistant' as const,
|
||||
parts: [assistantTextPart(authoritativeText)],
|
||||
pending: false,
|
||||
interim: true,
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
@@ -459,19 +468,15 @@ export function useMessageStream({
|
||||
return mergeFinalAssistantText(parts, visibleFinalText)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
// 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 newAssistantFromCompletion = (): ChatMessage => ({
|
||||
id: `assistant-${Date.now()}`,
|
||||
|
||||
@@ -111,6 +111,36 @@ 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,6 +120,46 @@ 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,6 +2,7 @@ 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,
|
||||
@@ -10,6 +11,7 @@ import {
|
||||
getSessionPreviewRecord,
|
||||
progressPreviewServerRestart,
|
||||
requestPreviewReload,
|
||||
setCurrentSessionPreviewTarget,
|
||||
setPreviewTarget
|
||||
} from '@/store/preview'
|
||||
import { $currentCwd } from '@/store/session'
|
||||
@@ -101,6 +103,30 @@ 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)
|
||||
|
||||
@@ -126,7 +152,7 @@ export function usePreviewRouting({
|
||||
requestPreviewReload()
|
||||
}
|
||||
},
|
||||
[activeSessionIdRef, baseHandleGatewayEvent]
|
||||
[activeSessionIdRef, baseHandleGatewayEvent, currentCwd]
|
||||
)
|
||||
|
||||
return { handleDesktopGatewayEvent, restartPreviewServer }
|
||||
|
||||
@@ -5,7 +5,18 @@ 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 { $busy, $connection, $messages, $sessions, $turnStartedAt, setSessions } from '@/store/session'
|
||||
import { $notifications, clearNotifications } from '@/store/notifications'
|
||||
import {
|
||||
$busy,
|
||||
$connection,
|
||||
$currentUsage,
|
||||
$messages,
|
||||
$sessions,
|
||||
$turnStartedAt,
|
||||
setCurrentUsage,
|
||||
setMessages,
|
||||
setSessions
|
||||
} from '@/store/session'
|
||||
import type { SessionInfo } from '@/types/hermes'
|
||||
|
||||
import type { SubmitTextOptions } from './utils'
|
||||
@@ -61,7 +72,10 @@ 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>
|
||||
}
|
||||
|
||||
@@ -98,7 +112,7 @@ function Harness({
|
||||
onSeedState?: (state: Record<string, unknown>) => void
|
||||
openMemoryGraph?: () => void
|
||||
refreshSessions: () => Promise<void>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
|
||||
requestGateway: <T>(method: string, params?: Record<string, unknown>, timeoutMs?: number) => Promise<T>
|
||||
resumeStoredSession?: (storedSessionId: string) => Promise<void> | void
|
||||
seedMessages?: unknown[]
|
||||
selectedStoredSessionIdRef?: MutableRefObject<string | null>
|
||||
@@ -160,14 +174,18 @@ 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,
|
||||
@@ -278,6 +296,513 @@ 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()
|
||||
@@ -531,6 +1056,44 @@ 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.
|
||||
@@ -703,32 +1266,41 @@ describe('usePromptActions submit / queue drain semantics', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('usePromptActions steerPrompt', () => {
|
||||
describe('usePromptActions redirectPrompt', () => {
|
||||
afterEach(() => {
|
||||
cleanup()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
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)
|
||||
it('redirects the live turn with trimmed correction text', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
const capturedStates: Record<string, unknown>[] = []
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
<Harness
|
||||
onReady={h => (handle = h)}
|
||||
onSeedState={state => capturedStates.push(state)}
|
||||
refreshSessions={async () => undefined}
|
||||
requestGateway={requestGateway}
|
||||
/>
|
||||
)
|
||||
|
||||
const accepted = await handle!.steerPrompt(' nudge the run ')
|
||||
const accepted = await handle!.redirectPrompt(' nudge the run ')
|
||||
|
||||
expect(accepted).toBe(true)
|
||||
// Steer never starts a turn — it rides the live run via session.steer only.
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.steer', {
|
||||
expect(requestGateway).toHaveBeenCalledWith('session.redirect', {
|
||||
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 gateway has no live tool window', async () => {
|
||||
it('reports rejection so the caller queues when the turn already ended', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'rejected' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
@@ -736,12 +1308,12 @@ describe('usePromptActions steerPrompt', () => {
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.steerPrompt('too late')).toBe(false)
|
||||
expect(await handle!.redirectPrompt('too late')).toBe(false)
|
||||
})
|
||||
|
||||
it('reports rejection (never throws) when the steer RPC errors', async () => {
|
||||
it('reports rejection without throwing when the redirect RPC errors', async () => {
|
||||
const requestGateway = vi.fn(async () => {
|
||||
throw new Error('agent does not support steer')
|
||||
throw new Error('agent does not support redirect')
|
||||
})
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
@@ -749,20 +1321,93 @@ describe('usePromptActions steerPrompt', () => {
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.steerPrompt('boom')).toBe(false)
|
||||
expect(await handle!.redirectPrompt('boom')).toBe(false)
|
||||
})
|
||||
|
||||
it('skips the RPC entirely for empty text', async () => {
|
||||
const requestGateway = vi.fn(async () => ({ status: 'queued' }) as never)
|
||||
const requestGateway = vi.fn(async () => ({ status: 'redirected' }) as never)
|
||||
|
||||
let handle: HarnessHandle | null = null
|
||||
await actRender(
|
||||
<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />
|
||||
)
|
||||
|
||||
expect(await handle!.steerPrompt(' ')).toBe(false)
|
||||
expect(await handle!.redirectPrompt(' ')).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,
|
||||
SessionSteerResponse
|
||||
SessionRedirectResponse
|
||||
} from '../../../types'
|
||||
|
||||
import {
|
||||
@@ -220,7 +220,7 @@ export function usePromptActions({
|
||||
const copy = t.desktop
|
||||
|
||||
const appendSessionTextMessage = useCallback(
|
||||
(sessionId: string, role: ChatMessage['role'], text: string) => {
|
||||
(sessionId: string, role: ChatMessage['role'], text: string, storedSessionId?: string | null) => {
|
||||
// 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({
|
||||
}
|
||||
]
|
||||
}),
|
||||
selectedStoredSessionIdRef.current
|
||||
storedSessionId ?? selectedStoredSessionIdRef.current
|
||||
)
|
||||
},
|
||||
[selectedStoredSessionIdRef, updateSessionState]
|
||||
@@ -478,8 +478,10 @@ export function usePromptActions({
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
submitPromptText,
|
||||
updateSessionState
|
||||
})
|
||||
|
||||
const submitText = useCallback(
|
||||
@@ -599,11 +601,11 @@ export function usePromptActions({
|
||||
}
|
||||
}, [activeSessionIdRef, busyRef, copy.stopFailed, requestGateway, selectedStoredSessionIdRef, updateSessionState])
|
||||
|
||||
// Steer = nudge the live turn without interrupting: the gateway appends the
|
||||
// text to the next tool result so the model reads it on its next iteration
|
||||
// (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(
|
||||
// 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(
|
||||
async (rawText: string): Promise<boolean> => {
|
||||
const text = sanitizeComposerInput(rawText).trim()
|
||||
const sessionId = activeSessionId || activeSessionIdRef.current
|
||||
@@ -612,25 +614,54 @@ export function usePromptActions({
|
||||
return false
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await requestGateway<SessionSteerResponse>('session.steer', { session_id: sessionId, text })
|
||||
// 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 })
|
||||
|
||||
if (result?.status === 'queued') {
|
||||
if (result?.status === 'redirected' || result?.status === 'queued') {
|
||||
triggerHaptic('submit')
|
||||
// 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}`)
|
||||
appendSessionTextMessage(id, 'user', text)
|
||||
|
||||
return true
|
||||
}
|
||||
} catch {
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
// Swallow — caller queues the text so nothing is lost.
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway]
|
||||
[activeSessionId, activeSessionIdRef, appendSessionTextMessage, requestGateway, selectedStoredSessionIdRef]
|
||||
)
|
||||
|
||||
const reloadFromMessage = useCallback(
|
||||
@@ -806,7 +837,9 @@ export function usePromptActions({
|
||||
handoffSession,
|
||||
reloadFromMessage,
|
||||
restoreToMessage,
|
||||
steerPrompt,
|
||||
redirectPrompt,
|
||||
/** @deprecated Use `redirectPrompt` — this is an active-turn redirect, not tool steer. */
|
||||
steerPrompt: redirectPrompt,
|
||||
submitText,
|
||||
transcribeVoiceAudio
|
||||
}
|
||||
|
||||
@@ -1,21 +1,23 @@
|
||||
import { type MutableRefObject, useCallback } from 'react'
|
||||
import { type MutableRefObject, useCallback, useRef } from 'react'
|
||||
|
||||
import { getProfiles } from '@/hermes'
|
||||
import type { Translations } from '@/i18n'
|
||||
import { type ChatMessage } from '@/lib/chat-messages'
|
||||
import { type ChatMessage, toChatMessages } 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 { notify, notifyError } from '@/store/notifications'
|
||||
import { dismissNotification, 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'
|
||||
@@ -23,22 +25,35 @@ import {
|
||||
$connection,
|
||||
$sessions,
|
||||
$yoloActive,
|
||||
setCurrentUsage,
|
||||
setModelPickerOpen,
|
||||
setSessionPickerOpen,
|
||||
setSessions,
|
||||
setYoloActive
|
||||
} from '@/store/session'
|
||||
|
||||
import type { BrowserManageResponse, SessionTitleResponse, SlashExecResponse } from '../../../types'
|
||||
import type {
|
||||
BrowserManageResponse,
|
||||
ClientSessionState,
|
||||
SessionCompressResponse,
|
||||
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
|
||||
@@ -50,7 +65,12 @@ interface SlashActionCtx {
|
||||
|
||||
interface SlashCommandDeps {
|
||||
activeSessionIdRef: MutableRefObject<string | null>
|
||||
appendSessionTextMessage: (sessionId: string, role: ChatMessage['role'], text: string) => void
|
||||
appendSessionTextMessage: (
|
||||
sessionId: string,
|
||||
role: ChatMessage['role'],
|
||||
text: string,
|
||||
storedSessionId?: string | null
|
||||
) => void
|
||||
branchCurrentSession: () => Promise<boolean>
|
||||
busyRef: MutableRefObject<boolean>
|
||||
copy: Translations['desktop']
|
||||
@@ -64,8 +84,14 @@ 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. */
|
||||
@@ -83,10 +109,14 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
submitPromptText,
|
||||
updateSessionState
|
||||
} = deps
|
||||
|
||||
const compressInFlightRef = useRef(new Set<string>())
|
||||
|
||||
return useCallback(
|
||||
async (rawCommand: string, options?: { sessionId?: string; recordInput?: boolean }) => {
|
||||
const ensureSessionId = async (sessionHint?: string) =>
|
||||
@@ -97,7 +127,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 } | null> => {
|
||||
): Promise<{ render: (text: string) => void; sessionId: string; storedSessionId: string | null } | null> => {
|
||||
const sessionId = await ensureSessionId(ctx.sessionHint)
|
||||
|
||||
if (!sessionId) {
|
||||
@@ -106,10 +136,19 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
return null
|
||||
}
|
||||
|
||||
const render = (text: string) =>
|
||||
appendSessionTextMessage(sessionId, 'system', ctx.recordInput ? slashStatusText(ctx.command, text) : text)
|
||||
// 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
|
||||
|
||||
return { render, sessionId }
|
||||
const render = (text: string) =>
|
||||
appendSessionTextMessage(
|
||||
sessionId,
|
||||
'system',
|
||||
ctx.recordInput ? slashStatusText(ctx.command, text) : text,
|
||||
storedSessionId
|
||||
)
|
||||
|
||||
return { render, sessionId, storedSessionId }
|
||||
}
|
||||
|
||||
// `exec` commands (and unknown skill / quick commands the backend owns)
|
||||
@@ -131,6 +170,8 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
return
|
||||
}
|
||||
|
||||
let slashExecError: unknown = null
|
||||
|
||||
const handleDispatch = async (
|
||||
dispatch: NonNullable<ReturnType<typeof parseCommandDispatch>>
|
||||
): Promise<void> => {
|
||||
@@ -206,8 +247,11 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
renderSlashOutput(output?.warning ? `warning: ${output.warning}\n${body}` : body)
|
||||
|
||||
return
|
||||
} catch {
|
||||
// Fall back to command.dispatch for skill/send/alias directives.
|
||||
} 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
|
||||
}
|
||||
|
||||
try {
|
||||
@@ -223,6 +267,66 @@ 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)}`)
|
||||
}
|
||||
}
|
||||
@@ -237,6 +341,145 @@ 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.
|
||||
@@ -607,6 +850,9 @@ 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)
|
||||
@@ -628,8 +874,10 @@ export function useSlashCommand(deps: SlashCommandDeps) {
|
||||
refreshSessions,
|
||||
requestGateway,
|
||||
resumeStoredSession,
|
||||
selectedStoredSessionIdRef,
|
||||
startFreshSessionDraft,
|
||||
submitPromptText
|
||||
submitPromptText,
|
||||
updateSessionState
|
||||
]
|
||||
)
|
||||
}
|
||||
|
||||
@@ -6,6 +6,12 @@ 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,
|
||||
@@ -17,6 +23,7 @@ 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,
|
||||
@@ -141,6 +148,16 @@ 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.
|
||||
@@ -175,11 +192,28 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
|
||||
let startingRouteToken = getRouteToken()
|
||||
|
||||
const sessionContextDrifted = (): boolean =>
|
||||
targetStartedInCurrentView &&
|
||||
(selectedStoredSessionIdRef.current !== startingStoredSessionId || getRouteToken() !== startingRouteToken)
|
||||
// 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 targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionContextDrifted()
|
||||
const targetIsCurrentView = (): boolean => targetStartedInCurrentView && !sessionDriftReason()
|
||||
|
||||
// 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
|
||||
@@ -317,7 +351,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
return abortForSessionSwitch(null)
|
||||
}
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
const routedResumeDrift = sessionDriftReason()
|
||||
|
||||
if (routedResumeDrift) {
|
||||
console.warn('[submit-drift-abort]', routedResumeDrift, { phase: 'post-routed-resume' })
|
||||
|
||||
return abortForSessionSwitch(null)
|
||||
}
|
||||
|
||||
@@ -351,7 +389,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
source: 'desktop'
|
||||
})
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
const resumeDrift = sessionDriftReason()
|
||||
|
||||
if (resumeDrift) {
|
||||
console.warn('[submit-drift-abort]', resumeDrift, { phase: 'post-resume' })
|
||||
|
||||
return abortForSessionSwitch(sessionId)
|
||||
}
|
||||
|
||||
@@ -371,7 +413,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
return abortForSessionSwitch(null)
|
||||
}
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
const resumeSettleDrift = sessionDriftReason()
|
||||
|
||||
if (resumeSettleDrift) {
|
||||
console.warn('[submit-drift-abort]', resumeSettleDrift, { phase: 'post-resume-settle' })
|
||||
|
||||
return abortForSessionSwitch(sessionId)
|
||||
}
|
||||
|
||||
@@ -400,7 +446,11 @@ 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.
|
||||
if (sessionContextDrifted()) {
|
||||
const createNullDrift = sessionDriftReason()
|
||||
|
||||
if (createNullDrift) {
|
||||
console.warn('[submit-drift-abort]', createNullDrift, { phase: 'post-create-null' })
|
||||
|
||||
return abortForSessionSwitch(null)
|
||||
}
|
||||
|
||||
@@ -439,7 +489,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
updateComposerAttachments: usingComposerAttachments
|
||||
})
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
const attachmentsDrift = sessionDriftReason()
|
||||
|
||||
if (attachmentsDrift) {
|
||||
console.warn('[submit-drift-abort]', attachmentsDrift, { phase: 'post-attachments' })
|
||||
|
||||
return abortForSessionSwitch(sessionId)
|
||||
}
|
||||
|
||||
@@ -450,6 +504,12 @@ 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.
|
||||
@@ -457,7 +517,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
|
||||
try {
|
||||
await withSessionBusyRetry(() =>
|
||||
requestGateway('prompt.submit', { session_id: sessionId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
requestGateway('prompt.submit', submitParams(sessionId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
)
|
||||
} catch (firstErr) {
|
||||
const recoverStoredSessionId = targetStoredSessionId ?? selectedStoredSessionIdRef.current
|
||||
@@ -473,7 +533,11 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
source: 'desktop'
|
||||
})
|
||||
|
||||
if (sessionContextDrifted()) {
|
||||
const resumeRetryDrift = sessionDriftReason()
|
||||
|
||||
if (resumeRetryDrift) {
|
||||
console.warn('[submit-drift-abort]', resumeRetryDrift, { phase: 'post-resume-retry' })
|
||||
|
||||
return abortForSessionSwitch(sessionId)
|
||||
}
|
||||
|
||||
@@ -485,7 +549,7 @@ export function useSubmitPrompt(deps: SubmitPromptDeps) {
|
||||
}
|
||||
|
||||
await withSessionBusyRetry(() =>
|
||||
requestGateway('prompt.submit', { session_id: recoveredId, text }, PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
requestGateway('prompt.submit', submitParams(recoveredId), PROMPT_SUBMIT_REQUEST_TIMEOUT_MS)
|
||||
)
|
||||
} else {
|
||||
submitErr = firstErr
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
isSessionBusyError,
|
||||
isSessionIdCandidate,
|
||||
isSessionNotFoundError,
|
||||
renderRpcResult,
|
||||
slashStatusText,
|
||||
visibleUserIndexAtOrdinal,
|
||||
visibleUserOrdinal
|
||||
@@ -125,3 +126,139 @@ 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,6 +192,140 @@ 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,10 +60,11 @@ import {
|
||||
} from '@/store/session-states'
|
||||
import { broadcastSessionsChanged } from '@/store/session-sync'
|
||||
import { isWatchWindow } from '@/store/windows'
|
||||
import type { SessionCreateResponse, SessionResumeResponse, UsageStats } from '@/types/hermes'
|
||||
import type { SessionCreateResponse, SessionMessage, 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,
|
||||
@@ -339,7 +340,6 @@ 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,11 +362,24 @@ export function useSessionActions({
|
||||
const created = await requestGateway<SessionCreateResponse>('session.create', params)
|
||||
const stored = created.stored_session_id ?? null
|
||||
|
||||
if (
|
||||
activeSessionIdRef.current !== startingActiveSessionId ||
|
||||
selectedStoredSessionIdRef.current !== startingStoredSessionId ||
|
||||
getRouteToken() !== startingRouteToken
|
||||
) {
|
||||
// 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' })
|
||||
await requestGateway('session.close', { session_id: created.session_id }).catch(() => undefined)
|
||||
|
||||
return null
|
||||
@@ -765,8 +778,9 @@ 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)
|
||||
@@ -792,7 +806,6 @@ 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
|
||||
@@ -819,24 +832,14 @@ 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) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
prefetchedResult = await prefetchPromise
|
||||
}
|
||||
} catch {
|
||||
// Non-fatal: gateway resume below can still hydrate the session.
|
||||
@@ -848,6 +851,16 @@ 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
|
||||
@@ -863,10 +876,7 @@ export function useSessionActions({
|
||||
const hasLiveProjection = Boolean(resumed.inflight || resumed.queued)
|
||||
|
||||
const preferredMessages =
|
||||
prefetchApplied &&
|
||||
prefetchMatchesResumedSession &&
|
||||
!hasLiveProjection &&
|
||||
resumed.messages.length <= prefetchedMessageCount
|
||||
prefetchApplied && prefetchMatchesResumedSession && !hasLiveProjection
|
||||
? localSnapshot
|
||||
: (() => {
|
||||
const previousMessages = resumedSameSelectedSession
|
||||
@@ -914,6 +924,14 @@ 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,6 +327,42 @@ 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', () => {
|
||||
@@ -354,6 +390,32 @@ 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'] as const
|
||||
const COMPARED_FIELDS = ['id', 'role', 'pending', 'error', 'hidden', 'branchGroupId', 'interim'] as const
|
||||
const IGNORED_FIELDS = ['timestamp', 'attachmentRefs', 'parts'] as const
|
||||
|
||||
// Compile-time check: every ChatMessagePart discriminant must be handled by
|
||||
@@ -154,7 +154,10 @@ export function chatMessagesEquivalent(a: ChatMessage, b: ChatMessage): boolean
|
||||
a.pending !== b.pending ||
|
||||
a.error !== b.error ||
|
||||
a.hidden !== b.hidden ||
|
||||
a.branchGroupId !== b.branchGroupId
|
||||
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)
|
||||
) {
|
||||
return false
|
||||
}
|
||||
@@ -231,9 +234,12 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes
|
||||
* dropping either makes an accepted turn appear to vanish during transport
|
||||
* churn.
|
||||
*
|
||||
* 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.
|
||||
* 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.
|
||||
*/
|
||||
export function preserveLocalPendingTurnMessages(
|
||||
nextMessages: ChatMessage[],
|
||||
@@ -254,6 +260,10 @@ 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) {
|
||||
@@ -269,6 +279,14 @@ 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) {
|
||||
@@ -311,8 +329,15 @@ 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) {
|
||||
if (inflightUser && !inflightUserAlreadyPersisted) {
|
||||
projected.push({
|
||||
id: `user-inflight-${sessionId}`,
|
||||
role: 'user',
|
||||
|
||||
@@ -21,27 +21,7 @@ import { publishSessionState } from '@/store/session-states'
|
||||
|
||||
import type { ClientSessionState } from '../../types'
|
||||
|
||||
// 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
|
||||
}
|
||||
import { chatMessageArraysEquivalent } from './use-session-actions/utils'
|
||||
|
||||
interface SessionStateCacheOptions {
|
||||
activeSessionId: string | null
|
||||
@@ -159,7 +139,12 @@ 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-identical to what's already on screen.
|
||||
// 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).
|
||||
const currentMessages = $messages.get()
|
||||
|
||||
// On a thread switch `$messages` still holds the *previous* thread, so
|
||||
@@ -172,7 +157,7 @@ export function useSessionStateCache({
|
||||
? preserveLocalAssistantErrors(pending.state.messages, currentMessages)
|
||||
: pending.state.messages
|
||||
|
||||
if (!sameMessageList(nextMessages, currentMessages)) {
|
||||
if (!chatMessageArraysEquivalent(nextMessages, currentMessages)) {
|
||||
setMessages(nextMessages)
|
||||
}
|
||||
|
||||
|
||||
@@ -247,10 +247,11 @@ 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'],
|
||||
// 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'],
|
||||
// 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).
|
||||
// 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).
|
||||
|
||||
@@ -30,18 +30,12 @@ describe('settings helpers', () => {
|
||||
expect(fieldCopyForSchemaKey(FIELD_DESCRIPTIONS, 'desktop.repo_scan_exclude_paths')).toBeTruthy()
|
||||
})
|
||||
|
||||
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'])
|
||||
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()
|
||||
})
|
||||
|
||||
describe('isExternalMemoryProvider', () => {
|
||||
|
||||
@@ -113,7 +113,7 @@ export function inferFieldSchema(value: unknown): ConfigFieldSchema {
|
||||
return { type: 'string' }
|
||||
}
|
||||
|
||||
// Backend schema omits some declared keys (e.g. memory.provider); config presence is the availability signal.
|
||||
// Backend schema omits some declared keys; config presence is the availability signal.
|
||||
export function sectionFieldEntries(
|
||||
schema: Record<string, ConfigFieldSchema>,
|
||||
config: HermesConfigRecord
|
||||
|
||||
@@ -211,6 +211,31 @@ 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({
|
||||
@@ -917,8 +942,13 @@ 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.
|
||||
fireEvent.click(await screen.findByRole('button', { name: /ElevenLabs/ }))
|
||||
// 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'))
|
||||
|
||||
const trigger = await screen.findByRole('button', { name: /Actions for ELEVENLABS_API_KEY/ })
|
||||
fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false, pointerType: 'mouse' })
|
||||
|
||||
|
||||
@@ -490,6 +490,9 @@ 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)
|
||||
|
||||
@@ -535,7 +538,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 (activeProvider || providers.length === 0) {
|
||||
if (providerChoiceClaimedRef.current || activeProvider || providers.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -545,10 +548,19 @@ 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)
|
||||
|
||||
@@ -730,6 +742,7 @@ 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"
|
||||
>
|
||||
|
||||
@@ -0,0 +1,93 @@
|
||||
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, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import { useI18n } from '@/i18n'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
@@ -7,15 +7,18 @@ 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, requestGateway, sessionId }: ContextUsagePanelProps) {
|
||||
export function ContextUsagePanel({ currentUsage, onUsageSnapshot, 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) {
|
||||
@@ -32,6 +35,11 @@ export function ContextUsagePanel({ currentUsage, requestGateway, sessionId }: C
|
||||
.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 { useMemo } from 'react'
|
||||
import { useCallback, useMemo } from 'react'
|
||||
|
||||
import type { CommandCenterSection } from '@/app/command-center'
|
||||
import { $terminalTakeover, setTerminalTakeover } from '@/app/right-sidebar/store'
|
||||
@@ -27,7 +27,8 @@ import {
|
||||
$sessions,
|
||||
$sessionStartedAt,
|
||||
$turnStartedAt,
|
||||
sessionMatchesStoredId
|
||||
sessionMatchesStoredId,
|
||||
setCurrentUsage
|
||||
} from '@/store/session'
|
||||
import { $focusedRuntimeId, $focusedSessionState, $focusedStoredSessionId } from '@/store/session-states'
|
||||
import { $subagentsBySession, activeSubagentCount, failedSubagentCount } from '@/store/subagents'
|
||||
@@ -40,7 +41,7 @@ import {
|
||||
$updateStatus,
|
||||
openUpdateOverlayFor
|
||||
} from '@/store/updates'
|
||||
import type { StatusResponse } from '@/types/hermes'
|
||||
import type { StatusResponse, UsageStats } from '@/types/hermes'
|
||||
|
||||
import { CRON_ROUTE, SETTINGS_ROUTE } from '../../routes'
|
||||
import type { StatusbarItem } from '../statusbar-controls'
|
||||
@@ -144,6 +145,14 @@ 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(
|
||||
@@ -455,7 +464,12 @@ export function useStatusbarItems({
|
||||
menuAlign: 'end',
|
||||
menuClassName: 'w-auto border-(--ui-stroke-secondary) p-0',
|
||||
menuContent: (
|
||||
<ContextUsagePanel currentUsage={currentUsage} requestGateway={requestGateway} sessionId={activeSessionId} />
|
||||
<ContextUsagePanel
|
||||
currentUsage={currentUsage}
|
||||
onUsageSnapshot={publishContextUsage}
|
||||
requestGateway={requestGateway}
|
||||
sessionId={activeSessionId}
|
||||
/>
|
||||
),
|
||||
title: copy.openContextUsage,
|
||||
variant: 'menu'
|
||||
@@ -496,6 +510,7 @@ export function useStatusbarItems({
|
||||
contextUsage,
|
||||
copy,
|
||||
currentUsage,
|
||||
publishContextUsage,
|
||||
requestGateway,
|
||||
sessionStartedAt,
|
||||
gatewayState,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import type * as React from 'react'
|
||||
|
||||
import type { ChatMessage } from '@/lib/chat-messages'
|
||||
import type { UsageStats } from '@/types/hermes'
|
||||
import type { SessionMessage, UsageStats } from '@/types/hermes'
|
||||
|
||||
export interface ContextSuggestion {
|
||||
text: string
|
||||
@@ -53,6 +53,31 @@ 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.
|
||||
@@ -60,6 +85,11 @@ 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, useState } from 'react'
|
||||
import { type FC, useCallback, useMemo } from 'react'
|
||||
|
||||
import {
|
||||
contentHasVisibleText,
|
||||
@@ -15,23 +15,17 @@ import {
|
||||
pickPrimaryPreviewTarget
|
||||
} from '@/components/assistant-ui/thread/content'
|
||||
import { MESSAGE_PARTS_COMPONENTS } from '@/components/assistant-ui/thread/message-parts'
|
||||
import { StreamStallIndicator } from '@/components/assistant-ui/thread/status'
|
||||
import { ResponseLoadingIndicator, 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 { GitBranchIcon, Loader2Icon, Volume2Icon, VolumeXIcon, XIcon } from '@/lib/icons'
|
||||
import { AudioLines, GitForkIcon, Loader2Icon, RefreshCwIcon, 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'
|
||||
@@ -65,6 +59,10 @@ 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
|
||||
@@ -85,10 +83,6 @@ 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"
|
||||
@@ -103,7 +97,7 @@ export const AssistantMessage: FC<{
|
||||
>
|
||||
{/* Todos render in the composer status stack now, not inline. */}
|
||||
<MessagePrimitive.Parts components={MESSAGE_PARTS_COMPONENTS} />
|
||||
{isRunning && <StreamStallIndicator />}
|
||||
{isPlaceholder ? <ResponseLoadingIndicator /> : isRunning && <StreamStallIndicator />}
|
||||
{previewTargets.length > 0 && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{previewTargets.map(target => (
|
||||
@@ -130,7 +124,7 @@ export const AssistantMessage: FC<{
|
||||
</ErrorPrimitive.Root>
|
||||
</MessagePrimitive.Error>
|
||||
</div>
|
||||
{hasVisibleText && (
|
||||
{hasVisibleText && !isInterim && (
|
||||
<AssistantFooter getMessageText={getMessageText} messageId={messageId} onBranchInNewChat={onBranchInNewChat} />
|
||||
)}
|
||||
</MessagePrimitive.Root>
|
||||
@@ -140,12 +134,11 @@ 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={cn(
|
||||
className={
|
||||
// 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
|
||||
@@ -153,38 +146,33 @@ 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-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'
|
||||
)}
|
||||
'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'
|
||||
}
|
||||
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}>
|
||||
<Codicon name="refresh" />
|
||||
<RefreshCwIcon className="size-3.5" />
|
||||
</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 ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
|
||||
const ReadAloudButton: FC<{ getText: () => string; messageId: string }> = ({ getText, messageId }) => {
|
||||
const { t } = useI18n()
|
||||
const copy = t.assistant.thread
|
||||
const voicePlayback = useStore($voicePlayback)
|
||||
@@ -195,7 +183,8 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe
|
||||
const isPreparing = readAloudStatus === 'preparing'
|
||||
const isSpeaking = readAloudStatus === 'speaking'
|
||||
const anyPlaybackActive = voicePlayback.status !== 'idle'
|
||||
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : Volume2Icon
|
||||
const Icon = isPreparing ? Loader2Icon : isSpeaking ? VolumeXIcon : AudioLines
|
||||
const tooltip = isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud
|
||||
|
||||
const read = useCallback(async () => {
|
||||
const text = getText()
|
||||
@@ -212,29 +201,37 @@ const ReadAloudItem: FC<{ getText: () => string; messageId: string }> = ({ getTe
|
||||
}, [copy.readAloudFailed, getText, messageId])
|
||||
|
||||
return (
|
||||
<DropdownMenuItem
|
||||
<TooltipIconButton
|
||||
disabled={isPreparing || (!isSpeaking && anyPlaybackActive)}
|
||||
onSelect={e => {
|
||||
e.preventDefault()
|
||||
onClick={() => {
|
||||
triggerHaptic('selection')
|
||||
void (isSpeaking ? stopVoicePlayback() : read())
|
||||
}}
|
||||
tooltip={tooltip}
|
||||
>
|
||||
<Icon className={isPreparing ? 'animate-spin' : undefined} />
|
||||
{isPreparing ? copy.preparingAudio : isSpeaking ? copy.stopReading : copy.readAloud}
|
||||
</DropdownMenuItem>
|
||||
<Icon className={cn('size-3.5', isPreparing && 'animate-spin')} />
|
||||
</TooltipIconButton>
|
||||
)
|
||||
}
|
||||
|
||||
const MessageTimestamp: FC = () => {
|
||||
const MessageAge: FC = () => {
|
||||
const { t } = useI18n()
|
||||
const createdAt = useAuiState(s => s.message.createdAt)
|
||||
const label = formatMessageTimestamp(createdAt, t.assistant.thread)
|
||||
const date = createdAt ? new Date(createdAt) : null
|
||||
|
||||
if (!label) {
|
||||
if (!date || Number.isNaN(date.getTime())) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <DropdownMenuLabel className="text-xs font-normal text-muted-foreground">{label}</DropdownMenuLabel>
|
||||
// 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>
|
||||
)
|
||||
}
|
||||
|
||||
const AssistantFooter: FC<MessageActionProps> = props => (
|
||||
|
||||
@@ -2,11 +2,7 @@ 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,
|
||||
ResponseLoadingIndicator
|
||||
} from '@/components/assistant-ui/thread/status'
|
||||
import { BackgroundResumeNotice, CenteredThreadSpinner } 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'
|
||||
@@ -100,7 +96,7 @@ export const Thread: FC<{
|
||||
clampToComposer={clampToComposer}
|
||||
components={messageComponents}
|
||||
emptyPlaceholder={emptyPlaceholder}
|
||||
loadingIndicator={loading === 'response' ? <ResponseLoadingIndicator /> : <BackgroundResumeNotice />}
|
||||
loadingIndicator={<BackgroundResumeNotice />}
|
||||
sessionKey={sessionKey}
|
||||
/>
|
||||
{loading === 'session' && <CenteredThreadSpinner />}
|
||||
|
||||
@@ -73,6 +73,7 @@ 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,6 +328,37 @@ 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],
|
||||
@@ -455,6 +486,34 @@ 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).')} />)
|
||||
|
||||
|
||||
@@ -268,15 +268,39 @@ 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 text-[0.66rem] tabular-nums',
|
||||
'shrink-0 inline-flex items-center gap-1.5 text-[0.66rem] tabular-nums',
|
||||
isCurrent ? 'text-primary-foreground/80' : 'text-muted-foreground'
|
||||
)}
|
||||
title={copy.priceTitle}
|
||||
>
|
||||
{price.input || '?'} / {price.output || '?'}
|
||||
{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}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1077,6 +1077,7 @@ 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`
|
||||
@@ -2137,7 +2138,8 @@ export const en: Translations = {
|
||||
proNeedsSubscription: 'Pro models need a paid Nous subscription.',
|
||||
free: 'Free',
|
||||
freeTier: 'Free tier',
|
||||
priceTitle: 'Input / Output price per million tokens'
|
||||
priceTitle: 'Input / Output price per million tokens',
|
||||
wasPrice: 'was'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
|
||||
@@ -1057,6 +1057,7 @@ 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} トーク`
|
||||
@@ -2073,7 +2074,8 @@ export const ja = defineLocale({
|
||||
proNeedsSubscription: 'Pro モデルには有料の Nous サブスクリプションが必要です。',
|
||||
free: '無料',
|
||||
freeTier: '無料プラン',
|
||||
priceTitle: '100 万トークンあたりの入力/出力価格'
|
||||
priceTitle: '100 万トークンあたりの入力/出力価格',
|
||||
wasPrice: '旧価格'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
|
||||
@@ -949,6 +949,7 @@ 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
|
||||
@@ -1761,6 +1762,7 @@ export interface Translations {
|
||||
free: string
|
||||
freeTier: string
|
||||
priceTitle: string
|
||||
wasPrice: string
|
||||
}
|
||||
|
||||
modelVisibility: {
|
||||
|
||||
@@ -1025,6 +1025,7 @@ 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} 詞元`
|
||||
@@ -2004,7 +2005,8 @@ export const zhHant = defineLocale({
|
||||
proNeedsSubscription: 'Pro 模型需要付費 Nous 訂閱。',
|
||||
free: '免費',
|
||||
freeTier: '免費層',
|
||||
priceTitle: '每百萬 Token 的輸入/輸出價格'
|
||||
priceTitle: '每百萬 Token 的輸入/輸出價格',
|
||||
wasPrice: '原價'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
|
||||
@@ -1272,6 +1272,7 @@ 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} 词元`
|
||||
@@ -2315,7 +2316,8 @@ export const zh: Translations = {
|
||||
proNeedsSubscription: 'Pro 模型需要付费 Nous 订阅。',
|
||||
free: '免费',
|
||||
freeTier: '免费层',
|
||||
priceTitle: '每百万 token 的输入/输出价格'
|
||||
priceTitle: '每百万 token 的输入/输出价格',
|
||||
wasPrice: '原价'
|
||||
},
|
||||
|
||||
modelVisibility: {
|
||||
|
||||
@@ -17,6 +17,10 @@ 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[]
|
||||
}
|
||||
@@ -76,6 +80,8 @@ 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
|
||||
|
||||
@@ -381,7 +381,8 @@ export function toRuntimeMessage(message: ChatMessage): ThreadMessage {
|
||||
unstable_annotations: [],
|
||||
unstable_data: [],
|
||||
steps: [],
|
||||
custom: {}
|
||||
// Carries ChatMessage.interim to AssistantMessage's footer gate.
|
||||
custom: message.interim ? { interim: true } : {}
|
||||
}
|
||||
} as ThreadMessage
|
||||
}
|
||||
|
||||
@@ -36,6 +36,12 @@ 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)
|
||||
|
||||
|
||||
@@ -101,5 +101,9 @@ const remoteGit: GitBridge = {
|
||||
}
|
||||
|
||||
export function desktopGit(): GitBridge | undefined {
|
||||
if (typeof window === 'undefined') {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return isDesktopFsRemoteMode() ? remoteGit : window.hermesDesktop?.git
|
||||
}
|
||||
|
||||
@@ -81,6 +81,66 @@ 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' })
|
||||
|
||||
@@ -31,6 +31,7 @@ export interface DesktopThemeCommandOption {
|
||||
export type DesktopActionId =
|
||||
| 'branch'
|
||||
| 'browser'
|
||||
| 'compress'
|
||||
| 'handoff'
|
||||
| 'hatch'
|
||||
| 'help'
|
||||
@@ -55,17 +56,41 @@ export type DesktopUnavailableReason = 'advanced' | 'messaging' | 'settings' | '
|
||||
* - `action` → handled by a local client handler (new chat, branch, …)
|
||||
* - `picker` → opens an overlay (`/model`, `/resume`); a typed arg is
|
||||
* resolved by that picker instead of falling through
|
||||
* - `rpc` → dedicated gateway RPC named on the surface. The dispatcher
|
||||
* calls it directly with the params built by `buildParams`,
|
||||
* bypassing `slash.exec` / `command.dispatch`. Reserved for
|
||||
* commands that have a first-class RPC handler in
|
||||
* `tui_gateway/server.py` (e.g. `/save` → session.save).
|
||||
* - `exec` → runs on the backend via slash.exec / command.dispatch and
|
||||
* renders its text output inline
|
||||
* renders its text output inline. Only commands WITHOUT a
|
||||
* dedicated RPC should stay here.
|
||||
* - `unavailable`→ a known command with genuinely no desktop UI (terminal-only,
|
||||
* messaging-only, …); shows a reason instead of executing
|
||||
*/
|
||||
export type DesktopCommandSurface =
|
||||
| { kind: 'action'; action: DesktopActionId }
|
||||
| { kind: 'picker'; picker: DesktopPickerId }
|
||||
| {
|
||||
kind: 'rpc'
|
||||
rpc: string
|
||||
timeoutMs?: number
|
||||
buildParams: (ctx: SlashCommandBuildCtx) => Record<string, unknown>
|
||||
}
|
||||
| { kind: 'exec' }
|
||||
| { kind: 'unavailable'; reason: DesktopUnavailableReason }
|
||||
|
||||
/**
|
||||
* Inputs a `buildParams` function receives. The dispatcher passes session id,
|
||||
* the typed arg, and the canonical command name so handlers can construct
|
||||
* the exact JSON the gateway method expects.
|
||||
*/
|
||||
export interface SlashCommandBuildCtx {
|
||||
arg: string
|
||||
command: string
|
||||
name: string
|
||||
sessionId: string
|
||||
}
|
||||
|
||||
export interface DesktopCommandSpec {
|
||||
/** Canonical command, leading slash included (e.g. `/resume`). */
|
||||
name: string
|
||||
@@ -92,6 +117,21 @@ const action = (id: DesktopActionId): DesktopCommandSurface => ({ kind: 'action'
|
||||
const picker = (id: DesktopPickerId): DesktopCommandSurface => ({ kind: 'picker', picker: id })
|
||||
const unavailable = (reason: DesktopUnavailableReason): DesktopCommandSurface => ({ kind: 'unavailable', reason })
|
||||
|
||||
/**
|
||||
* Route a command directly to its dedicated gateway RPC. Prefer this over
|
||||
* `exec()` whenever `tui_gateway/server.py` exposes a `@method(...)` for the
|
||||
* command — bypassing `slash.exec` keeps the path short and the response
|
||||
* structured.
|
||||
*
|
||||
* The dispatcher calls `requestGateway(surface.rpc, surface.buildParams(ctx))`
|
||||
* and then runs `renderRpcResult` to format the response.
|
||||
*/
|
||||
const rpc = (
|
||||
rpcName: string,
|
||||
buildParams: (ctx: SlashCommandBuildCtx) => Record<string, unknown>,
|
||||
timeoutMs?: number
|
||||
): DesktopCommandSurface => ({ kind: 'rpc', rpc: rpcName, timeoutMs, buildParams })
|
||||
|
||||
/**
|
||||
* THE source of truth for desktop slash commands. Everything below — execution
|
||||
* gating, popover suggestions, catalog filtering, pill grouping, and the
|
||||
@@ -140,7 +180,16 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
args: true
|
||||
},
|
||||
|
||||
// Backend-executed commands that render useful inline output
|
||||
// Backend-executed commands that render useful inline output.
|
||||
// Commands with a dedicated gateway RPC (@method in tui_gateway/server.py)
|
||||
// route to it directly via `rpc(...)` — bypassing slash.exec avoids the
|
||||
// slash-worker pipe timeout and the "not a quick/plugin/skill command"
|
||||
// fallback noise for commands the dispatcher doesn't handle inline.
|
||||
// These commands have gateway RPCs, but their established desktop behavior
|
||||
// carries richer CLI semantics: /agents includes delegations, /stop cancels
|
||||
// them, /steer falls back to a next-turn prompt, and /usage is a formatted
|
||||
// live report. Keep them on slash.exec until their RPC contracts are fully
|
||||
// equivalent.
|
||||
{
|
||||
name: '/agents',
|
||||
description: 'Show active desktop sessions and running tasks',
|
||||
@@ -148,7 +197,17 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
surface: exec()
|
||||
},
|
||||
{ name: '/background', description: 'Run a prompt in the background', aliases: ['/bg', '/btw'], surface: exec() },
|
||||
{ name: '/compress', description: 'Compress this conversation context', aliases: ['/compact'], surface: exec() },
|
||||
// /compress must be an action (session.compress RPC), not exec: the slash
|
||||
// worker route times out on large sessions (30s WS / 45s pipe) before the
|
||||
// LLM summarise call finishes, then command.dispatch surfaces a bogus
|
||||
// "not a quick/plugin/skill command: compress" (#44456).
|
||||
{
|
||||
name: '/compress',
|
||||
description: 'Compress this conversation context',
|
||||
aliases: ['/compact'],
|
||||
surface: action('compress'),
|
||||
args: true
|
||||
},
|
||||
{ name: '/debug', description: 'Create a debug report', surface: exec() },
|
||||
{ name: '/goal', description: 'Manage the standing goal for this session', surface: exec() },
|
||||
{ name: '/personality', description: 'Switch personality for this session', surface: exec(), args: true },
|
||||
@@ -167,9 +226,17 @@ const DESKTOP_COMMAND_SPECS: readonly DesktopCommandSpec[] = [
|
||||
{ name: '/queue', description: 'Queue a prompt for the next turn', aliases: ['/q'], surface: exec() },
|
||||
{ name: '/retry', description: 'Retry the last user message', surface: exec() },
|
||||
{ name: '/rollback', description: 'List or restore filesystem checkpoints', surface: exec() },
|
||||
{ name: '/save', description: 'Save the current transcript to JSON', surface: exec() },
|
||||
{ name: '/status', description: 'Show current session status', surface: exec() },
|
||||
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec() },
|
||||
{
|
||||
name: '/save',
|
||||
description: 'Save the current transcript to JSON',
|
||||
surface: rpc('session.save', ctx => ({ session_id: ctx.sessionId }))
|
||||
},
|
||||
{
|
||||
name: '/status',
|
||||
description: 'Show current session status',
|
||||
surface: rpc('session.status', ctx => ({ session_id: ctx.sessionId }))
|
||||
},
|
||||
{ name: '/steer', description: 'Steer the current run after the next tool call', surface: exec(), args: true },
|
||||
{ name: '/stop', description: 'Stop running background processes', surface: exec() },
|
||||
{ name: '/tools', description: 'List or toggle tools available to the agent', surface: exec(), args: true },
|
||||
{ name: '/undo', description: 'Remove the last user/assistant exchange', surface: exec() },
|
||||
|
||||
@@ -44,6 +44,8 @@ import {
|
||||
IconFolderOpen as FolderOpen,
|
||||
IconGitBranch as GitBranch,
|
||||
IconGitBranch as GitBranchIcon,
|
||||
IconGitFork as GitFork,
|
||||
IconGitFork as GitForkIcon,
|
||||
IconGlobe as Globe,
|
||||
IconHash as Hash,
|
||||
IconHelpCircle as HelpCircle,
|
||||
@@ -163,6 +165,8 @@ export {
|
||||
FolderOpen,
|
||||
GitBranch,
|
||||
GitBranchIcon,
|
||||
GitFork,
|
||||
GitForkIcon,
|
||||
Globe,
|
||||
Hash,
|
||||
HelpCircle,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user