Compare commits
30
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ef6bbd201 | ||
|
|
18ca0e862c | ||
|
|
0e281b58e6 | ||
|
|
086a56a028 | ||
|
|
35e0f56fbf | ||
|
|
08626c18be | ||
|
|
a5b9803a51 | ||
|
|
acc1b6e76a | ||
|
|
eb09df6ec8 | ||
|
|
a632e68a01 | ||
|
|
456f18b19c | ||
|
|
52e16c1138 | ||
|
|
2ffdf08376 | ||
|
|
b99e1e3bf6 | ||
|
|
3133af8215 | ||
|
|
aa274364bb | ||
|
|
470c7e2a60 | ||
|
|
9403b4f8ba | ||
|
|
17a99f6b15 | ||
|
|
c0dff40e3a | ||
|
|
faa4cec01b | ||
|
|
28028cce55 | ||
|
|
977884e6cd | ||
|
|
ae22a03ef6 | ||
|
|
a660630986 | ||
|
|
5e999b98cb | ||
|
|
25eafd7d71 | ||
|
|
495d4acec5 | ||
|
|
54c39c0301 | ||
|
|
77aa026ca6 |
@@ -87,12 +87,6 @@ jobs:
|
||||
uses: ./.github/workflows/js-tests.yml
|
||||
secrets: inherit
|
||||
|
||||
e2e-desktop:
|
||||
name: Desktop E2E
|
||||
needs: detect
|
||||
if: needs.detect.outputs.python == 'true' || needs.detect.outputs.frontend == 'true'
|
||||
uses: ./.github/workflows/e2e-desktop.yml
|
||||
|
||||
docs-site:
|
||||
name: Docs Site
|
||||
needs: detect
|
||||
@@ -171,7 +165,6 @@ jobs:
|
||||
- tests
|
||||
- lint
|
||||
- js-tests
|
||||
- e2e-desktop
|
||||
- docs-site
|
||||
- history-check
|
||||
- contributor-check
|
||||
|
||||
@@ -1,212 +0,0 @@
|
||||
name: E2E Desktop
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
concurrency:
|
||||
group: e2e-desktop-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
e2e:
|
||||
name: Playwright E2E (Linux)
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 20
|
||||
steps:
|
||||
- uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
# ── System deps for Electron on headless Ubuntu ───────────────────
|
||||
# Electron needs GTK, NSS,atk, etc. even under xvfb. Playwright's
|
||||
# install-deps covers browsers; for Electron we install the apt
|
||||
# packages directly.
|
||||
- name: Install system dependencies for Electron
|
||||
run: |
|
||||
sudo apt-get update -qq
|
||||
sudo apt-get install -y -qq \
|
||||
xvfb \
|
||||
libgtk-3-0 libnotify4 libnss3 libxss1 libxtst6 \
|
||||
xdg-utils libatspi2.0-0 libdrm2 libgbm1 libasound2t64
|
||||
|
||||
# ── Node ───────────────────────────────────────────────────────────
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
# Full npm ci (not --ignore-scripts): electron's postinstall
|
||||
# downloads the binary we launch, and node-pty's native build is
|
||||
# needed for the terminal pane.
|
||||
- uses: ./.github/actions/retry
|
||||
with:
|
||||
command: npm ci
|
||||
|
||||
# ── Python (for the hermes serve backend) ──────────────────────────
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@fac544c07dec837d0ccb6301d7b5580bf5edae39 # 8.2.0
|
||||
with:
|
||||
enable-cache: true
|
||||
cache-dependency-glob: |
|
||||
pyproject.toml
|
||||
uv.lock
|
||||
- name: Set up Python 3.11
|
||||
run: uv python install 3.11
|
||||
- name: Install Python dependencies
|
||||
uses: ./.github/actions/retry
|
||||
with:
|
||||
command: uv sync --locked --python 3.11 --extra all --extra dev
|
||||
|
||||
# ── Build desktop app ─────────────────────────────────────────────
|
||||
- run: npm run --prefix apps/desktop build
|
||||
|
||||
# ── Restore visual baseline screenshots from main ──────────────────
|
||||
# Baselines are generated on main (via --update-snapshots) and cached.
|
||||
# On PRs, we restore them so toHaveScreenshot has something to compare
|
||||
# against. The cache key is keyed on the desktop source files so a
|
||||
# UI change naturally invalidates it — but we fall back to the main
|
||||
# cache to avoid cold starts on unrelated PRs.
|
||||
- name: Restore visual baseline screenshots
|
||||
id: restore-baselines
|
||||
uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: apps/desktop/e2e/*-snapshots
|
||||
key: visual-baselines-${{ github.ref_name }}
|
||||
restore-keys: |
|
||||
visual-baselines-main
|
||||
|
||||
# ── Run Playwright E2E under xvfb ─────────────────────────────────
|
||||
# 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.
|
||||
- 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" \
|
||||
npx playwright test --reporter=list --update-snapshots
|
||||
else
|
||||
echo "On PR — comparing against cached baselines"
|
||||
xvfb-run -a --server-args="-screen 0 1280x1024x24" \
|
||||
npx playwright test --reporter=list
|
||||
fi
|
||||
env:
|
||||
CI: "true"
|
||||
# Ensure no real API keys leak into the test env.
|
||||
OPENROUTER_API_KEY: ""
|
||||
OPENAI_API_KEY: ""
|
||||
NOUS_API_KEY: ""
|
||||
|
||||
# ── Save updated baselines to cache (main only) ───────────────────
|
||||
- name: Save updated baselines to cache
|
||||
if: github.ref_name == 'main' && always()
|
||||
uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4
|
||||
with:
|
||||
path: apps/desktop/e2e/*-snapshots
|
||||
key: visual-baselines-main
|
||||
|
||||
# ── Upload Playwright report (HTML + traces) ──────────────────────
|
||||
- name: Upload Playwright report
|
||||
id: upload-report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-report-${{ github.sha }}
|
||||
path: apps/desktop/playwright-report
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
|
||||
# ── Upload test results (screenshots, traces, diffs) ───────────────
|
||||
- name: Upload test results
|
||||
id: upload-results
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: playwright-test-results-${{ github.sha }}
|
||||
path: apps/desktop/test-results
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
|
||||
# ── Upload just the visual diffs (small, fast to review) ──────────
|
||||
- name: Upload visual diffs
|
||||
id: upload-diffs
|
||||
if: always() && github.ref_name != 'main'
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: visual-diffs-${{ github.sha }}
|
||||
path: |
|
||||
apps/desktop/test-results/**/*-diff.png
|
||||
apps/desktop/test-results/**/*-actual.png
|
||||
apps/desktop/test-results/**/*-expected.png
|
||||
retention-days: 14
|
||||
overwrite: true
|
||||
if-no-files-found: ignore
|
||||
|
||||
# ── 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
|
||||
# without downloading artifacts. Runs AFTER uploads so it can link
|
||||
# the artifact download URLs from their step outputs.
|
||||
- name: Generate visual diff summary
|
||||
if: always()
|
||||
working-directory: apps/desktop
|
||||
env:
|
||||
REPORT_URL: ${{ steps.upload-report.outputs.artifact-url }}
|
||||
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"
|
||||
|
||||
# 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"
|
||||
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"
|
||||
|
||||
# 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$//')
|
||||
test_name=$(basename "$base")
|
||||
echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY"
|
||||
echo "" >> "$GITHUB_STEP_SUMMARY"
|
||||
if [ -n "$RESULTS_URL" ]; then
|
||||
echo "- [playwright-test-results]($RESULTS_URL) — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [ -n "$REPORT_URL" ]; then
|
||||
echo "- [playwright-report]($REPORT_URL) — interactive HTML report" >> "$GITHUB_STEP_SUMMARY"
|
||||
fi
|
||||
if [ -n "$DIFFS_URL" ]; then
|
||||
echo "- [visual-diffs]($DIFFS_URL) — just the diffed screenshots (small, fast to review)" >> "$GITHUB_STEP_SUMMARY"
|
||||
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"
|
||||
|
||||
# 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"
|
||||
node -e "
|
||||
const r = require('./playwright-report/results.json');
|
||||
const stats = r.stats || {};
|
||||
console.log('| Status | Count |');
|
||||
console.log('|--------|-------|');
|
||||
console.log('| ✅ Passed | ' + (stats.expected || 0) + ' |');
|
||||
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
|
||||
fi
|
||||
@@ -1,396 +0,0 @@
|
||||
name: E2E Windows Desktop
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [ethie/e2e]
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: e2e-windows-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
# this is separated so we don't have node.js and stuff polluting the system
|
||||
build-installer:
|
||||
if: false # NOTE: build-installer is disabled for now, since we don't ship updates to the installer. when we do, re-enable it.
|
||||
name: Build Hermes-Setup.exe
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 30
|
||||
|
||||
steps:
|
||||
- name: checkout cache inputs
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
sparse-checkout: |
|
||||
package-lock.json
|
||||
apps/bootstrap-installer
|
||||
sparse-checkout-cone-mode: true
|
||||
|
||||
# The cache key is the exact installer build fingerprint. A hit means
|
||||
# this package-lock + bootstrap-installer source combo was already built,
|
||||
# so we can skip the entire Node/Rust/toolchain dance and just upload it.
|
||||
- name: Restore installer build cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
id: installer-cache
|
||||
with:
|
||||
path: Hermes-Setup.exe
|
||||
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('package-lock.json', 'apps/bootstrap-installer/**', '!apps/bootstrap-installer/src-tauri/target/**') }}
|
||||
|
||||
- name: Setup Node.js
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4
|
||||
with:
|
||||
node-version: 22
|
||||
cache: npm
|
||||
|
||||
- name: Setup Rust
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: dtolnay/rust-toolchain@1.96.0 # stable
|
||||
- name: checkout full tree on cache miss
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
uses: actions/checkout@v4
|
||||
|
||||
- name: Install npm dependencies
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
run: npm ci
|
||||
|
||||
- name: Build dev installer for this branch
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
run: npm run tauri:build
|
||||
working-directory: apps/bootstrap-installer
|
||||
timeout-minutes: 10
|
||||
|
||||
# Only runs on cache miss. Pick the exe the Tauri build produced and
|
||||
# normalize its name so downstream jobs always know what to download.
|
||||
- name: Normalize installer artifact name
|
||||
if: steps.installer-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
$candidates = @(
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/bundle/app/Hermes_0.0.1_x64-setup.exe',
|
||||
'apps/bootstrap-installer/src-tauri/target/release/Hermes.exe'
|
||||
)
|
||||
$installer = $null
|
||||
foreach ($c in $candidates) {
|
||||
if (Test-Path $c) {
|
||||
$installer = Resolve-Path $c
|
||||
break
|
||||
}
|
||||
}
|
||||
if (-not $installer) {
|
||||
$installer = Get-ChildItem -Path 'apps/bootstrap-installer/src-tauri/target/release' `
|
||||
-Recurse -Filter '*.exe' | Where-Object { $_.Name -notlike '*setup*' -or $true } | Select-Object -First 1 -ExpandProperty FullName
|
||||
}
|
||||
if (-not $installer) {
|
||||
throw 'Could not find built Hermes-Setup.exe'
|
||||
}
|
||||
Copy-Item -Path $installer -Destination 'Hermes-Setup.exe' -Force
|
||||
Write-Host "Normalized installer: Hermes-Setup.exe (from $installer)"
|
||||
|
||||
e2e:
|
||||
name: Install this commit as latest main
|
||||
if: false # NOTE: this job isn't used yet.
|
||||
# needs: build-installer
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 60
|
||||
|
||||
env:
|
||||
# Isolated install directory so the real install flow doesn't touch the
|
||||
# runner's user profile. Kept under the workspace for easy cleanup.
|
||||
HERMES_HOME: ${{ github.workspace }}\.e2e-hermes-home
|
||||
INSTALL_DIR: ${{ github.workspace }}\.e2e-hermes-home\hermes-agent
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
path: source
|
||||
|
||||
- name: Restore installer from build cache
|
||||
if: false # build-installer is since we don't update the installer right now. instead, we download the latest installer from the website
|
||||
id: installer-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: Hermes-Setup.exe
|
||||
key: hermes-installer-built-cache-${{ runner.os }}-${{ hashFiles('source/package-lock.json', 'source/apps/bootstrap-installer/**', '!source/apps/bootstrap-installer/src-tauri/target/**') }}
|
||||
|
||||
- name: Download latest production installer
|
||||
id: download-installer
|
||||
shell: pwsh
|
||||
run: |
|
||||
Invoke-WebRequest https://hermes-assets.nousresearch.com/Hermes-Setup.exe -OutFile Hermes-Setup.exe
|
||||
|
||||
- name: Restore cached test tools
|
||||
id: test-tools-cache
|
||||
uses: actions/cache@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5
|
||||
with:
|
||||
path: test-bins
|
||||
key: test-bins-${{ runner.os }}-v1
|
||||
|
||||
- name: Install AutoHotkey v2 and ffmpeg
|
||||
if: steps.test-tools-cache.outputs.cache-hit != 'true'
|
||||
shell: pwsh
|
||||
run: |
|
||||
# Install fresh when the cache missed.
|
||||
|
||||
New-Item -ItemType Directory -Path test-bins\autohotkey, test-bins\ffmpeg -Force | Out-Null
|
||||
|
||||
# AutoHotkey: copy its whole v2 directory so helper exes/dlls come along.
|
||||
winget install -e --id AutoHotkey.AutoHotkey --silent --accept-source-agreements --accept-package-agreements --disable-interactivity
|
||||
$ahkDir = "$env:ProgramW6432\AutoHotkey\v2"
|
||||
if (-not (Test-Path $ahkDir)) {
|
||||
throw "AutoHotkey install directory not found: $ahkDir"
|
||||
}
|
||||
Copy-Item -Path "$ahkDir\*" -Destination test-bins\autohotkey -Recurse -Force
|
||||
|
||||
# ffmpeg : just install into dir
|
||||
winget install -e --id Gyan.FFmpeg --silent --accept-source-agreements --accept-package-agreements --disable-interactivity --location ffmpeg_dir
|
||||
Copy-Item -Path "ffmpeg_dir\*\*" -Destination test-bins\ffmpeg -Recurse -Force
|
||||
|
||||
- name: Add test-bins to PATH
|
||||
shell: pwsh
|
||||
run: |
|
||||
ls "$PWD\test-bins\ffmpeg"
|
||||
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\autohotkey"
|
||||
Add-Content -Path $env:GITHUB_PATH -Value "$PWD\test-bins\ffmpeg\bin"
|
||||
|
||||
# ── Prepare an isolated HERMES_HOME and copy checked-out repo ──────
|
||||
# actions/checkout already has the right commit; just mirror it into the
|
||||
# isolated home so the installer doesn't need to reach GitHub.
|
||||
- name: Move checked-out workspace into isolated HERMES_HOME
|
||||
shell: pwsh
|
||||
run: |
|
||||
New-Item -ItemType Directory -Path $env:INSTALL_DIR -Force
|
||||
Get-ChildItem -Path ${{ github.workspace }}\source -Force | Move-Item -Destination $env:INSTALL_DIR -Force
|
||||
Write-Host "Isolated install dir ready: $env:INSTALL_DIR"
|
||||
|
||||
# ── Run the headed installer + AHK helper ───────────────────────
|
||||
- name: Launch Hermes-Setup.exe and install it
|
||||
shell: pwsh
|
||||
timeout-minutes: 10
|
||||
env:
|
||||
HERMES_SETUP_DEV_REPO_ROOT: ${{ env.INSTALL_DIR }}
|
||||
run: |
|
||||
$installer = "Hermes-Setup.exe"
|
||||
|
||||
# ── Start screen recording (live stdin pipe) ──────────────────
|
||||
# ffmpeg must be started, fed, and stopped from the SAME step: the
|
||||
# graceful-stop signal is the character 'q' written to ffmpeg's live
|
||||
# stdin. A separate teardown step can't do this because the process
|
||||
# that owns the writable stdin pipe dies when this step ends.
|
||||
#
|
||||
# Start-Process / -RedirectStandardInput <file> does NOT work: it
|
||||
# hands ffmpeg a file handle opened once at EOF, so appending 'q' to
|
||||
# the file on disk never reaches the running process. We need a real
|
||||
# writable pipe, which only System.Diagnostics.Process exposes.
|
||||
#
|
||||
# -pix_fmt yuv420p keeps
|
||||
# the output broadly playable.
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.FileName = "ffmpeg"
|
||||
$psi.Arguments = "-y -f gdigrab -framerate 15 -i desktop " +
|
||||
"-hide_banner -loglevel error " +
|
||||
"-c:v libx264 -preset ultrafast -pix_fmt yuv420p recording.mkv"
|
||||
$psi.RedirectStandardInput = $true
|
||||
$psi.UseShellExecute = $false
|
||||
$ffmpeg = [System.Diagnostics.Process]::Start($psi)
|
||||
$ffmpeg.Id | Out-File ffmpeg.pid
|
||||
# Note: stderr is intentionally left attached to the console so it is
|
||||
# captured in the step log. Do NOT redirect a stream we don't drain --
|
||||
# ffmpeg's progress output would fill the pipe buffer and block.
|
||||
Write-Host "ffmpeg recording started (pid $($ffmpeg.Id ))"
|
||||
|
||||
$e2eDir = "$env:RUNNER_TEMP\e2e-windows"
|
||||
|
||||
Copy-Item -Path "$env:INSTALL_DIR\e2e\windows" -Destination $e2eDir -Force -Recurse
|
||||
|
||||
$installerSuccess = $false
|
||||
try {
|
||||
# Launch the real installer
|
||||
$proc = Start-Process -FilePath $installer -PassThru -NoNewWindow
|
||||
$proc.Id | Out-File installer.pid
|
||||
|
||||
$ahkProc = Start-Process -FilePath ".\test-bins\autohotkey\AutoHotkey64.exe" `
|
||||
-ArgumentList "$e2eDir\install-hermes-desktop.ahk", "$PWD\ahk.log" -PassThru -NoNewWindow
|
||||
|
||||
# Wait for AHK helper to finish, and tail logs.
|
||||
|
||||
$logReader = $null
|
||||
$logStream = $null
|
||||
$logPath = Join-Path $env:HERMES_HOME "logs\bootstrap-installer.log"
|
||||
# can take a long time for installer!
|
||||
$deadline = (Get-Date).AddSeconds(60 * 8)
|
||||
|
||||
try {
|
||||
while ((Get-Date) -lt $deadline -and -not $ahkProc.HasExited) {
|
||||
if (-not $logReader) {
|
||||
if (Test-Path $logPath) {
|
||||
Write-Host "Found bootstrap-installer.log; tailing..."
|
||||
# FileShare.ReadWrite is required: the installer almost
|
||||
# certainly still has the file open for writing, and a
|
||||
# plain Get-Content/File.Open would throw or lock it out.
|
||||
$logStream = [System.IO.File]::Open(
|
||||
$logPath, 'Open', 'Read', 'ReadWrite')
|
||||
$logReader = New-Object System.IO.StreamReader($logStream)
|
||||
}
|
||||
} else {
|
||||
$line = $logReader.ReadLine()
|
||||
while ($null -ne $line) {
|
||||
Write-Host "[bootstrap] $line"
|
||||
$line = $logReader.ReadLine()
|
||||
}
|
||||
}
|
||||
Start-Sleep -Milliseconds 500
|
||||
}
|
||||
|
||||
# Drain anything written in the final tick before exit/timeout.
|
||||
if ($logReader) {
|
||||
$line = $logReader.ReadLine()
|
||||
while ($null -ne $line) {
|
||||
Write-Host "[bootstrap] $line"
|
||||
$line = $logReader.ReadLine()
|
||||
}
|
||||
}
|
||||
}
|
||||
finally {
|
||||
if ($logReader) { $logReader.Dispose() }
|
||||
if ($logStream) { $logStream.Dispose() }
|
||||
}
|
||||
|
||||
if (-not $ahkProc.HasExited) {
|
||||
Write-Host "AHK helper is still running; stopping it"
|
||||
Stop-Process -Id $ahkProc.Id -Force -ErrorAction SilentlyContinue
|
||||
} else {
|
||||
Write-Host "autohotkey helper exited"
|
||||
}
|
||||
}
|
||||
|
||||
finally {
|
||||
# Gracefully stop ffmpeg by writing 'q' to its LIVE stdin pipe, so
|
||||
# the container header/index are finalized and the mkv is playable.
|
||||
# This runs in the same step that owns the pipe, even on failure.
|
||||
if ($ffmpeg -and -not $ffmpeg.HasExited) {
|
||||
Write-Host "Stopping ffmpeg gracefully (q on stdin)"
|
||||
try {
|
||||
$ffmpeg.StandardInput.Write("q")
|
||||
$ffmpeg.StandardInput.Close()
|
||||
} catch {
|
||||
Write-Host "Failed to write q to ffmpeg stdin: $_"
|
||||
}
|
||||
if (-not $ffmpeg.WaitForExit(15000)) {
|
||||
Write-Host "ffmpeg did not exit after 15s; killing"
|
||||
$ffmpeg.Kill()
|
||||
}
|
||||
}
|
||||
Write-Host "ffmpeg stopped"
|
||||
|
||||
# Installer should have exited
|
||||
if ($proc.HasExited) {
|
||||
# TODO check exit code once we add exit code in installer
|
||||
$installerSuccess = $true
|
||||
} else {
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
throw "Installer is still running. Install did not succeed."
|
||||
}
|
||||
if (-not $installerSuccess) {
|
||||
throw "Installer did not exit after autohotkey script finished. Check installer logs!"
|
||||
}
|
||||
}
|
||||
|
||||
# ── Run Playwright against the installed binary ─────────────────
|
||||
# (placeholder: will be enabled once installer completes successfully.)
|
||||
- name: Launch installed app and run e2e
|
||||
if: false
|
||||
working-directory: source/apps/desktop
|
||||
run: npm exec playwright test e2e/ --reporter=list
|
||||
env:
|
||||
# Point the e2e spec at the desktop binary that the installer built.
|
||||
HERMES_E2E_INSTALL_ROOT: ${{ env.HERMES_HOME }}\hermes-agent
|
||||
|
||||
# ── Teardown & artifacts ────────────────────────────────────────
|
||||
# ffmpeg is normally started AND gracefully stopped inside the launch
|
||||
# step (so 'q' reaches its live stdin pipe). This step is only a
|
||||
# safety net: if the launch step timed out or crashed before its
|
||||
# finally block ran, force-kill any orphaned ffmpeg so the runner can
|
||||
# release recording.mkv for upload. The mkv container survives a hard
|
||||
# kill (only the trailing seek index is lost), so the artifact is still
|
||||
# usable for coordinate discovery even on this fallback path.
|
||||
- name: Stop orphaned screen recording (safety net)
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$ffmpegpid = Get-Content ffmpeg.pid -ErrorAction SilentlyContinue
|
||||
if ($ffmpegpid) {
|
||||
$proc = Get-Process -Id $ffmpegpid -ErrorAction SilentlyContinue
|
||||
if ($proc) {
|
||||
Write-Host "Orphaned ffmpeg (pid $ffmpegpid) still running; force-stopping"
|
||||
Stop-Process -Id $proc.Id -Force -ErrorAction SilentlyContinue
|
||||
Start-Sleep -Seconds 2
|
||||
} else {
|
||||
Write-Host "ffmpeg already exited cleanly; nothing to do"
|
||||
}
|
||||
}
|
||||
|
||||
- name: Burn debug overlay into recording
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
$logPath = Join-Path $PWD 'ahk.log'
|
||||
$x = $y = $w = $h = $null
|
||||
if (Test-Path $logPath) {
|
||||
$line = Get-Content $logPath -Raw | Select-String -Pattern 'Window found at x=(\d+) y=(\d+) w=(\d+) h=(\d+)' -AllMatches | Select-Object -Last 1
|
||||
if ($line) {
|
||||
$x = [int]$line.Matches[0].Groups[1].Value
|
||||
$y = [int]$line.Matches[0].Groups[2].Value
|
||||
$w = [int]$line.Matches[0].Groups[3].Value
|
||||
$h = [int]$line.Matches[0].Groups[4].Value
|
||||
Write-Host "Parsed window rect: x=$x y=$y w=$w h=$h"
|
||||
} else {
|
||||
Write-Host "no window rect found in ahk.log; only timestamp will be burned"
|
||||
}
|
||||
} else {
|
||||
Write-Host "ahk.log not found; only timestamp will be burned"
|
||||
}
|
||||
|
||||
# Build the timestamp overlay
|
||||
$vf = "drawtext=text='%{pts\:hms}':fontfile='C\:\\Windows\\Fonts\\arial.ttf':fontsize=20:fontcolor=white:box=1:boxcolor=black@0.5:x=8:y=8"
|
||||
|
||||
if ($x -ne $null -and $y -ne $null -and $w -ne $null -and $h -ne $null) {
|
||||
# Window border + 16px grid only over the window + axis labels
|
||||
$grid = "drawbox=x=$x`:y=$y`:w=$w`:hf=$h`:color=red@0.9:t=2,split=2[box][win];[win]crop=$w`:$h`:$x`:$y`:$y"
|
||||
}
|
||||
|
||||
Write-Host "Overlay filter: $vf"
|
||||
|
||||
ffmpeg -y -i recording.mkv -vf "$vf" -c:v libx264 -preset veryfast -pix_fmt yuv420p recording-overlay.mkv
|
||||
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ffmpeg overlay failed"
|
||||
}
|
||||
|
||||
Move-Item -Path recording-overlay.mkv -Destination recording.mkv -Force
|
||||
Write-Host "Overlayed recording saved as recording.mkv"
|
||||
|
||||
- name: Upload screen recording
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
id: upload-recording
|
||||
if: always()
|
||||
with:
|
||||
name: screen-recording-${{ github.sha }}
|
||||
path: recording.mkv
|
||||
retention-days: 1
|
||||
archive: false
|
||||
overwrite: true
|
||||
|
||||
- name: Bootstrap Installer log
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-Content "$env:HERMES_HOME\logs\bootstrap-installer.log"
|
||||
|
||||
- name: Autohotkey log
|
||||
if: always()
|
||||
shell: pwsh
|
||||
run: |
|
||||
Get-Content ahk.log
|
||||
@@ -4,8 +4,6 @@
|
||||
/_pycache/
|
||||
*.pyc*
|
||||
__pycache__/
|
||||
act/
|
||||
.act-sandbox-agent.*
|
||||
.venv/
|
||||
.venv
|
||||
.vscode/
|
||||
@@ -56,10 +54,6 @@ __pycache__/
|
||||
hermes_agent.egg-info/
|
||||
wandb/
|
||||
testlogs
|
||||
playwright-report/
|
||||
test-results/
|
||||
# Playwright visual regression baselines — cached from main in CI, not committed
|
||||
*-snapshots/
|
||||
|
||||
# CLI config (may contain sensitive SSH paths)
|
||||
cli-config.yaml
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"id": "hermes-agent",
|
||||
"name": "Hermes Agent",
|
||||
"version": "0.18.2",
|
||||
"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",
|
||||
@@ -9,7 +9,7 @@
|
||||
"license": "MIT",
|
||||
"distribution": {
|
||||
"uvx": {
|
||||
"package": "hermes-agent[acp]==0.18.2",
|
||||
"package": "hermes-agent[acp]==0.19.0",
|
||||
"args": ["hermes-acp"]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -457,7 +457,8 @@ def _is_kimi_coding_endpoint(base_url: str | None) -> bool:
|
||||
|
||||
# Model-name prefixes that identify the Kimi / Moonshot family. Covers
|
||||
# - official slugs: ``kimi-k2.5``, ``kimi_thinking``, ``moonshot-v1-8k``
|
||||
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``
|
||||
# - common release lines: ``k1.5-...``, ``k2-thinking``, ``k25-...``, ``k2.5-...``,
|
||||
# and the bare Coding Plan slug ``k3`` (plus ``k3.x``/``k3-...`` variants)
|
||||
# Matched case-insensitively against the post-``normalize_model_name`` form,
|
||||
# so a caller's ``provider/vendor/model`` slug is handled the same as a
|
||||
# bare name.
|
||||
@@ -467,8 +468,14 @@ _KIMI_FAMILY_MODEL_PREFIXES = (
|
||||
"k1.", "k1-",
|
||||
"k2.", "k2-",
|
||||
"k25", "k2.5",
|
||||
"k3.", "k3-",
|
||||
)
|
||||
|
||||
# Bare release slugs with no separator suffix (Kimi Coding Plan serves K3
|
||||
# as the exact slug ``k3``). Kept exact-match so unrelated model names that
|
||||
# merely start with the same characters don't get misclassified.
|
||||
_KIMI_FAMILY_EXACT_SLUGS = frozenset({"k3"})
|
||||
|
||||
|
||||
def _model_name_is_kimi_family(model: str | None) -> bool:
|
||||
if not isinstance(model, str):
|
||||
@@ -479,6 +486,8 @@ def _model_name_is_kimi_family(model: str | None) -> bool:
|
||||
# Strip vendor prefix (e.g. ``moonshotai/kimi-k2.5`` → ``kimi-k2.5``)
|
||||
if "/" in m:
|
||||
m = m.rsplit("/", 1)[-1]
|
||||
if m in _KIMI_FAMILY_EXACT_SLUGS:
|
||||
return True
|
||||
return m.startswith(_KIMI_FAMILY_MODEL_PREFIXES)
|
||||
|
||||
|
||||
|
||||
+140
-71
@@ -18,9 +18,15 @@ into it via :func:`agent.lsp.manager.LSPService.touch_file`.
|
||||
|
||||
Implementation notes:
|
||||
|
||||
- Push diagnostics are stored per-URI in :attr:`_push_diagnostics` from
|
||||
``textDocument/publishDiagnostics`` notifications. Pull diagnostics
|
||||
go in :attr:`_pull_diagnostics`. The merged view dedupes by content.
|
||||
- All per-document state lives in one :class:`_DocState` keyed by
|
||||
absolute path. Freshness is tracked with **document versions**,
|
||||
not timestamps: every didChange bumps ``version``, and each stored
|
||||
push/pull result is tagged with the version it describes. A
|
||||
result is fresh iff its tag >= the version being waited on, so a
|
||||
didChange implicitly invalidates everything older — no clearing,
|
||||
no clock comparisons, no race windows. This is what prevents
|
||||
"ghost diagnostics": a slow server's leftovers from the previous
|
||||
edit can never masquerade as a verdict on the current content.
|
||||
|
||||
- Whole-document sync. Even when the server advertises incremental
|
||||
sync, we send a single ``contentChanges`` entry replacing the
|
||||
@@ -45,6 +51,7 @@ import asyncio
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Any, Awaitable, Callable, Dict, List, Optional, Set
|
||||
from urllib.parse import quote, unquote
|
||||
@@ -124,6 +131,40 @@ def _end_position(text: str) -> Dict[str, int]:
|
||||
return {"line": last_line, "character": last_col}
|
||||
|
||||
|
||||
@dataclass
|
||||
class _DocState:
|
||||
"""Everything the client tracks for one open document.
|
||||
|
||||
``version`` is the LSP document version we last sent (didOpen=0,
|
||||
each didChange +1). It doubles as the freshness token: stored
|
||||
push/pull results are tagged with the version they describe
|
||||
(``push_version`` / ``pull_version``), and a result is *fresh*
|
||||
iff its tag has caught up to ``version``. Bumping the version on
|
||||
didChange therefore invalidates all older results implicitly —
|
||||
no store-clearing, no timestamps.
|
||||
|
||||
``push_version``/``pull_version`` start at -1 = "no data yet".
|
||||
Servers that echo a document version in publishDiagnostics get
|
||||
exact tagging; those that don't are credited with the current
|
||||
version at receipt time (a push observed after we sent the
|
||||
change describes the changed content or newer).
|
||||
"""
|
||||
|
||||
version: int = 0
|
||||
text: str = ""
|
||||
push: List[Dict[str, Any]] = field(default_factory=list)
|
||||
pull: List[Dict[str, Any]] = field(default_factory=list)
|
||||
push_version: int = -1
|
||||
pull_version: int = -1
|
||||
seed_seen: bool = False
|
||||
|
||||
def fresh_push(self, version: Optional[int] = None) -> bool:
|
||||
return self.push_version >= (self.version if version is None else version)
|
||||
|
||||
def fresh_pull(self, version: Optional[int] = None) -> bool:
|
||||
return self.pull_version >= (self.version if version is None else version)
|
||||
|
||||
|
||||
class LSPClient:
|
||||
"""Async LSP client tied to one server process and one workspace root.
|
||||
|
||||
@@ -186,18 +227,10 @@ class LSPClient:
|
||||
# is silently dropped by default.
|
||||
}
|
||||
|
||||
# Tracked file state — required for didChange version bumps.
|
||||
self._files: Dict[str, Dict[str, Any]] = {}
|
||||
# Diagnostic stores, keyed by file path (NOT URI).
|
||||
self._push_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
self._pull_diagnostics: Dict[str, List[Dict[str, Any]]] = {}
|
||||
# Per-path "last published" time so wait-for-fresh logic works.
|
||||
self._published: Dict[str, float] = {}
|
||||
# Per-path version of the latest push (matches our didChange
|
||||
# version when the server respects it).
|
||||
self._published_version: Dict[str, int] = {}
|
||||
# First-push seen flag, for typescript-style seed-on-first-push.
|
||||
self._first_push_seen: Set[str] = set()
|
||||
# Per-document state (version, text, diagnostic stores, and
|
||||
# their freshness tags), keyed by absolute file path (NOT URI).
|
||||
# See _DocState for the version-based freshness model.
|
||||
self._docs: Dict[str, _DocState] = {}
|
||||
# Capability registrations — only diagnostic ones are tracked.
|
||||
self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {}
|
||||
|
||||
@@ -647,25 +680,25 @@ class LSPClient:
|
||||
if not isinstance(diagnostics, list):
|
||||
diagnostics = []
|
||||
version = params.get("version")
|
||||
loop_time = asyncio.get_event_loop().time()
|
||||
|
||||
if self._seed_first_push and path not in self._first_push_seen:
|
||||
# First push: seed without firing the event so a waiter
|
||||
# doesn't resolve on the very first push (which arrives
|
||||
# before the user-triggered didChange could've produced
|
||||
# fresh diagnostics).
|
||||
self._first_push_seen.add(path)
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
doc = self._docs.setdefault(path, _DocState(version=-1))
|
||||
if self._seed_first_push and not doc.seed_seen:
|
||||
# First push: seed the store WITHOUT a freshness tag. It
|
||||
# arrives before the user-triggered didChange could've
|
||||
# produced fresh diagnostics, so it must never satisfy a
|
||||
# waiter — it's baseline data only.
|
||||
doc.seed_seen = True
|
||||
doc.push = diagnostics
|
||||
return
|
||||
|
||||
self._push_diagnostics[path] = diagnostics
|
||||
self._published[path] = loop_time
|
||||
if isinstance(version, int):
|
||||
self._published_version[path] = version
|
||||
self._first_push_seen.add(path)
|
||||
doc.seed_seen = True
|
||||
doc.push = diagnostics
|
||||
# Tag with the echoed document version when the server provides
|
||||
# one; otherwise credit the current version — a push observed
|
||||
# after we sent the change describes the changed content (or
|
||||
# newer). Note doc.version is -1 for never-opened paths
|
||||
# (e.g. relatedDocuments spillover), keeping them unfresh.
|
||||
doc.push_version = version if isinstance(version, int) else doc.version
|
||||
# Bump the monotonic push counter and wake every waiter. We
|
||||
# keep the Event sticky-set so any wait already in progress
|
||||
# resolves; waiters re-check their predicate after waking and
|
||||
@@ -694,16 +727,16 @@ class LSPClient:
|
||||
raise LSPProtocolError(f"cannot read {abs_path}: {e}") from e
|
||||
|
||||
uri = file_uri(abs_path)
|
||||
existing = self._files.get(abs_path)
|
||||
doc = self._docs.get(abs_path)
|
||||
|
||||
if existing is not None:
|
||||
if doc is not None and doc.version >= 0:
|
||||
# Re-open: bump version, fire didChangeWatchedFiles + didChange.
|
||||
await self._send_notification(
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 2}]}, # 2 = CHANGED
|
||||
)
|
||||
new_version = existing["version"] + 1
|
||||
old_text = existing["text"]
|
||||
new_version = doc.version + 1
|
||||
old_text = doc.text
|
||||
content_changes: List[Dict[str, Any]]
|
||||
if self._sync_kind == 2:
|
||||
content_changes = [
|
||||
@@ -724,7 +757,11 @@ class LSPClient:
|
||||
"contentChanges": content_changes,
|
||||
},
|
||||
)
|
||||
self._files[abs_path] = {"version": new_version, "text": text}
|
||||
# Bumping the version is the whole invalidation story:
|
||||
# every stored result tagged with an older version is now
|
||||
# stale by definition (see _DocState).
|
||||
doc.version = new_version
|
||||
doc.text = text
|
||||
return new_version
|
||||
|
||||
# First open: didChangeWatchedFiles CREATED + didOpen.
|
||||
@@ -732,12 +769,9 @@ class LSPClient:
|
||||
"workspace/didChangeWatchedFiles",
|
||||
{"changes": [{"uri": uri, "type": 1}]}, # 1 = CREATED
|
||||
)
|
||||
# Clear any stale push/pull entries — fresh open should start
|
||||
# from scratch.
|
||||
self._push_diagnostics.pop(abs_path, None)
|
||||
self._pull_diagnostics.pop(abs_path, None)
|
||||
self._published.pop(abs_path, None)
|
||||
self._published_version.pop(abs_path, None)
|
||||
# Fresh doc state — anything stashed under this path by a
|
||||
# pre-open push (relatedDocuments spillover etc.) is discarded.
|
||||
self._docs[abs_path] = _DocState(version=0, text=text)
|
||||
await self._send_notification(
|
||||
"textDocument/didOpen",
|
||||
{
|
||||
@@ -749,7 +783,6 @@ class LSPClient:
|
||||
}
|
||||
},
|
||||
)
|
||||
self._files[abs_path] = {"version": 0, "text": text}
|
||||
return 0
|
||||
|
||||
async def save_file(self, path: str) -> None:
|
||||
@@ -769,12 +802,19 @@ class LSPClient:
|
||||
async def _pull_document_diagnostics(self, path: str) -> None:
|
||||
"""Send ``textDocument/diagnostic`` for one file.
|
||||
|
||||
Stores results into :attr:`_pull_diagnostics`. Silently
|
||||
no-ops on errors (server may not support the pull endpoint).
|
||||
Stores results into the doc's pull store, tagged with the
|
||||
document version captured at request send time. If a didChange
|
||||
races past the in-flight request, the version bump makes the
|
||||
stored result stale automatically — no explicit invalidation.
|
||||
Silently no-ops on errors (server may not support the pull
|
||||
endpoint).
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
doc = self._docs.get(abs_path)
|
||||
sent_version = doc.version if doc else -1
|
||||
try:
|
||||
params: Dict[str, Any] = {
|
||||
"textDocument": {"uri": file_uri(os.path.abspath(path))}
|
||||
"textDocument": {"uri": file_uri(abs_path)}
|
||||
}
|
||||
result = await self._send_request_with_retry(
|
||||
"textDocument/diagnostic",
|
||||
@@ -788,7 +828,9 @@ class LSPClient:
|
||||
return
|
||||
items = result.get("items")
|
||||
if isinstance(items, list):
|
||||
self._pull_diagnostics[os.path.abspath(path)] = items
|
||||
doc = self._docs.setdefault(abs_path, _DocState(version=-1))
|
||||
doc.pull = items
|
||||
doc.pull_version = sent_version
|
||||
related = result.get("relatedDocuments")
|
||||
if isinstance(related, dict):
|
||||
for uri, sub in related.items():
|
||||
@@ -796,7 +838,11 @@ class LSPClient:
|
||||
continue
|
||||
sub_items = sub.get("items")
|
||||
if isinstance(sub_items, list):
|
||||
self._pull_diagnostics[uri_to_path(uri)] = sub_items
|
||||
rel = self._docs.setdefault(uri_to_path(uri), _DocState(version=-1))
|
||||
rel.pull = sub_items
|
||||
# Same send-anchored tagging: fresh only if that
|
||||
# doc hasn't changed since the request went out.
|
||||
rel.pull_version = rel.version
|
||||
|
||||
async def wait_for_diagnostics(
|
||||
self,
|
||||
@@ -804,22 +850,36 @@ class LSPClient:
|
||||
version: int,
|
||||
*,
|
||||
mode: str = "document",
|
||||
) -> None:
|
||||
timeout: Optional[float] = None,
|
||||
) -> bool:
|
||||
"""Wait for the server to publish diagnostics for ``path`` at ``version``.
|
||||
|
||||
``mode`` is ``"document"`` (5s budget, document pulls) or
|
||||
``"full"`` (10s budget, also workspace pulls). Best-effort —
|
||||
returns silently on timeout. Does NOT throw if the server
|
||||
doesn't support pull diagnostics; we still get the push side.
|
||||
``"full"`` (10s budget, also workspace pulls). ``timeout``
|
||||
overrides the mode's default budget when provided — this is
|
||||
how the user's ``lsp.wait_timeout`` config reaches the wait
|
||||
loop (slow servers like tsserver on big projects need more
|
||||
than the 5s default).
|
||||
|
||||
Returns ``True`` when *fresh* diagnostics arrived (a push at
|
||||
or after our didChange, or a pull answered after it) and
|
||||
``False`` on timeout. Callers must treat ``False`` as "no
|
||||
data", NOT as "no errors" — the diagnostic stores may still
|
||||
hold stale entries from the previous edit at that point.
|
||||
Best-effort — never throws if the server doesn't support pull
|
||||
diagnostics; we still get the push side.
|
||||
"""
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
if timeout is not None and timeout > 0:
|
||||
budget = timeout
|
||||
else:
|
||||
budget = DIAGNOSTICS_FULL_WAIT if mode == "full" else DIAGNOSTICS_DOCUMENT_WAIT
|
||||
deadline = asyncio.get_event_loop().time() + budget
|
||||
abs_path = os.path.abspath(path)
|
||||
|
||||
while True:
|
||||
remaining = deadline - asyncio.get_event_loop().time()
|
||||
if remaining <= 0:
|
||||
return
|
||||
return False
|
||||
|
||||
# Concurrent: document pull + push wait.
|
||||
pull_task = asyncio.create_task(self._pull_document_diagnostics(abs_path))
|
||||
@@ -838,26 +898,24 @@ class LSPClient:
|
||||
pass
|
||||
|
||||
# If we got a fresh push for our version, we're done.
|
||||
current_v = self._published_version.get(abs_path)
|
||||
if abs_path in self._published and (
|
||||
current_v is None or current_v >= version
|
||||
):
|
||||
return
|
||||
doc = self._docs.get(abs_path)
|
||||
if doc and doc.fresh_push(version):
|
||||
return True
|
||||
|
||||
# Pull may have populated _pull_diagnostics — that's also
|
||||
# success.
|
||||
if abs_path in self._pull_diagnostics:
|
||||
return
|
||||
# Pull may have answered for the current version — that's
|
||||
# also success.
|
||||
if doc and doc.fresh_pull(version):
|
||||
return True
|
||||
|
||||
# Loop until budget runs out.
|
||||
|
||||
async def _wait_for_fresh_push(self, path: str, version: int, timeout: float) -> None:
|
||||
"""Wait until a publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
"""Wait until a fresh publishDiagnostics arrives for ``path`` at ``version``+."""
|
||||
deadline = asyncio.get_event_loop().time() + timeout
|
||||
baseline = self._push_counter
|
||||
while True:
|
||||
current_v = self._published_version.get(path)
|
||||
if path in self._published and (current_v is None or current_v >= version):
|
||||
doc = self._docs.get(path)
|
||||
if doc and doc.fresh_push(version):
|
||||
# Debounce — wait a tick in case more diagnostics arrive
|
||||
# immediately after. TS often emits in pairs. We
|
||||
# snapshot the counter so we wake on a *new* push, not
|
||||
@@ -888,17 +946,28 @@ class LSPClient:
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
|
||||
def diagnostics_for(self, path: str) -> List[Dict[str, Any]]:
|
||||
def diagnostics_for(self, path: str, *, fresh_only: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Return current merged + deduped diagnostics for one file.
|
||||
|
||||
Diagnostics from push and pull stores are concatenated and
|
||||
deduplicated by ``(severity, code, message, range)`` content
|
||||
key. Empty list if the server hasn't published anything.
|
||||
|
||||
With ``fresh_only=True``, a store only contributes when its
|
||||
version tag has caught up to the document's current version —
|
||||
stale leftovers from the previous edit cycle are excluded.
|
||||
This is what report paths should use: after an edit, "stale
|
||||
errors" and "no errors" must not be conflated.
|
||||
"""
|
||||
abs_path = os.path.abspath(path)
|
||||
push = self._push_diagnostics.get(abs_path) or []
|
||||
pull = self._pull_diagnostics.get(abs_path) or []
|
||||
return _dedupe(push, pull)
|
||||
doc = self._docs.get(os.path.abspath(path))
|
||||
if doc is None:
|
||||
return []
|
||||
if fresh_only:
|
||||
return _dedupe(
|
||||
doc.push if doc.fresh_push() else [],
|
||||
doc.pull if doc.fresh_pull() else [],
|
||||
)
|
||||
return _dedupe(doc.push, doc.pull)
|
||||
|
||||
|
||||
def _dedupe(*lists: List[Dict[str, Any]]) -> List[Dict[str, Any]]:
|
||||
|
||||
+41
-10
@@ -292,7 +292,10 @@ class LSPService:
|
||||
if not self.enabled_for(file_path):
|
||||
return
|
||||
try:
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=8.0)
|
||||
# Outer join budget must exceed the inner wait budget or a
|
||||
# slow-but-alive server gets falsely marked broken.
|
||||
t = max(8.0, self._wait_timeout + 3.0)
|
||||
diags = self._loop.run(self._snapshot_async(file_path), timeout=t)
|
||||
self._delta_baseline[os.path.abspath(file_path)] = diags or []
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("baseline snapshot failed for %s: %s", file_path, e)
|
||||
@@ -341,7 +344,7 @@ class LSPService:
|
||||
|
||||
try:
|
||||
t = timeout if timeout is not None else self._wait_timeout + 2.0
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t) or []
|
||||
diags = self._loop.run(self._open_and_wait_async(file_path), timeout=t)
|
||||
except asyncio.TimeoutError as e:
|
||||
eventlog.log_timeout(server_id, file_path)
|
||||
logger.debug("LSP diagnostics timeout for %s: %s", file_path, e)
|
||||
@@ -353,6 +356,17 @@ class LSPService:
|
||||
self._mark_broken_for_file(file_path, e)
|
||||
return []
|
||||
|
||||
if diags is None:
|
||||
# The server is alive but never produced diagnostics for the
|
||||
# post-edit content within the wait budget (common for
|
||||
# tsserver on large projects). Report "no data" rather than
|
||||
# whatever stale state is in the stores — surfacing the
|
||||
# previous edit's errors as if they were current is the
|
||||
# ghost-diagnostics bug. The server is NOT marked broken:
|
||||
# slow is not dead, and the next edit may well succeed.
|
||||
eventlog.log_timeout(server_id, file_path, kind="fresh diagnostics")
|
||||
return []
|
||||
|
||||
abs_path = os.path.abspath(file_path)
|
||||
if delta:
|
||||
baseline = self._delta_baseline.get(abs_path) or []
|
||||
@@ -452,26 +466,43 @@ class LSPService:
|
||||
return []
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
fresh = await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("snapshot open/wait failed: %s", e)
|
||||
return []
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
return list(client.diagnostics_for(file_path))
|
||||
if not fresh:
|
||||
# No fresh data for the pre-edit content — an empty baseline
|
||||
# is safe: worst case the delta filter removes less, never
|
||||
# more. Never seed the baseline from stale stores.
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
|
||||
async def _open_and_wait_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
async def _open_and_wait_async(self, file_path: str) -> Optional[List[Dict[str, Any]]]:
|
||||
"""Open + wait for FRESH diagnostics.
|
||||
|
||||
Returns the fresh diagnostic list, or ``None`` when the server
|
||||
never produced post-change data within the wait budget. The
|
||||
distinction matters: ``[]`` means "server checked the new
|
||||
content, it's clean", ``None`` means "no verdict" — the caller
|
||||
must not substitute stale data for either.
|
||||
"""
|
||||
client = await self._get_or_spawn(file_path)
|
||||
if client is None:
|
||||
return []
|
||||
return None
|
||||
try:
|
||||
version = await client.open_file(file_path, language_id=language_id_for(file_path))
|
||||
await client.save_file(file_path)
|
||||
await client.wait_for_diagnostics(file_path, version, mode=self._wait_mode)
|
||||
fresh = await client.wait_for_diagnostics(
|
||||
file_path, version, mode=self._wait_mode, timeout=self._wait_timeout
|
||||
)
|
||||
except Exception as e: # noqa: BLE001
|
||||
logger.debug("open/wait failed for %s: %s", file_path, e)
|
||||
return []
|
||||
return None
|
||||
self._last_used[(client.server_id, client.workspace_root)] = time.time()
|
||||
return list(client.diagnostics_for(file_path))
|
||||
if not fresh:
|
||||
return None
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
|
||||
async def _current_diags_async(self, file_path: str) -> List[Dict[str, Any]]:
|
||||
ws, gated = resolve_workspace_for_file(file_path)
|
||||
@@ -482,7 +513,7 @@ class LSPService:
|
||||
client = self._clients.get((srv.server_id, ws))
|
||||
if client is None:
|
||||
return []
|
||||
return list(client.diagnostics_for(file_path))
|
||||
return list(client.diagnostics_for(file_path, fresh_only=True))
|
||||
|
||||
async def _get_or_spawn(self, file_path: str) -> Optional[LSPClient]:
|
||||
srv = find_server_for_file(file_path)
|
||||
|
||||
+14
-3
@@ -319,7 +319,12 @@ DEFAULT_CONTEXT_LENGTHS = {
|
||||
"grok-3": 131072, # grok-3, grok-3-mini, grok-3-fast, grok-3-mini-fast
|
||||
"grok-2": 131072, # grok-2, grok-2-1212, grok-2-latest
|
||||
"grok": 131072, # catch-all (grok-beta, unknown grok-*)
|
||||
# Kimi
|
||||
# Kimi — K3 ships with a 1 Mi context window (1,048,576; verified against
|
||||
# models.dev and OpenRouter live metadata, matching the endpoint-scoped
|
||||
# override in _endpoint_scoped_context_length). Longest-key-first substring
|
||||
# matching ensures "kimi-k3" resolves to 1M while older/unknown Kimi models
|
||||
# still hit the generic 256K fallback.
|
||||
"kimi-k3": 1_048_576,
|
||||
"kimi": 262144,
|
||||
# Upstage Solar — api.upstage.ai/v1/models does not return context_length,
|
||||
# so these fallbacks keep token budgeting / compression from probing down
|
||||
@@ -543,7 +548,13 @@ def _is_known_provider_base_url(base_url: str) -> bool:
|
||||
|
||||
|
||||
def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
|
||||
"""Return metadata confirmed only for one provider endpoint."""
|
||||
"""Return metadata confirmed only for the Kimi Coding endpoint.
|
||||
|
||||
Kimi Coding serves K3 under the bare slug ``k3``, but users may also
|
||||
configure or select the public-facing aliases ``kimi-k3`` and
|
||||
``kimi-k3-cot``. Only canonical ``https://api.kimi.com/coding`` endpoints
|
||||
(legacy Moonshot keys do not serve K3) get the 1 Mi context window.
|
||||
"""
|
||||
normalized = _normalize_base_url(base_url)
|
||||
try:
|
||||
parsed = urlparse(normalized)
|
||||
@@ -559,7 +570,7 @@ def _endpoint_scoped_context_length(model: str, base_url: str) -> Optional[int]:
|
||||
and parsed.path.rstrip("/") in {"/coding", "/coding/v1"}
|
||||
and not parsed.query
|
||||
and not parsed.fragment
|
||||
and model.strip().lower() == "k3"
|
||||
and model.strip().lower() in {"k3", "kimi-k3", "kimi-k3-cot"}
|
||||
):
|
||||
return 1_048_576
|
||||
return None
|
||||
|
||||
@@ -15,6 +15,9 @@ and MoonshotAI/kimi-cli#1595:
|
||||
2. When ``anyOf`` is used, ``type`` must be on the ``anyOf`` children, not
|
||||
the parent. Presence of both causes "type should be defined in anyOf
|
||||
items instead of the parent schema".
|
||||
3. Every object schema must carry a ``required`` array, even an empty one.
|
||||
Standard JSON Schema allows omitting it; Moonshot 400s with
|
||||
"required must be an array".
|
||||
|
||||
The ``#/definitions/...`` → ``#/$defs/...`` rewrite for draft-07 refs is
|
||||
handled separately in ``tools/mcp_tool._normalize_mcp_input_schema`` so it
|
||||
@@ -130,9 +133,32 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
else:
|
||||
repaired.pop("enum")
|
||||
|
||||
# Rule 4: object schemas must carry a `required` array, even when empty.
|
||||
if repaired.get("type") == "object":
|
||||
repaired = _ensure_required_array(repaired)
|
||||
|
||||
return repaired
|
||||
|
||||
|
||||
def _ensure_required_array(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Guarantee an object schema carries a ``required`` array (Moonshot rule).
|
||||
|
||||
Standard JSON Schema lets you omit ``required`` when nothing is required;
|
||||
Moonshot 400s on that ("required must be an array"). Ensure the key is a
|
||||
list. When ``properties`` is known, prune ``required`` entries that don't
|
||||
name a real property — defensive against dangling names, which Moonshot
|
||||
also rejects. Mutates and returns ``node``.
|
||||
"""
|
||||
props = node.get("properties")
|
||||
req = node.get("required")
|
||||
if isinstance(req, list):
|
||||
if isinstance(props, dict):
|
||||
node["required"] = [r for r in req if r in props]
|
||||
else:
|
||||
node["required"] = []
|
||||
return node
|
||||
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
node_type = node.get("type")
|
||||
@@ -174,17 +200,18 @@ def sanitize_moonshot_tool_parameters(parameters: Any) -> Dict[str, Any]:
|
||||
applied. Input is not mutated.
|
||||
"""
|
||||
if not isinstance(parameters, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
repaired = _repair_schema(copy.deepcopy(parameters), is_schema=True)
|
||||
if not isinstance(repaired, dict):
|
||||
return {"type": "object", "properties": {}}
|
||||
return {"type": "object", "properties": {}, "required": []}
|
||||
|
||||
# Top-level must be an object schema
|
||||
if repaired.get("type") != "object":
|
||||
repaired["type"] = "object"
|
||||
if "properties" not in repaired:
|
||||
repaired["properties"] = {}
|
||||
_ensure_required_array(repaired)
|
||||
|
||||
return repaired
|
||||
|
||||
@@ -232,6 +259,10 @@ def is_moonshot_model(model: str | None) -> bool:
|
||||
tail = bare.rsplit("/", 1)[-1]
|
||||
if tail.startswith("kimi-") or tail == "kimi":
|
||||
return True
|
||||
# Kimi Coding Plan serves K3 under the bare slug ``k3`` (plus dated /
|
||||
# suffixed variants like ``k3.1`` or ``k3-turbo``).
|
||||
if tail == "k3" or tail.startswith(("k3.", "k3-")):
|
||||
return True
|
||||
# Vendor-prefixed forms commonly used on aggregators
|
||||
if "moonshot" in bare or "/kimi" in bare or bare.startswith("kimi"):
|
||||
return True
|
||||
|
||||
+13
-2
@@ -805,8 +805,19 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
"matrix": (
|
||||
"You are in a Matrix room communicating with your user. "
|
||||
"Matrix renders Markdown — bold, italic, code blocks, and links work; "
|
||||
"the adapter converts your Markdown to HTML for rich display. "
|
||||
"The adapter converts your Markdown to HTML for rich display — bold, "
|
||||
"italic, inline code, fenced code blocks, headings, bullet and "
|
||||
"numbered lists, blockquotes, and links all render.\n\n"
|
||||
"Do NOT use Markdown tables: many popular Matrix clients (Element X, "
|
||||
"Beeper, most mobile apps) do not render HTML tables, so the cells "
|
||||
"collapse into one continuous run of text. Present tabular data as "
|
||||
"labeled '**Label:** value' lines or bullet lists instead.\n\n"
|
||||
"Avoid ||spoiler|| tags, ~~strikethrough~~, and checkboxes "
|
||||
"(- [ ] / - [x]) — they are not converted and appear as literal "
|
||||
"characters.\n\n"
|
||||
"LINKS: prefer [descriptive link text](url) over bare URLs. When "
|
||||
"referencing something with an associated URL (events, sources, "
|
||||
"people), make the name a clickable link.\n\n"
|
||||
"You can send media files natively: include MEDIA:/absolute/path/to/file "
|
||||
"in your response. Images (.jpg, .png, .webp) are sent as inline photos, "
|
||||
"audio (.ogg, .mp3) as voice/audio messages, video (.mp4) inline, "
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
/**
|
||||
* E2E boot-failure tests — verify the app shows an error overlay when the
|
||||
* backend can't start.
|
||||
*
|
||||
* Injects a fake boot error (HERMES_DESKTOP_BOOT_FAKE_ERROR) so the backend
|
||||
* resolution fails with a controlled error message. The app should show the
|
||||
* BootFailureOverlay with retry/repair actions.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type DeadBackendFixture,
|
||||
setupDeadBackend,
|
||||
waitForBootFailure,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: DeadBackendFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('boot failure with dead backend', () => {
|
||||
test('app shows error state', async () => {
|
||||
// Inject a fake boot error so the backend resolution "fails" with a
|
||||
// controlled error message. This is the only reliable way to trigger
|
||||
// BootFailureOverlay in dev mode.
|
||||
fixture = await setupDeadBackend({ fakeError: true })
|
||||
|
||||
await waitForBootFailure(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('screenshot of error state', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'boot-failure-error-state', app: fixture.app })
|
||||
})
|
||||
})
|
||||
@@ -1,63 +0,0 @@
|
||||
/**
|
||||
* E2E smoke tests for the dev-mode desktop app.
|
||||
*
|
||||
* These tests launch the Electron app from the built dist/ (not the
|
||||
* packaged binary) with a real `hermes serve` backend pointed at a mock
|
||||
* inference server. The full chain is exercised:
|
||||
*
|
||||
* electron → hermes serve (python) → mock provider → renderer
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
* Run from the nix devshell:
|
||||
* npm exec playwright test e2e/boot.spec.ts --reporter=list
|
||||
*/
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: MockBackendFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
fixture = await setupMockBackend()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('dev-mode boot with mock backend', () => {
|
||||
test('window opens with Hermes title', async () => {
|
||||
const title = await fixture!.page.title()
|
||||
expect(title).toContain('Hermes')
|
||||
})
|
||||
|
||||
test('renderer mounts and shows DOM content', async () => {
|
||||
const page = fixture!.page
|
||||
// Wait for the React root to mount. The app renders into #root
|
||||
// (see src/main.tsx), but content may arrive through portals — so
|
||||
// check the body for any interactive content instead.
|
||||
await page.waitForSelector('body', { state: 'attached' })
|
||||
// Wait for the main app shell — the composer is always present.
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'attached',
|
||||
timeout: 30_000,
|
||||
})
|
||||
})
|
||||
|
||||
test('backend boots and app becomes ready', async () => {
|
||||
// This is the big one — wait for the full boot chain to complete:
|
||||
// electron starts → hermes serve is spawned → WS connects → config
|
||||
// loaded → sessions loaded → boot overlay dismissed → composer visible.
|
||||
await waitForAppReady(fixture!, 120_000)
|
||||
})
|
||||
|
||||
test('screenshot after boot', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'boot-ready', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
@@ -1,91 +0,0 @@
|
||||
/**
|
||||
* E2E chat tests — send a message and verify a response appears.
|
||||
*
|
||||
* Requires the full boot chain to complete (hermes serve + mock inference
|
||||
* provider). The mock server returns a canned reply, so we verify the
|
||||
* response text shows up in the chat transcript.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type MockBackendFixture,
|
||||
setupMockBackend,
|
||||
waitForAppReady,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
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.describe('chat interaction with mock backend', () => {
|
||||
test('send a message and receive a response', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// Find the composer — it's a contenteditable textbox.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.waitFor({ state: 'visible', timeout: 10_000 })
|
||||
|
||||
// Click to focus, then type the message character by character.
|
||||
// Using `type` instead of `fill` because the composer is a
|
||||
// contenteditable div with custom keydown handling that tracks
|
||||
// IME composition state — `fill` bypasses the event chain.
|
||||
await composer.click()
|
||||
await composer.type('Hello, can you hear me?', { delay: 20 })
|
||||
|
||||
// Submit with Enter — the composer's keydown handler intercepts
|
||||
// plain Enter (without Shift) and calls submitDraft().
|
||||
await page.keyboard.press('Enter')
|
||||
|
||||
// Wait for the user's message to appear in the transcript.
|
||||
// The message renders as an assistant-ui message in the chat view.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body
|
||||
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
|
||||
return (body.textContent ?? '').includes('Hello, can you hear me?')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 15_000 },
|
||||
)
|
||||
|
||||
// Wait for the mock response to appear. The canned reply is:
|
||||
// "Hello from the mock inference server! The full boot chain is working."
|
||||
// Give it a generous timeout — the inference request goes through the
|
||||
// gateway → hermes serve → mock server → streaming SSE back.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const body = document.body
|
||||
|
||||
if (!body) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = body.textContent ?? ''
|
||||
|
||||
return text.includes('mock inference server') || text.includes('boot chain is working')
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot of chat with messages', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
@@ -1,72 +0,0 @@
|
||||
/**
|
||||
* Monkey-patch: playwright's test runner never calls tracing.start() on
|
||||
* Electron's internal BrowserContext because:
|
||||
* 1. Playwright._allContexts() only returns [chromium, firefox, webkit]
|
||||
* contexts — Electron's context is excluded.
|
||||
* 2. ArtifactsRecorder.didCreateBrowserContext runs in willStartTest, before
|
||||
* beforeAll launches the electron app.
|
||||
* 3. The runAfterCreateBrowserContext hook doesn't exist on the Electron
|
||||
* class (only on BrowserType).
|
||||
*
|
||||
* As a result, trace screenshots (screencast) and DOM snapshots are never
|
||||
* captured for electron tests.
|
||||
*
|
||||
* This patch:
|
||||
* 1. Patches _allContexts() to include electron contexts, so the test
|
||||
* runner's didFinishTest() cleanup calls _stopTracing() → stopChunk()
|
||||
* on the electron context (saving the trace chunk + merging it into
|
||||
* the final trace.zip).
|
||||
* 2. Manually calls tracing.start() + startChunk() after launch.
|
||||
* 3. Wraps tracing.start to become startChunk after the first call,
|
||||
* so the test runner's willStartTest doesn't throw "already started".
|
||||
*
|
||||
* Imported from playwright.config.ts so it runs before any test.
|
||||
*
|
||||
* Pinned dependency: this file reaches into Playwright internals (_playwright,
|
||||
* _allContexts, _context) that have no public contract. @playwright/test is
|
||||
* pinned exact (=1.58.2 in package.json) so a bump can't silently break the
|
||||
* monkeypatch. When bumping, re-verify these private symbols still exist on
|
||||
* the Electron / PlaywrightInternal classes and that tracing still merges.
|
||||
*/
|
||||
|
||||
import { _electron as electron, type BrowserContext } from '@playwright/test'
|
||||
import * as crypto from 'node:crypto'
|
||||
|
||||
const electronContexts = new Set<BrowserContext>()
|
||||
const originalLaunch = electron.launch.bind(electron)
|
||||
|
||||
electron.launch = async (options: any) => {
|
||||
const app = await originalLaunch(options)
|
||||
const ctx = (app as any)._context as BrowserContext
|
||||
electronContexts.add(ctx)
|
||||
ctx.once('close', () => electronContexts.delete(ctx))
|
||||
|
||||
// Patch _allContexts so the test runner sees the electron context
|
||||
// (didFinishTest cleanup → _stopTracing → stopChunk → merge into trace.zip).
|
||||
const pw = (electron as any)._playwright as any
|
||||
if (pw && !pw.__electronTracingPatched) {
|
||||
pw.__electronTracingPatched = true
|
||||
const original = pw._allContexts.bind(pw)
|
||||
pw._allContexts = () => [...original(), ...electronContexts]
|
||||
}
|
||||
|
||||
// Start tracing — mirrors ArtifactsRecorder.didCreateBrowserContext.
|
||||
const traceName = crypto.randomUUID()
|
||||
await ctx.tracing.start({
|
||||
screenshots: true,
|
||||
snapshots: true,
|
||||
sources: true,
|
||||
}).catch(() => {})
|
||||
await ctx.tracing.startChunk({ title: 'electron', name: traceName }).catch(() => {})
|
||||
|
||||
// Wrap tracing.start to redirect to startChunk after the first call.
|
||||
// The test runner's willStartTest calls tracing.start() on all contexts
|
||||
// in _allContexts(). Since we already started, redirect to startChunk
|
||||
// to avoid "Tracing has been already started" errors.
|
||||
const tracing = ctx.tracing as any
|
||||
tracing.start = async (opts: any) => {
|
||||
return tracing.startChunk(opts)
|
||||
}
|
||||
|
||||
return app
|
||||
}
|
||||
@@ -1,674 +0,0 @@
|
||||
/**
|
||||
* Shared E2E fixtures for the Hermes desktop Playwright suite.
|
||||
*
|
||||
* Two fixture modes:
|
||||
*
|
||||
* 1. `mockBackend` — starts a mock inference server, writes a config.yaml
|
||||
* that points at it, and launches the desktop app so the full chain
|
||||
* (electron → hermes serve → provider → inference → renderer) is
|
||||
* exercised with a real backend but a fake LLM.
|
||||
*
|
||||
* 2. `noProvider` — launches the app with an empty config (no provider
|
||||
* configured). The onboarding overlay should appear. Used to test the
|
||||
* first-run flow without real credentials.
|
||||
*
|
||||
* Both modes launch the *dev* Electron app (`electron .` against the built
|
||||
* `dist/`), not the packaged binary. This avoids the multi-minute
|
||||
* `electron-builder --dir` step and matches `hermes desktop --source`. The
|
||||
* packaged-binary path is already covered by `launch.spec.ts`.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so that `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 { _electron, type ElectronApplication, type Page } from '@playwright/test'
|
||||
|
||||
import { startMockServer } from './mock-server'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
const RELEASE_ROOT = path.join(DESKTOP_ROOT, 'release')
|
||||
|
||||
// ─── Credential stripping (matches launch.spec.ts) ──────────────────────
|
||||
|
||||
const CREDENTIAL_SUFFIXES: string[] = [
|
||||
'_API_KEY',
|
||||
'_TOKEN',
|
||||
'_SECRET',
|
||||
'_PASSWORD',
|
||||
'_CREDENTIALS',
|
||||
'_ACCESS_KEY',
|
||||
'_PRIVATE_KEY',
|
||||
'_OAUTH_TOKEN',
|
||||
]
|
||||
|
||||
const CREDENTIAL_NAMES = new Set([
|
||||
'ANTHROPIC_BASE_URL',
|
||||
'ANTHROPIC_TOKEN',
|
||||
'AWS_ACCESS_KEY_ID',
|
||||
'AWS_SECRET_ACCESS_KEY',
|
||||
'AWS_SESSION_TOKEN',
|
||||
'CUSTOM_API_KEY',
|
||||
'GEMINI_BASE_URL',
|
||||
'OPENAI_BASE_URL',
|
||||
'OPENROUTER_BASE_URL',
|
||||
'OLLAMA_BASE_URL',
|
||||
'GROQ_BASE_URL',
|
||||
'XAI_BASE_URL',
|
||||
])
|
||||
|
||||
function isCredentialEnvVar(name: string): boolean {
|
||||
if (CREDENTIAL_NAMES.has(name)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return CREDENTIAL_SUFFIXES.some((suffix) => name.endsWith(suffix))
|
||||
}
|
||||
|
||||
function stripCredentials(env: Record<string, string | undefined>): Record<string, string> {
|
||||
const clean: Record<string, string> = {}
|
||||
|
||||
for (const [key, value] of Object.entries(env)) {
|
||||
if (!value) {
|
||||
continue
|
||||
}
|
||||
|
||||
if (isCredentialEnvVar(key)) {
|
||||
continue
|
||||
}
|
||||
|
||||
clean[key] = value
|
||||
}
|
||||
|
||||
return clean
|
||||
}
|
||||
|
||||
// ─── Sandbox creation ──────────────────────────────────────────────────
|
||||
|
||||
export interface Sandbox {
|
||||
root: string
|
||||
hermesHome: string
|
||||
userDataDir: string
|
||||
cleanup: () => void
|
||||
}
|
||||
|
||||
function createSandbox(prefix: string): Sandbox {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-e2e-${prefix}-${Math.random()}`))
|
||||
const hermesHome = path.join(root, 'hermes-home')
|
||||
const userDataDir = path.join(root, 'electron-user-data')
|
||||
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
|
||||
// Write a fixed window-state.json so the Electron window opens at a
|
||||
// consistent size — helps with visual regression screenshots. The
|
||||
// exact size is also enforced right before each screenshot (see
|
||||
// expectVisualSnapshot in visual-snapshot.ts) because window managers
|
||||
// may resize after launch.
|
||||
fs.writeFileSync(
|
||||
path.join(userDataDir, 'window-state.json'),
|
||||
JSON.stringify(
|
||||
{ x: 0, y: 0, width: 1220, height: 800, isMaximized: false },
|
||||
null,
|
||||
2,
|
||||
),
|
||||
'utf8',
|
||||
)
|
||||
|
||||
return {
|
||||
root,
|
||||
hermesHome,
|
||||
userDataDir,
|
||||
cleanup: () => {
|
||||
try {
|
||||
fs.rmSync(root, { recursive: true, force: true })
|
||||
} catch {
|
||||
// best-effort
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Config writing ─────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
function writeMockProviderConfig(hermesHome: string, mockUrl: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
|
||||
const config = `# Auto-generated by E2E test fixtures
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`
|
||||
|
||||
fs.writeFileSync(configPath, config, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write a minimal .env with the mock API key. The key_env in config.yaml
|
||||
* references MOCK_API_KEY, so the backend resolves credentials from here.
|
||||
*/
|
||||
function writeEnvFile(hermesHome: string, apiKey = 'e2e-mock-key'): void {
|
||||
const envPath = path.join(hermesHome, '.env')
|
||||
fs.writeFileSync(envPath, `MOCK_API_KEY=${apiKey}\n`, 'utf8')
|
||||
}
|
||||
|
||||
/**
|
||||
* Write an empty config (no providers). The desktop app should show the
|
||||
* onboarding overlay because no inference provider is configured.
|
||||
*/
|
||||
function writeEmptyConfig(hermesHome: string): void {
|
||||
const configPath = path.join(hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(configPath, '# Auto-generated by E2E test fixtures — no providers configured\n', 'utf8')
|
||||
}
|
||||
|
||||
// ─── Env building ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the environment for the Electron app process.
|
||||
*
|
||||
* Key env vars:
|
||||
* - HERMES_HOME → sandbox hermes-home (isolated config/sessions)
|
||||
* - HERMES_DESKTOP_USER_DATA_DIR → sandbox electron-user-data
|
||||
* - HERMES_DESKTOP_IGNORE_EXISTING=1 → don't pick up `hermes` from PATH
|
||||
* (we want the dev checkout at REPO_ROOT)
|
||||
* - HERMES_DESKTOP_HERMES_ROOT → REPO_ROOT (dev checkout resolution)
|
||||
* - HERMES_DESKTOP_APP_NAME → unique-ish per test (avoids single-instance lock)
|
||||
* - XDG_RUNTIME_DIR → ensure Electron has a writable runtime dir on Linux
|
||||
*/
|
||||
function buildAppEnv(sandbox: Sandbox, extra: Record<string, string> = {}): Record<string, string> {
|
||||
const clean = stripCredentials(process.env)
|
||||
|
||||
// XDG_RUNTIME_DIR is needed for Electron on Linux when running in a
|
||||
// headless/CI context — without it the zygote may fail to initialize.
|
||||
if (!clean.XDG_RUNTIME_DIR && process.env.XDG_RUNTIME_DIR) {
|
||||
clean.XDG_RUNTIME_DIR = process.env.XDG_RUNTIME_DIR
|
||||
}
|
||||
|
||||
// DISPLAY — needed for Electron to open a window.
|
||||
if (!clean.DISPLAY && process.env.DISPLAY) {
|
||||
clean.DISPLAY = process.env.DISPLAY
|
||||
}
|
||||
|
||||
return {
|
||||
...clean,
|
||||
HERMES_HOME: sandbox.hermesHome,
|
||||
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
||||
HERMES_DESKTOP_APP_NAME: `HermesE2E-${Date.now()}`,
|
||||
// Clear dev-server override — we want the built dist/, not a vite server.
|
||||
// The dev-server check in main.ts looks for this env var; if it's set,
|
||||
// it loads from the vite URL instead of the local file.
|
||||
...extra,
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Electron launch ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Verify that the desktop app has been built (dist/ exists). Playwright
|
||||
* tests can't run without it — the Electron main process loads
|
||||
* dist/electron-main.mjs and the renderer loads dist/index.html.
|
||||
*/
|
||||
function assertDistBuilt(): void {
|
||||
const distDir = path.join(DESKTOP_ROOT, 'dist')
|
||||
const electronMain = path.join(distDir, 'electron-main.mjs')
|
||||
const indexHtml = path.join(distDir, 'index.html')
|
||||
|
||||
if (!fs.existsSync(electronMain)) {
|
||||
throw new Error(
|
||||
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${electronMain}`,
|
||||
)
|
||||
}
|
||||
|
||||
if (!fs.existsSync(indexHtml)) {
|
||||
throw new Error(
|
||||
`Desktop dist/index.html not found. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${indexHtml}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the Electron binary. In the nix devshell, `electron` is on PATH.
|
||||
* As a fallback, use the node_modules/.bin/electron from the desktop package.
|
||||
*/
|
||||
function findElectron(): string {
|
||||
// In dev mode, we use the `electron` binary directly (not the packaged app).
|
||||
// The dev:electron script in package.json does exactly this: `electron .`
|
||||
// after building. We replicate that here.
|
||||
const localElectron = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
|
||||
|
||||
if (fs.existsSync(localElectron)) {
|
||||
return localElectron
|
||||
}
|
||||
|
||||
// Fall back to PATH
|
||||
const result = spawnSync('which', ['electron'], {
|
||||
encoding: 'utf8',
|
||||
})
|
||||
|
||||
if (result.status === 0 && result.stdout.trim()) {
|
||||
return result.stdout.trim()
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
'Electron binary not found. Run "npm install" from the repo root to install devDependencies.',
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the desktop app in dev mode.
|
||||
*
|
||||
* @param sandbox - isolated HERMES_HOME + userData
|
||||
* @param env - the process environment (already has HERMES_HOME etc.)
|
||||
* @returns the ElectronApplication + first Page
|
||||
*/
|
||||
async function launchDesktop(
|
||||
env: Record<string, string>,
|
||||
): Promise<{ app: ElectronApplication; page: Page }> {
|
||||
assertDistBuilt()
|
||||
|
||||
const electronBin = findElectron()
|
||||
|
||||
// `electron .` loads from the package.json `main` field
|
||||
// (dist/electron-main.mjs after build).
|
||||
const app = await _electron.launch({
|
||||
executablePath: electronBin,
|
||||
args: [
|
||||
DESKTOP_ROOT, // `electron .` — the `.` is the desktop package dir
|
||||
'--disable-gpu',
|
||||
'--no-sandbox',
|
||||
],
|
||||
env,
|
||||
cwd: DESKTOP_ROOT,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
|
||||
return { app, page }
|
||||
}
|
||||
|
||||
// ─── Public fixtures ────────────────────────────────────────────────────
|
||||
|
||||
export interface MockBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
mockUrl: string
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up a full mock-backend E2E environment:
|
||||
* 1. Start the mock inference server
|
||||
* 2. Create a sandbox with config.yaml pointing at it
|
||||
* 3. Launch the desktop app
|
||||
* 4. Return handles for test interaction
|
||||
*/
|
||||
export async function setupMockBackend(): Promise<MockBackendFixture> {
|
||||
// 1. Start mock server
|
||||
const mock = await startMockServer()
|
||||
|
||||
// 2. Create sandbox + write config
|
||||
const sandbox = createSandbox('mock')
|
||||
writeMockProviderConfig(sandbox.hermesHome, mock.url)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
// 3. Build env + launch
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
mockUrl: mock.url,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
await mock.close()
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface NoProviderFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with no provider configured. The onboarding overlay should
|
||||
* appear because there's no inference provider in config.yaml.
|
||||
*/
|
||||
export async function setupNoProvider(): Promise<NoProviderFixture> {
|
||||
const sandbox = createSandbox('noprovider')
|
||||
writeEmptyConfig(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox)
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export interface DeadBackendFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
export interface DeadBackendOptions {
|
||||
/**
|
||||
* When true, inject a fake boot error via HERMES_DESKTOP_BOOT_FAKE_ERROR
|
||||
* so the backend resolution itself "fails" with a controlled error message.
|
||||
* This is the only reliable way to trigger BootFailureOverlay in dev mode
|
||||
* (the real backend always resolves via SOURCE_REPO_ROOT).
|
||||
*/
|
||||
fakeError?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the app with a provider pointing at a dead endpoint (port 1, which
|
||||
* nothing listens on). By default the backend still boots (`hermes serve`
|
||||
* starts fine — the dead endpoint only matters at chat time). Pass
|
||||
* `{ fakeError: true }` to inject a fake boot failure, triggering the
|
||||
* BootFailureOverlay.
|
||||
*/
|
||||
export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise<DeadBackendFixture> {
|
||||
const sandbox = createSandbox('dead')
|
||||
const configPath = path.join(sandbox.hermesHome, 'config.yaml')
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
`# Auto-generated by E2E test fixtures — dead provider
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
mock:
|
||||
api: http://127.0.0.1:1/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`,
|
||||
'utf8',
|
||||
)
|
||||
writeEnvFile(sandbox.hermesHome)
|
||||
|
||||
const env = buildAppEnv(sandbox, options.fakeError ? { HERMES_DESKTOP_BOOT_FAKE_ERROR: 'Failed to connect to Hermes backend: connection refused' } : {})
|
||||
const { app, page } = await launchDesktop(env)
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Packaged-binary fixture ───────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Resolve the packaged Electron binary path, per-platform, matching
|
||||
* electron-builder's output layout under release/.
|
||||
*/
|
||||
function resolvePackagedBinaryPath(): string {
|
||||
if (process.platform === 'win32') {
|
||||
return path.join(RELEASE_ROOT, 'win-unpacked', 'Hermes.exe')
|
||||
}
|
||||
|
||||
if (process.platform === 'darwin') {
|
||||
const arch = process.arch === 'arm64' ? 'arm64' : 'x64'
|
||||
|
||||
return path.join(RELEASE_ROOT, `mac-${arch}`, 'Hermes.app', 'Contents', 'MacOS', 'Hermes')
|
||||
}
|
||||
|
||||
return path.join(RELEASE_ROOT, 'linux-unpacked', 'hermes')
|
||||
}
|
||||
|
||||
export const PACKAGED_BINARY_PATH = resolvePackagedBinaryPath()
|
||||
|
||||
export function packagedBinaryExists(): boolean {
|
||||
return fs.existsSync(PACKAGED_BINARY_PATH)
|
||||
}
|
||||
|
||||
export interface PackagedAppFixture {
|
||||
app: ElectronApplication
|
||||
page: Page
|
||||
sandbox: Sandbox
|
||||
cleanup: () => Promise<void>
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch the *packaged* Electron binary (from `npm run pack` →
|
||||
* `electron-builder --dir`) with `BOOT_FAKE=1` so it simulates boot
|
||||
* progress without spawning a real Hermes backend.
|
||||
*
|
||||
* Uses the same sandbox isolation (credential stripping, isolated
|
||||
* HERMES_HOME + userData, unique app name) as the dev-mode fixtures.
|
||||
*
|
||||
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
||||
*/
|
||||
export async function setupPackagedApp(): Promise<PackagedAppFixture> {
|
||||
if (!packagedBinaryExists()) {
|
||||
throw new Error(
|
||||
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
||||
)
|
||||
}
|
||||
|
||||
const sandbox = createSandbox('packaged')
|
||||
|
||||
// Build the sandbox env using the shared helpers, then add the
|
||||
// packaged-binary-specific overrides.
|
||||
const env = buildAppEnv(sandbox, {
|
||||
// Fake boot: simulates progress steps without spawning the real backend.
|
||||
HERMES_DESKTOP_BOOT_FAKE: '1',
|
||||
HERMES_DESKTOP_BOOT_FAKE_STEP_MS: '120',
|
||||
})
|
||||
|
||||
// Clear dev-server + hermes-root overrides — the packaged binary
|
||||
// should use its own bundled renderer, not the dev checkout.
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_DEV_SERVER
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES
|
||||
delete (env as Record<string, string | undefined>).HERMES_DESKTOP_HERMES_ROOT
|
||||
|
||||
const app = await _electron.launch({
|
||||
executablePath: PACKAGED_BINARY_PATH,
|
||||
args: ['--disable-gpu', '--no-sandbox'],
|
||||
env,
|
||||
})
|
||||
|
||||
const page = await app.firstWindow()
|
||||
|
||||
return {
|
||||
app,
|
||||
page,
|
||||
sandbox,
|
||||
cleanup: async () => {
|
||||
await app.close().catch(() => undefined)
|
||||
sandbox.cleanup()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Wait helpers ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Wait for the desktop app to finish booting and show the main chat UI.
|
||||
*
|
||||
* The boot overlay disappears when `completeDesktopBoot()` fires in the
|
||||
* renderer — at that point the gateway is open, config is loaded, and
|
||||
* sessions are loaded. We detect this by waiting for the boot/connecting
|
||||
* overlay to become invisible and the main app shell to be present.
|
||||
*
|
||||
* Two things must both be true before we return:
|
||||
* 1. The composer (chat input) is visible — it's disabled until the
|
||||
* gateway is open.
|
||||
* 2. No full-screen overlay (onboarding Preparing, connecting overlay,
|
||||
* boot-failure) covers the viewport center. The composer can be
|
||||
* "visible" in Playwright's eyes (non-zero bounding box, not
|
||||
* display:none) even when a z-1300+ overlay is painted on top of it,
|
||||
* so checking the composer alone catches the app mid-boot at ~92%
|
||||
* with the loading bar still showing.
|
||||
*/
|
||||
export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise<void> {
|
||||
const { page, app } = fixture
|
||||
|
||||
// Wait for the composer to exist in the DOM (not necessarily interactive yet).
|
||||
await page.waitForSelector('textarea, [contenteditable="true"]', {
|
||||
state: 'attached',
|
||||
timeout: timeoutMs,
|
||||
})
|
||||
|
||||
// Now poll until no full-screen overlay covers the viewport center.
|
||||
// elementFromPoint returns the topmost element at a point — if it's part
|
||||
// of a fixed inset-0 overlay (onboarding/connecting/boot-failure), the
|
||||
// app isn't ready yet.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const el = document.elementFromPoint(window.innerWidth / 2, window.innerHeight / 2)
|
||||
|
||||
if (!el) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Walk up to the nearest positioned ancestor — overlays are
|
||||
// `position: fixed; inset: 0`. If the hit element or an ancestor
|
||||
// is a full-viewport fixed overlay, we're still covered.
|
||||
let node: Element | null = el
|
||||
while (node) {
|
||||
const cs = window.getComputedStyle(node)
|
||||
|
||||
if (cs.position === 'fixed') {
|
||||
const rect = node.getBoundingClientRect()
|
||||
|
||||
if (rect.left <= 0 && rect.top <= 0 && rect.right >= window.innerWidth && rect.bottom >= window.innerHeight) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
node = node.parentElement
|
||||
}
|
||||
|
||||
return true
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
|
||||
// On Electron 40.x, ready-to-show may never fire (electron/electron#51972)
|
||||
// and the window stays hidden even though the DOM is rendered. The main
|
||||
// process has a TEST_WORKER_INDEX-gated fallback that force-shows the
|
||||
// window, but the DOM can be ready before that fires. Poll until the
|
||||
// window is actually visible so interactions (click, screenshot) don't
|
||||
// hit a hidden surface.
|
||||
if (app) {
|
||||
const deadline = Date.now() + timeoutMs
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const visible = await app.evaluate(({ BrowserWindow }) => {
|
||||
const w = BrowserWindow.getAllWindows()[0]
|
||||
|
||||
return w ? w.isVisible() : false
|
||||
}).catch(() => false)
|
||||
|
||||
if (visible) {break}
|
||||
await page.waitForTimeout(500)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the onboarding overlay to appear (no provider configured).
|
||||
*/
|
||||
export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
// The onboarding overlay contains a heading with "Choose your provider"
|
||||
// or similar text. We look for any text that indicates the picker.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = root.textContent ?? ''
|
||||
|
||||
return (
|
||||
text.includes('provider') ||
|
||||
text.includes('Provider') ||
|
||||
text.includes('Choose') ||
|
||||
text.includes('API key') ||
|
||||
text.includes('Sign in')
|
||||
)
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Wait for the boot failure overlay to appear.
|
||||
*/
|
||||
export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise<void> {
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
// Boot failure is terminal: the backend gave up. The renderer shows
|
||||
// either BootFailureOverlay (z-1400, with Retry/Repair buttons) or
|
||||
// falls back to the onboarding picker (z-1300) as a recovery path.
|
||||
// We wait for the failure dialog itself — the Preparing component may
|
||||
// still paint its progress bar (recolored red) underneath the overlay,
|
||||
// which is harmless.
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
// BootFailureOverlay buttons.
|
||||
const hasFailureUI =
|
||||
text.includes('Retry') ||
|
||||
text.includes('Repair') ||
|
||||
text.includes('Use local gateway') ||
|
||||
text.includes('Connection settings')
|
||||
|
||||
// The error toast / notification that fires on failDesktopBoot().
|
||||
const hasErrorToast = text.includes('Desktop boot failed')
|
||||
|
||||
return hasFailureUI || hasErrorToast
|
||||
},
|
||||
undefined,
|
||||
{ timeout: timeoutMs },
|
||||
)
|
||||
}
|
||||
@@ -1,88 +0,0 @@
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
PACKAGED_BINARY_PATH,
|
||||
type PackagedAppFixture,
|
||||
packagedBinaryExists,
|
||||
setupPackagedApp,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
/**
|
||||
* E2E smoke tests for the packaged Hermes desktop app.
|
||||
*
|
||||
* Launches the real packaged Electron binary (produced by `npm run pack` →
|
||||
* `electron-builder --dir`) with BOOT_FAKE=1 and full sandbox isolation
|
||||
* (credential stripping, isolated HERMES_HOME + userData, unique app name).
|
||||
*
|
||||
* Skips if the packaged binary doesn't exist — run `npm run pack` first.
|
||||
*/
|
||||
|
||||
let fixture: PackagedAppFixture | null = null
|
||||
|
||||
test.beforeAll(async () => {
|
||||
test.skip(
|
||||
!packagedBinaryExists(),
|
||||
`Built app binary not found: ${PACKAGED_BINARY_PATH}. Run 'npm run pack' first.`,
|
||||
)
|
||||
|
||||
fixture = await setupPackagedApp()
|
||||
})
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test('window opens with the Hermes title', async () => {
|
||||
const title = await fixture!.page.title()
|
||||
expect(title).toContain('Hermes')
|
||||
})
|
||||
|
||||
test('renderer loads and shows DOM content', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForSelector('#root', { state: 'attached', timeout: 30_000 })
|
||||
const childCount = await page.locator('#root > *').count()
|
||||
expect(childCount).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
test('boot progress overlay fades out or shows error state', async () => {
|
||||
const page = fixture!.page
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
if (!root) {
|
||||
return false
|
||||
}
|
||||
|
||||
const text = root.textContent ?? ''
|
||||
|
||||
// Error path: boot failure overlay renders an error message.
|
||||
if (text.includes('error') || text.includes('Error') || text.includes('failed')) {
|
||||
return true
|
||||
}
|
||||
|
||||
// Success path: overlay disappears and the app renders. If there's
|
||||
// no "boot" / "starting" / "installing" text visible, boot has
|
||||
// completed (either to the main UI or to onboarding).
|
||||
const bootIndicators = ['starting', 'resolving', 'spawning', 'waiting', 'installing']
|
||||
const lower = text.toLowerCase()
|
||||
|
||||
return !bootIndicators.some((word) => lower.includes(word))
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 60_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('can capture a screenshot for the CI artifact', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Visual snapshot — won't fail on diff, just logs + generates diff image
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'packaged-app-booted', timeout: 10_000, app: fixture!.app })
|
||||
})
|
||||
@@ -1,87 +0,0 @@
|
||||
/**
|
||||
* E2E tests asserting the mock backend gets the app past the setup/onboarding
|
||||
* screen.
|
||||
*
|
||||
* The mock backend fixture writes a config.yaml with a pre-configured mock
|
||||
* provider pointing at a mock inference server. When the app boots, the
|
||||
* runtime readiness check should detect the working provider and dismiss the
|
||||
* onboarding overlay — landing straight on the chat UI without ever showing
|
||||
* the "Let's get you setup with Hermes Agent" screen.
|
||||
*
|
||||
* If these tests fail, the mock backend config isn't getting the app past
|
||||
* onboarding — the chat interaction tests (chat.spec.ts) will also fail
|
||||
* because the composer is blocked by the setup overlay.
|
||||
*
|
||||
* 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 { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
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.describe('mock backend gets past setup screen', () => {
|
||||
test('onboarding overlay is not shown', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// The onboarding overlay renders "Let's get you setup with Hermes Agent"
|
||||
// when the runtime check fails to find a working provider. With the mock
|
||||
// backend configured, the runtime check should pass and the overlay
|
||||
// returns null — this text should NOT be present in the DOM.
|
||||
await page.waitForFunction(
|
||||
() => {
|
||||
const text = document.body.textContent ?? ''
|
||||
|
||||
return !text.includes("Let's get you setup")
|
||||
},
|
||||
undefined,
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('chat composer is visible', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// The composer (contenteditable div) should be visible and not blocked
|
||||
// by the onboarding overlay. If the first test passed, the overlay is
|
||||
// gone and the composer is the primary interactive surface.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await expect(composer).toBeVisible()
|
||||
})
|
||||
|
||||
test('can type into the composer', async () => {
|
||||
const page = fixture!.page
|
||||
|
||||
// If the setup overlay is truly gone, the composer accepts input.
|
||||
const composer = page.locator('[contenteditable="true"]').first()
|
||||
await composer.click()
|
||||
await composer.type('hello mock backend', { delay: 20 })
|
||||
|
||||
// Verify the typed text appears in the DOM.
|
||||
await page.waitForFunction(
|
||||
() => (document.body.textContent ?? '').includes('hello mock backend'),
|
||||
undefined,
|
||||
{ timeout: 10_000 },
|
||||
)
|
||||
})
|
||||
|
||||
test('screenshot shows chat UI without setup screen', async () => {
|
||||
await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app })
|
||||
})
|
||||
})
|
||||
@@ -1,203 +0,0 @@
|
||||
/**
|
||||
* Minimal OpenAI-compatible mock inference server for E2E tests.
|
||||
*
|
||||
* Implements just enough of the /v1/* surface for `hermes serve` to resolve a
|
||||
* provider, list models, and stream a canned chat completion back to the
|
||||
* desktop app — without any real LLM.
|
||||
*
|
||||
* Endpoints:
|
||||
* GET /v1/models → { data: [{ id, ... }] }
|
||||
* POST /v1/chat/completions → streaming (SSE) or non-streaming response
|
||||
*
|
||||
* The canned response is a short, deterministic assistant message. Tool-call
|
||||
* requests are not simulated — the E2E tests only need the chat surface to
|
||||
* prove the full boot → gateway → inference → renderer chain works.
|
||||
*/
|
||||
|
||||
import http 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.'
|
||||
|
||||
/**
|
||||
* Start the mock server on an ephemeral port.
|
||||
*
|
||||
* @returns a handle with `port`, `url`, and `close()`.
|
||||
*/
|
||||
export function startMockServer(): Promise<{ port: number; url: string; close: () => Promise<void> }> {
|
||||
return new Promise((resolve, reject) => {
|
||||
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.
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
// GET /v1/models — return a single fake model.
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
object: 'list',
|
||||
data: [
|
||||
{
|
||||
id: 'mock-model',
|
||||
object: 'model',
|
||||
created: 0,
|
||||
owned_by: 'mock',
|
||||
},
|
||||
],
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
// POST /v1/chat/completions — return a canned response.
|
||||
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
|
||||
let body = ''
|
||||
|
||||
req.on('data', (chunk: Buffer) => {
|
||||
body += chunk.toString()
|
||||
})
|
||||
|
||||
req.on('end', () => {
|
||||
let parsed: any = {}
|
||||
|
||||
try {
|
||||
parsed = JSON.parse(body)
|
||||
} catch {
|
||||
// malformed JSON — treat as non-streaming with defaults
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
|
||||
// Send the content in a few chunks to simulate streaming.
|
||||
const words = CANNED_REPLY.split(' ')
|
||||
let i = 0
|
||||
|
||||
const sendChunk = () => {
|
||||
if (i >= words.length) {
|
||||
// Final chunk with finish_reason
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: {},
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
)
|
||||
res.write('data: [DONE]\n\n')
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
const word = i === 0 ? words[i] : ' ' + words[i]
|
||||
res.write(
|
||||
`data: ${JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion.chunk',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
delta: { content: word },
|
||||
finish_reason: null,
|
||||
},
|
||||
],
|
||||
})}\n\n`,
|
||||
)
|
||||
i++
|
||||
// Small delay between chunks to simulate real streaming.
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
|
||||
sendChunk()
|
||||
} else {
|
||||
// Non-streaming response
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
id: 'mock-completion',
|
||||
object: 'chat.completion',
|
||||
created: 0,
|
||||
model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: { role: 'assistant', content: CANNED_REPLY },
|
||||
finish_reason: 'stop',
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: 10,
|
||||
completion_tokens: 20,
|
||||
total_tokens: 30,
|
||||
},
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
|
||||
req.on('error', () => {
|
||||
res.writeHead(400)
|
||||
res.end('Bad request')
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback — 404 for anything else
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
})
|
||||
|
||||
server.on('error', reject)
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
reject(new Error('Failed to get server address'))
|
||||
return
|
||||
}
|
||||
|
||||
const port = addr.port
|
||||
const url = `http://127.0.0.1:${port}`
|
||||
|
||||
resolve({
|
||||
port,
|
||||
url,
|
||||
close: () =>
|
||||
new Promise((resolveClose, rejectClose) => {
|
||||
server.close((err) => {
|
||||
if (err) {
|
||||
rejectClose(err)
|
||||
} else {
|
||||
resolveClose()
|
||||
}
|
||||
})
|
||||
}),
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
/**
|
||||
* E2E onboarding tests — verify the provider picker appears when no
|
||||
* inference provider is configured.
|
||||
*
|
||||
* Launches the app with an empty config.yaml (no providers). The renderer
|
||||
* should detect the unconfigured state and show the DesktopOnboardingOverlay
|
||||
* with provider options / API key form.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*/
|
||||
|
||||
import { expect, test } from '@playwright/test'
|
||||
|
||||
import {
|
||||
type NoProviderFixture,
|
||||
setupNoProvider,
|
||||
waitForOnboarding,
|
||||
} from './fixtures'
|
||||
import { expectVisualSnapshot } from './visual-snapshot'
|
||||
|
||||
let fixture: NoProviderFixture | null = null
|
||||
|
||||
test.afterAll(async () => {
|
||||
await fixture?.cleanup()
|
||||
fixture = null
|
||||
})
|
||||
|
||||
test.describe('onboarding with no provider configured', () => {
|
||||
test('onboarding overlay appears on first boot', async () => {
|
||||
fixture = await setupNoProvider()
|
||||
|
||||
// The app should boot (hermes serve starts fine even without a provider),
|
||||
// but the renderer should show the onboarding overlay because no
|
||||
// provider is configured.
|
||||
await waitForOnboarding(fixture.page, 90_000)
|
||||
})
|
||||
|
||||
test('onboarding shows provider options or API key form', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
const page = fixture.page
|
||||
|
||||
// The onboarding overlay should contain provider-related text.
|
||||
// It might show OAuth providers, an API key form, or a "choose later"
|
||||
// link. Verify at least one of these is visible.
|
||||
const rootText = await page.evaluate(() => {
|
||||
const root = document.getElementById('root')
|
||||
|
||||
return root?.textContent ?? ''
|
||||
})
|
||||
|
||||
const hasProviderText =
|
||||
rootText.includes('provider') ||
|
||||
rootText.includes('Provider') ||
|
||||
rootText.includes('API key') ||
|
||||
rootText.includes('Sign in') ||
|
||||
rootText.includes('OpenRouter') ||
|
||||
rootText.includes('OpenAI')
|
||||
|
||||
expect(hasProviderText).toBe(true)
|
||||
})
|
||||
|
||||
test('screenshot of onboarding overlay', async () => {
|
||||
if (!fixture) {
|
||||
test.skip(true, 'Previous test failed — no app running')
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
await expectVisualSnapshot(fixture.page, { name: 'onboarding-overlay', app: fixture.app })
|
||||
})
|
||||
})
|
||||
@@ -1,150 +0,0 @@
|
||||
/**
|
||||
* Visual snapshot helper — wraps `toHaveScreenshot` so visual diffs are
|
||||
* reported without failing the test suite.
|
||||
*
|
||||
* On CI, the JSON reporter + post-test script parse the results and post a
|
||||
* summary to the GitHub Actions step output, and diff images are uploaded
|
||||
* as artifacts. This keeps visual regressions visible without gating PRs
|
||||
* on pixel-perfect matches.
|
||||
*
|
||||
* The actual screenshot is always written to the test output dir so CI
|
||||
* artifacts include every screenshot — not just the ones that diffed.
|
||||
* When it differs, this helper also writes expected and diff images:
|
||||
* <name>-actual.png, <name>-expected.png, <name>-diff.png
|
||||
*/
|
||||
import fs from 'node:fs'
|
||||
import path from 'node:path'
|
||||
|
||||
import { type ElectronApplication, type Page, test } from '@playwright/test'
|
||||
|
||||
/** Fixed window dimensions for visual regression screenshots. */
|
||||
export const VISUAL_WINDOW_WIDTH = 1220
|
||||
export const VISUAL_WINDOW_HEIGHT = 800
|
||||
|
||||
export interface VisualSnapshotOptions {
|
||||
/** Snapshot name — defaults to the test title. */
|
||||
name?: string
|
||||
/** Full page screenshot vs. viewport-only (default). */
|
||||
fullPage?: boolean
|
||||
/** Timeout in ms. */
|
||||
timeout?: number
|
||||
/** The Electron app handle — used to size and decode screenshots. */
|
||||
app: ElectronApplication
|
||||
}
|
||||
|
||||
/**
|
||||
* Force the Electron window to a fixed size so screenshots are comparable
|
||||
* across runs and CI environments. Window managers (Hyprland, etc.) may
|
||||
* auto-tile or resize windows after launch; calling this right before the
|
||||
* screenshot ensures the viewport is always the expected size.
|
||||
*/
|
||||
async function forceFixedSize(app: ElectronApplication): Promise<void> {
|
||||
await app.evaluate(({ BrowserWindow }, { width, height }) => {
|
||||
const win = BrowserWindow.getAllWindows()[0]
|
||||
|
||||
if (win) {
|
||||
win.unmaximize()
|
||||
// setMinimumSize must be ≤ the target, otherwise setSize is clamped.
|
||||
win.setMinimumSize(width, height)
|
||||
win.setSize(width, height, false)
|
||||
win.setBounds({ x: 0, y: 0, width, height })
|
||||
}
|
||||
}, { width: VISUAL_WINDOW_WIDTH, height: VISUAL_WINDOW_HEIGHT })
|
||||
}
|
||||
|
||||
/**
|
||||
* Take a screenshot and compare it against the baseline.
|
||||
*
|
||||
* If the baseline doesn't exist yet (first run), Playwright creates it.
|
||||
* If it differs, the test logs a soft warning but does NOT fail — the diff
|
||||
* images are still generated for CI to surface.
|
||||
*/
|
||||
export async function expectVisualSnapshot(
|
||||
page: Page,
|
||||
options: VisualSnapshotOptions,
|
||||
): Promise<void> {
|
||||
const { name, fullPage = false, timeout = 30_000, app } = options
|
||||
|
||||
// Force the window to a fixed size right before the screenshot so it's
|
||||
// always comparable, regardless of WM resizing during the test.
|
||||
await forceFixedSize(app)
|
||||
// Give the renderer a moment to relayout after the resize.
|
||||
await page.waitForTimeout(500)
|
||||
|
||||
// Playwright appends a platform suffix (e.g. "-linux") and requires
|
||||
// a .png extension on the name argument. Auto-append it if missing.
|
||||
const snapshotName = name ? (name.endsWith('.png') ? name : `${name}.png`) : undefined
|
||||
|
||||
const info = test.info()
|
||||
const actual = await page.screenshot({ animations: 'disabled', caret: 'hide', fullPage, timeout })
|
||||
const baselinePath = info.snapshotPath(snapshotName ?? `${info.title}.png`)
|
||||
const outputName = (snapshotName ?? 'snapshot.png').replace(/\.png$/, '')
|
||||
|
||||
if (info.config.updateSnapshots === 'all' || info.config.updateSnapshots === 'changed') {
|
||||
fs.mkdirSync(path.dirname(baselinePath), { recursive: true })
|
||||
fs.writeFileSync(baselinePath, actual)
|
||||
// Also write to the output dir so CI artifacts include the screenshot.
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
console.log(`[visual-baseline] updated ${baselinePath}`)
|
||||
return
|
||||
}
|
||||
|
||||
if (!fs.existsSync(baselinePath)) {
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
console.log(`[visual-diff] ${name ?? '(unnamed)'} — no baseline available`)
|
||||
return
|
||||
}
|
||||
|
||||
const expected = fs.readFileSync(baselinePath)
|
||||
const comparison = await app.evaluate(
|
||||
({ nativeImage }, images) => {
|
||||
const actualImage = nativeImage.createFromBuffer(Buffer.from(images.actual, 'base64'))
|
||||
const expectedImage = nativeImage.createFromBuffer(Buffer.from(images.expected, 'base64'))
|
||||
const actualSize = actualImage.getSize()
|
||||
const expectedSize = expectedImage.getSize()
|
||||
|
||||
if (actualSize.width !== expectedSize.width || actualSize.height !== expectedSize.height) {
|
||||
return { mismatchRatio: 1, diff: images.actual }
|
||||
}
|
||||
|
||||
const actualPixels = actualImage.toBitmap()
|
||||
const expectedPixels = expectedImage.toBitmap()
|
||||
const diffPixels = Buffer.alloc(actualPixels.length)
|
||||
let mismatched = 0
|
||||
|
||||
for (let i = 0; i < actualPixels.length; i += 4) {
|
||||
const different =
|
||||
Math.abs(actualPixels[i] - expectedPixels[i]) > 51 ||
|
||||
Math.abs(actualPixels[i + 1] - expectedPixels[i + 1]) > 51 ||
|
||||
Math.abs(actualPixels[i + 2] - expectedPixels[i + 2]) > 51 ||
|
||||
Math.abs(actualPixels[i + 3] - expectedPixels[i + 3]) > 51
|
||||
|
||||
if (different) {
|
||||
mismatched++
|
||||
diffPixels[i + 2] = 255
|
||||
}
|
||||
diffPixels[i + 3] = 255
|
||||
}
|
||||
|
||||
return {
|
||||
mismatchRatio: mismatched / (actualPixels.length / 4),
|
||||
diff: nativeImage.createFromBitmap(diffPixels, actualSize).toPNG().toString('base64'),
|
||||
}
|
||||
},
|
||||
{ actual: actual.toString('base64'), expected: expected.toString('base64') },
|
||||
)
|
||||
|
||||
// Always write the actual screenshot to the output dir so CI artifacts
|
||||
// include every screenshot — not just the ones that diffed.
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual)
|
||||
|
||||
if (comparison.mismatchRatio <= 0.01) {
|
||||
return
|
||||
}
|
||||
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected)
|
||||
fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64'))
|
||||
console.log(
|
||||
`[visual-diff] ${name ?? '(unnamed)'} — ${(comparison.mismatchRatio * 100).toFixed(2)}% of pixels differ`,
|
||||
)
|
||||
}
|
||||
@@ -542,7 +542,6 @@ const DESKTOP_LOG_BACKUP_COUNT = 3
|
||||
const DESKTOP_LOG_DISCARD_BYTES = DESKTOP_LOG_MAX_BYTES * 4
|
||||
const desktopLogBackupPath = n => `${DESKTOP_LOG_PATH}.${n}`
|
||||
const BOOT_FAKE_MODE = process.env.HERMES_DESKTOP_BOOT_FAKE === '1'
|
||||
const BOOT_FAKE_ERROR = process.env.HERMES_DESKTOP_BOOT_FAKE_ERROR || ''
|
||||
|
||||
const BOOT_FAKE_STEP_MS = (() => {
|
||||
const raw = Number.parseInt(String(process.env.HERMES_DESKTOP_BOOT_FAKE_STEP_MS || ''), 10)
|
||||
@@ -7000,16 +6999,6 @@ async function startHermes() {
|
||||
throw backendStartFailure
|
||||
}
|
||||
|
||||
// E2E: simulate a boot failure without breaking the real backend. The boot
|
||||
// progresses a few steps, then fails with the given error message.
|
||||
if (BOOT_FAKE_ERROR) {
|
||||
await advanceBootProgress('backend.resolve', 'Resolving Hermes backend', 8)
|
||||
const error = new Error(BOOT_FAKE_ERROR) as any
|
||||
error.isBootstrapFailure = true
|
||||
bootstrapFailure = error
|
||||
throw error
|
||||
}
|
||||
|
||||
const existingConnectionPromise = backendConnectionState.getPromise()
|
||||
|
||||
if (existingConnectionPromise) {
|
||||
@@ -7619,14 +7608,6 @@ function createWindow() {
|
||||
}
|
||||
})
|
||||
|
||||
// Under Playright testing, instantly show the window.
|
||||
// `ready-to-show` doesn't fire in some testing envs.
|
||||
if (process.env.TEST_WORKER_INDEX !== undefined) {
|
||||
if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) {
|
||||
mainWindow.show()
|
||||
}
|
||||
}
|
||||
|
||||
mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('enter-full-screen', () => sendWindowStateChanged(true))
|
||||
mainWindow.on('will-leave-full-screen', () => sendWindowStateChanged(false))
|
||||
|
||||
@@ -13,7 +13,6 @@
|
||||
"scripts": {
|
||||
"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 .",
|
||||
@@ -41,7 +40,7 @@
|
||||
"test:desktop:nsis": "node scripts/test-desktop.mjs nsis",
|
||||
"test:desktop:existing": "node scripts/test-desktop.mjs existing",
|
||||
"test:desktop:fresh": "node scripts/test-desktop.mjs fresh",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit",
|
||||
"typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
"fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'",
|
||||
@@ -50,10 +49,7 @@
|
||||
"test:desktop:platforms": "vitest run --project electron",
|
||||
"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"
|
||||
"check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build"
|
||||
},
|
||||
"dependencies": {
|
||||
"@assistant-ui/react": "^0.14.23",
|
||||
@@ -128,7 +124,6 @@
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "=1.58.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
import './e2e/fix-electron-tracing'
|
||||
|
||||
import { defineConfig, type ReporterDescription } from '@playwright/test'
|
||||
|
||||
/**
|
||||
* Visual regression testing config.
|
||||
*
|
||||
* Screenshots are compared against baselines. On `main`, baselines are
|
||||
* generated with `--update-snapshots` and cached. On PRs, the cached
|
||||
* baselines are restored and screenshots are compared — but tests DON'T
|
||||
* fail on visual diffs (see `expectVisualSnapshot` in visual-snapshot.ts).
|
||||
* Instead, diffs are surfaced in the CI step summary and uploaded as
|
||||
* artifacts for human review.
|
||||
*
|
||||
* To update baselines after an intentional UI change:
|
||||
* npx playwright test --update-snapshots
|
||||
*/
|
||||
const reporters: ReporterDescription[] = [
|
||||
['list'],
|
||||
['html', { open: 'never', outputFolder: 'playwright-report' }],
|
||||
]
|
||||
|
||||
if (process.env.CI) {
|
||||
reporters.push(['json', { outputFile: 'playwright-report/results.json' }])
|
||||
}
|
||||
|
||||
export default defineConfig({
|
||||
/* Test files live under e2e/ so they never collide with the vitest suite
|
||||
* under src/ or the node:test files under electron/. */
|
||||
testDir: './e2e',
|
||||
/* The desktop app can take a while to bootstrap on cold CI runners — 90 s
|
||||
* per test gives us headroom without masking real hangs. */
|
||||
timeout: 90_000,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
/* Each test gets its own worker so the Electron process is fully isolated. */
|
||||
fullyParallel: false,
|
||||
reporter: reporters,
|
||||
use: {
|
||||
screenshot: 'on',
|
||||
trace: { mode: 'on', screenshots: true, snapshots: true, sources: true },
|
||||
// Emulate prefers-reduced-motion: reduce so all CSS transitions and
|
||||
// animations resolve instantly. This prevents boot/connecting overlays
|
||||
// from being mid-fade when a screenshot fires, and skips JS-driven exit
|
||||
// choreography in components that check matchMedia (onboarding, connecting
|
||||
// overlay, DecodeText). Without this, screenshots capture the loading bar
|
||||
// or overlay at a transient opacity because the text-content check fires
|
||||
// before the visual transition finishes.
|
||||
contextOptions: {
|
||||
reducedMotion: 'reduce',
|
||||
},
|
||||
},
|
||||
expect: {
|
||||
toHaveScreenshot: {
|
||||
// 1% of pixels may differ — absorbs sub-pixel font rendering variance
|
||||
// between local and CI environments.
|
||||
maxDiffPixelRatio: 0.01,
|
||||
animations: 'disabled',
|
||||
caret: 'hide',
|
||||
// Per-channel threshold for "close enough" — anti-aliasing differences.
|
||||
threshold: 0.2,
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -1,237 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Launch the desktop app with a mock inference provider — no real API
|
||||
* keys needed. Starts a local OpenAI-compatible server that returns a
|
||||
* canned reply, writes an isolated config.yaml + .env, and launches the
|
||||
* built Electron app against them.
|
||||
*
|
||||
* This reuses the same mock-server and config format as the E2E fixtures
|
||||
* (apps/desktop/e2e/mock-server.ts + fixtures.ts), so local dev and CI
|
||||
* test the same chain.
|
||||
*
|
||||
* Prerequisite: `npm run build` must have been run so dist/ exists.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/dev-mock.mjs
|
||||
* npm run dev:mock
|
||||
*
|
||||
* The mock server listens on an ephemeral port and replies to every
|
||||
* chat completion with:
|
||||
* "Hello from the mock inference server! The full boot chain is working."
|
||||
*/
|
||||
|
||||
import http from 'node:http'
|
||||
import fs from 'node:fs'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { spawn, spawnSync } from 'node:child_process'
|
||||
|
||||
const DESKTOP_ROOT = path.resolve(import.meta.dirname, '..')
|
||||
const REPO_ROOT = path.resolve(DESKTOP_ROOT, '..', '..')
|
||||
|
||||
// ── Canned reply ───────────────────────────────────────────────────────
|
||||
|
||||
const CANNED_REPLY =
|
||||
'Hello from the mock inference server! The full boot chain is working.'
|
||||
|
||||
// ── Mock server (mirrors e2e/mock-server.ts) ───────────────────────────
|
||||
|
||||
function startMockServer() {
|
||||
return new Promise((resolve, reject) => {
|
||||
const server = http.createServer((req, res) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*')
|
||||
res.setHeader('Access-Control-Allow-Headers', '*')
|
||||
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS')
|
||||
|
||||
if (req.method === 'OPTIONS') {
|
||||
res.writeHead(204)
|
||||
res.end()
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'GET' && req.url === '/v1/models') {
|
||||
res.writeHead(200, { 'Content-Type': 'application/json' })
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
object: 'list',
|
||||
data: [{ id: 'mock-model', object: 'model', created: 0, owned_by: 'mock' }],
|
||||
}),
|
||||
)
|
||||
return
|
||||
}
|
||||
|
||||
if (req.method === 'POST' && req.url?.startsWith('/v1/chat/completions')) {
|
||||
let body = ''
|
||||
req.on('data', (chunk) => { body += chunk.toString() })
|
||||
req.on('end', () => {
|
||||
let parsed = {}
|
||||
try { parsed = JSON.parse(body) } catch { /* non-streaming */ }
|
||||
|
||||
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',
|
||||
})
|
||||
const words = CANNED_REPLY.split(' ')
|
||||
let i = 0
|
||||
const sendChunk = () => {
|
||||
if (i >= words.length) {
|
||||
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++
|
||||
setTimeout(sendChunk, 20)
|
||||
}
|
||||
sendChunk()
|
||||
} else {
|
||||
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 },
|
||||
}),
|
||||
)
|
||||
}
|
||||
})
|
||||
req.on('error', () => { res.writeHead(400); res.end('Bad request') })
|
||||
return
|
||||
}
|
||||
|
||||
res.writeHead(404, { 'Content-Type': 'application/json' })
|
||||
res.end(JSON.stringify({ error: 'Not found' }))
|
||||
})
|
||||
|
||||
server.on('error', reject)
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
const addr = server.address()
|
||||
if (addr === null || typeof addr === 'string') {
|
||||
reject(new Error('Failed to get server address'))
|
||||
return
|
||||
}
|
||||
resolve({ port: addr.port, url: `http://127.0.0.1:${addr.port}`, close: () => server.close() })
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ── Config + env writing (mirrors e2e/fixtures.ts) ─────────────────────
|
||||
|
||||
function createSandbox() {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), `hermes-dev-mock-${Date.now()}`))
|
||||
const hermesHome = path.join(root, 'hermes-home')
|
||||
const userDataDir = path.join(root, 'electron-user-data')
|
||||
fs.mkdirSync(hermesHome, { recursive: true })
|
||||
fs.mkdirSync(userDataDir, { recursive: true })
|
||||
return { root, hermesHome, userDataDir, cleanup: () => fs.rmSync(root, { recursive: true, force: true }) }
|
||||
}
|
||||
|
||||
function writeMockConfig(hermesHome, mockUrl) {
|
||||
fs.writeFileSync(
|
||||
path.join(hermesHome, 'config.yaml'),
|
||||
`# Auto-generated by dev-mock.mjs
|
||||
model:
|
||||
default: mock-model
|
||||
provider: mock
|
||||
providers:
|
||||
mock:
|
||||
api: ${mockUrl}/v1
|
||||
name: Mock
|
||||
api_mode: chat_completions
|
||||
key_env: MOCK_API_KEY
|
||||
models:
|
||||
mock-model: {}
|
||||
context_length: 4096
|
||||
`,
|
||||
'utf8',
|
||||
)
|
||||
fs.writeFileSync(path.join(hermesHome, '.env'), 'MOCK_API_KEY=e2e-mock-key\n', 'utf8')
|
||||
}
|
||||
|
||||
// ── Electron launch ────────────────────────────────────────────────────
|
||||
|
||||
function findElectron() {
|
||||
const local = path.join(REPO_ROOT, 'node_modules', 'electron', 'dist', 'electron')
|
||||
if (fs.existsSync(local)) return local
|
||||
const r = spawnSync('which', ['electron'], { encoding: 'utf8' })
|
||||
if (r.status === 0 && r.stdout.trim()) return r.stdout.trim()
|
||||
throw new Error('Electron binary not found. Run "npm install" from the repo root.')
|
||||
}
|
||||
|
||||
function assertDistBuilt() {
|
||||
const electronMain = path.join(DESKTOP_ROOT, 'dist', 'electron-main.mjs')
|
||||
const indexHtml = path.join(DESKTOP_ROOT, 'dist', 'index.html')
|
||||
if (!fs.existsSync(electronMain) || !fs.existsSync(indexHtml)) {
|
||||
throw new Error(
|
||||
`Desktop dist not built. Run 'cd apps/desktop && npm run build' first.\n` +
|
||||
`Missing: ${electronMain}`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// ── Main ───────────────────────────────────────────────────────────────
|
||||
|
||||
async function main() {
|
||||
assertDistBuilt()
|
||||
|
||||
console.log('Starting mock inference server...')
|
||||
const mock = await startMockServer()
|
||||
console.log(` Mock server: ${mock.url}`)
|
||||
|
||||
const sandbox = createSandbox()
|
||||
writeMockConfig(sandbox.hermesHome, mock.url)
|
||||
console.log(` HERMES_HOME: ${sandbox.hermesHome}`)
|
||||
|
||||
const electronBin = findElectron()
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
HERMES_HOME: sandbox.hermesHome,
|
||||
HERMES_DESKTOP_USER_DATA_DIR: sandbox.userDataDir,
|
||||
HERMES_DESKTOP_IGNORE_EXISTING: '1',
|
||||
HERMES_DESKTOP_HERMES_ROOT: REPO_ROOT,
|
||||
HERMES_DESKTOP_APP_NAME: `HermesDevMock-${Date.now()}`,
|
||||
}
|
||||
|
||||
console.log('Launching Electron...')
|
||||
const child = spawn(electronBin, [DESKTOP_ROOT, '--disable-gpu', '--no-sandbox'], {
|
||||
env,
|
||||
cwd: DESKTOP_ROOT,
|
||||
stdio: 'inherit',
|
||||
})
|
||||
|
||||
child.on('exit', (code) => {
|
||||
mock.close()
|
||||
sandbox.cleanup()
|
||||
process.exit(code ?? 0)
|
||||
})
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error(err)
|
||||
process.exit(1)
|
||||
})
|
||||
@@ -409,6 +409,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
|
||||
if (sessionId) {
|
||||
flushQueuedDeltas(sessionId)
|
||||
const text = coerceGatewayText(payload?.text)
|
||||
|
||||
if (text) {
|
||||
finalizeInterimAssistantMessage(sessionId, text)
|
||||
}
|
||||
|
||||
@@ -379,6 +379,7 @@ export function useMessageStream({
|
||||
}
|
||||
|
||||
const authoritativeText = renderMediaTags(text).trim()
|
||||
|
||||
if (!authoritativeText) {
|
||||
return state
|
||||
}
|
||||
@@ -386,29 +387,30 @@ export function useMessageStream({
|
||||
const streamId = state.streamId
|
||||
|
||||
const replaceTextPart = (parts: ChatMessagePart[]) => {
|
||||
const visibleText = stripGeneratedImageEchoes(
|
||||
authoritativeText, generatedImageEchoSources(parts)
|
||||
).trim()
|
||||
const visibleText = stripGeneratedImageEchoes(authoritativeText, generatedImageEchoSources(parts)).trim()
|
||||
|
||||
return mergeFinalAssistantText(parts, visibleText)
|
||||
}
|
||||
|
||||
let nextMessages = state.messages
|
||||
|
||||
if (streamId && nextMessages.some(m => m.id === streamId)) {
|
||||
// Finalize the existing streaming bubble in place
|
||||
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 } : m
|
||||
)
|
||||
} else {
|
||||
// No streaming bubble — create a standalone interim message
|
||||
nextMessages = [...nextMessages, {
|
||||
id: `assistant-interim-${Date.now()}`,
|
||||
role: 'assistant' as const,
|
||||
parts: [assistantTextPart(authoritativeText)],
|
||||
pending: false,
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}]
|
||||
nextMessages = [
|
||||
...nextMessages,
|
||||
{
|
||||
id: `assistant-interim-${Date.now()}`,
|
||||
role: 'assistant' as const,
|
||||
parts: [assistantTextPart(authoritativeText)],
|
||||
pending: false,
|
||||
branchGroupId: state.pendingBranchGroup ?? undefined
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
return {
|
||||
@@ -451,6 +453,7 @@ export function useMessageStream({
|
||||
|
||||
const replaceTextPart = (parts: ChatMessagePart[]) => {
|
||||
const visibleFinalText = stripGeneratedImageEchoes(finalText, generatedImageEchoSources(parts)).trim()
|
||||
|
||||
return mergeFinalAssistantText(parts, visibleFinalText)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,9 +4,9 @@ import { useEffect, useRef } from 'react'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import type { ClientSessionState } from '@/app/types'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { chatMessageText } from '@/lib/chat-messages'
|
||||
import { $todosBySession, clearSessionTodos, setSessionTodos } from '@/store/todos'
|
||||
import { createClientSessionState } from '@/lib/chat-runtime'
|
||||
import { clearSessionTodos } from '@/store/todos'
|
||||
import type { RpcEvent } from '@/types/hermes'
|
||||
|
||||
import { useMessageStream } from './index'
|
||||
@@ -55,10 +55,13 @@ async function mountStream() {
|
||||
|
||||
const start = () => act(() => handleEvent!({ payload: {}, session_id: SID, type: 'message.start' }))
|
||||
const delta = (text: string) => act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.delta' }))
|
||||
|
||||
const interim = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text, already_streamed: true }, session_id: SID, type: 'message.interim' }))
|
||||
|
||||
const complete = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text }, session_id: SID, type: 'message.complete' }))
|
||||
|
||||
const completePreviewed = (text: string) =>
|
||||
act(() => handleEvent!({ payload: { text, response_previewed: true }, session_id: SID, type: 'message.complete' }))
|
||||
|
||||
@@ -69,11 +72,13 @@ function getState(): ClientSessionState {
|
||||
function assistantText(): string {
|
||||
const state = getState()
|
||||
const last = [...state.messages].reverse().find(m => m.role === 'assistant' && !m.hidden)
|
||||
|
||||
return last ? chatMessageText(last) : ''
|
||||
}
|
||||
|
||||
function assistantMessages(): string[] {
|
||||
const state = getState()
|
||||
|
||||
return state.messages
|
||||
.filter(m => m.role === 'assistant' && !m.hidden)
|
||||
.map(m => chatMessageText(m))
|
||||
@@ -222,7 +227,9 @@ describe('useMessageStream interim text sealing', () => {
|
||||
// Empty text
|
||||
await act(() => handleEvent!({ payload: { text: '' }, session_id: SID, type: 'message.interim' } as RpcEvent))
|
||||
// Undefined text
|
||||
await act(() => handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent))
|
||||
await act(() =>
|
||||
handleEvent!({ payload: { text: undefined }, session_id: SID, type: 'message.interim' } as RpcEvent)
|
||||
)
|
||||
|
||||
// Turn continues without finalizing or throwing
|
||||
expect(getState().busy).toBe(true)
|
||||
|
||||
@@ -36,7 +36,7 @@ describe('ResponseLoadingIndicator timer', () => {
|
||||
const sessionA = renderIndicator()
|
||||
|
||||
act(() => vi.advanceTimersByTime(5_000))
|
||||
expect(screen.getByText('5s')).toBeTruthy()
|
||||
expect(screen.getAllByText((_, node) => node?.textContent === '5s').length).toBeGreaterThan(0)
|
||||
sessionA.unmount()
|
||||
|
||||
$activeSessionId.set('session-b')
|
||||
@@ -44,13 +44,13 @@ describe('ResponseLoadingIndicator timer', () => {
|
||||
const sessionB = renderIndicator()
|
||||
|
||||
act(() => vi.advanceTimersByTime(3_000))
|
||||
expect(screen.getByText('3s')).toBeTruthy()
|
||||
expect(screen.getAllByText((_, node) => node?.textContent === '3s').length).toBeGreaterThan(0)
|
||||
sessionB.unmount()
|
||||
|
||||
$activeSessionId.set('session-a')
|
||||
$turnStartedAt.set(new Date('2026-01-01T00:00:00.000Z').getTime())
|
||||
renderIndicator()
|
||||
|
||||
expect(screen.getByText('8s')).toBeTruthy()
|
||||
expect(screen.getAllByText((_, node) => node?.textContent === '8s').length).toBeGreaterThan(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
import { formatElapsed } from './activity-timer'
|
||||
import { StableText } from './stable-text'
|
||||
|
||||
interface ActivityTimerTextProps {
|
||||
seconds: number
|
||||
@@ -9,16 +10,16 @@ interface ActivityTimerTextProps {
|
||||
|
||||
export function ActivityTimerText({ seconds, className }: ActivityTimerTextProps) {
|
||||
return (
|
||||
<span
|
||||
<StableText
|
||||
className={cn(
|
||||
// Tinted with --dt-midground (very low alpha) so the timer reads
|
||||
// as part of the same "live signal" cluster as the dither block /
|
||||
// arc-border / working-session dot, instead of being neutral chrome.
|
||||
'shrink-0 font-mono text-[0.56rem] leading-none tracking-[0.02em] text-midground/55 tabular-nums',
|
||||
'shrink-0 text-[0.56rem] leading-none tracking-[0.02em] text-midground/55',
|
||||
className
|
||||
)}
|
||||
>
|
||||
{formatElapsed(seconds)}
|
||||
</span>
|
||||
</StableText>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,23 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
interface StableTextProps {
|
||||
children: string
|
||||
className?: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders text as a row of 1ch-wide cells so individual characters can't
|
||||
* shift the layout as they change (e.g. digits in a ticking timer).
|
||||
* Works with any proportional font — no need for font-mono.
|
||||
*/
|
||||
export function StableText({ children, className }: StableTextProps) {
|
||||
return (
|
||||
<span className={cn('inline-flex', className)}>
|
||||
{children.split('').map((char, i) => (
|
||||
<span className="inline-block w-[1ch] text-center" key={i}>
|
||||
{char}
|
||||
</span>
|
||||
))}
|
||||
</span>
|
||||
)
|
||||
}
|
||||
@@ -35,20 +35,11 @@ function forcedPreview(): boolean {
|
||||
}
|
||||
}
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||
}
|
||||
|
||||
export function GatewayConnectingOverlay() {
|
||||
const gatewayState = useStore($gatewayState)
|
||||
const boot = useStore($desktopBoot)
|
||||
const gatewaySwitching = useStore($gatewaySwitching)
|
||||
const [previewing] = useState(forcedPreview)
|
||||
const reduce = prefersReducedMotion()
|
||||
// Under reduced motion, skip the multi-phase exit choreography (text-out →
|
||||
// hold → overlay fade) and jump straight to gone so the overlay unmounts
|
||||
// the instant the gateway opens. E2E screenshots rely on this to avoid
|
||||
// catching the overlay mid-fade.
|
||||
const [phase, setPhase] = useState<Phase>('live')
|
||||
// Once cold boot has completed once, never resurrect the fullscreen overlay
|
||||
// — soft gateway switches keep the shell and reskeleton the sidebar instead.
|
||||
@@ -90,13 +81,9 @@ export function GatewayConnectingOverlay() {
|
||||
}
|
||||
|
||||
if (gatewayState === 'open' && shownRef.current) {
|
||||
// Under reduced motion, skip the multi-phase exit choreography
|
||||
// (text-out → hold → overlay fade) and jump straight to gone so the
|
||||
// overlay unmounts the instant the gateway opens. E2E screenshots
|
||||
// rely on this to avoid catching the overlay mid-fade.
|
||||
setPhase(reduce ? 'gone' : 'text-out')
|
||||
setPhase('text-out')
|
||||
}
|
||||
}, [phase, previewing, gatewayState, reduce])
|
||||
}, [phase, previewing, gatewayState])
|
||||
|
||||
// Advance the exit choreography: text-out -> overlay-out -> gone.
|
||||
useEffect(() => {
|
||||
|
||||
@@ -23,10 +23,6 @@ export const DECODE_SCRAMBLE_CHARS = '/\\|-_=+<>~:*'
|
||||
const TICK_MS = 45
|
||||
const HOLD_TICKS = 16
|
||||
|
||||
function prefersReducedMotion(): boolean {
|
||||
return typeof window !== 'undefined' && Boolean(window.matchMedia?.('(prefers-reduced-motion: reduce)').matches)
|
||||
}
|
||||
|
||||
function scrambled(tail: string, resolvedCount: number): string {
|
||||
return Array.from(tail, (ch, i) =>
|
||||
ch === ' ' || i < resolvedCount ? ch : DECODE_SCRAMBLE_CHARS[(Math.random() * DECODE_SCRAMBLE_CHARS.length) | 0]
|
||||
@@ -66,15 +62,6 @@ export function DecodeText({
|
||||
return
|
||||
}
|
||||
|
||||
// Under reduced motion, skip the scramble interval and render the fully
|
||||
// resolved text immediately. The cursor blink (CSS animation) is also
|
||||
// killed by the blanket reduced-motion CSS rule.
|
||||
if (prefersReducedMotion()) {
|
||||
setTail(tailText)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
let resolved = 0
|
||||
let hold = 0
|
||||
|
||||
|
||||
@@ -804,10 +804,7 @@ describe('mergeFinalAssistantText', () => {
|
||||
})
|
||||
|
||||
it('drops reasoning that the final text fully covers (reasoning ⊆ final)', () => {
|
||||
const parts = [
|
||||
reasoningPart('Let me check the files.'),
|
||||
{ type: 'text' as const, text: 'streamed' }
|
||||
]
|
||||
const parts = [reasoningPart('Let me check the files.'), { type: 'text' as const, text: 'streamed' }]
|
||||
|
||||
const result = mergeFinalAssistantText(parts, 'Let me check the files. Everything looks good.')
|
||||
|
||||
@@ -819,7 +816,9 @@ describe('mergeFinalAssistantText', () => {
|
||||
// #61447: a short final ("Done.") must NOT swallow a longer reasoning block
|
||||
// that merely starts with it.
|
||||
const parts = [
|
||||
reasoningPart('Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'),
|
||||
reasoningPart(
|
||||
'Done. The root cause was a bare catch block swallowing Stripe errors. The fix adds proper error logging.'
|
||||
),
|
||||
{ type: 'text' as const, text: 'streamed' }
|
||||
]
|
||||
|
||||
@@ -842,10 +841,7 @@ describe('mergeFinalAssistantText', () => {
|
||||
})
|
||||
|
||||
it('handles empty final text', () => {
|
||||
const parts = [
|
||||
{ type: 'text' as const, text: 'streamed' },
|
||||
reasoningPart('some reasoning')
|
||||
]
|
||||
const parts = [{ type: 'text' as const, text: 'streamed' }, reasoningPart('some reasoning')]
|
||||
|
||||
const result = mergeFinalAssistantText(parts, '')
|
||||
|
||||
|
||||
@@ -152,10 +152,7 @@ const normalizeWs = (value: string) => value.replace(/\s+/g, ' ').trim()
|
||||
* - Keeps all other part types (tool-call, image, etc.).
|
||||
* - Appends the final text as a new text part.
|
||||
*/
|
||||
export function mergeFinalAssistantText(
|
||||
parts: ChatMessagePart[],
|
||||
finalText: string
|
||||
): ChatMessagePart[] {
|
||||
export function mergeFinalAssistantText(parts: ChatMessagePart[], finalText: string): ChatMessagePart[] {
|
||||
const dedupeReference = normalizeWs(finalText)
|
||||
|
||||
const kept = parts.filter(part => {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
import { StableText } from '@/components/chat/stable-text'
|
||||
import { compactNumber } from '@/lib/format'
|
||||
import type { UsageStats } from '@/types/hermes'
|
||||
|
||||
@@ -72,5 +73,9 @@ export function LiveDuration({ since }: { since: number | null | undefined }) {
|
||||
return () => window.clearInterval(timer)
|
||||
}, [since])
|
||||
|
||||
return since ? formatDuration(now - since) : null
|
||||
if (!since) {
|
||||
return null
|
||||
}
|
||||
|
||||
return <StableText>{formatDuration(now - since)}</StableText>
|
||||
}
|
||||
@@ -33,16 +33,12 @@ export function applyDesktopBootProgress(progress: DesktopBootProgress) {
|
||||
const nextProgress = clampProgress(progress.progress)
|
||||
const mergedProgress = progress.running ? Math.max(current.progress, nextProgress) : nextProgress
|
||||
|
||||
// Don't let a late progress event (error: null) clobber a previously-set
|
||||
// boot failure — failDesktopBoot is terminal for this boot cycle.
|
||||
const error = progress.error ?? (current.running ? null : current.error)
|
||||
|
||||
$desktopBoot.set({
|
||||
...current,
|
||||
...progress,
|
||||
error,
|
||||
error: progress.error ?? null,
|
||||
progress: mergedProgress,
|
||||
visible: progress.running || mergedProgress < 100 || Boolean(error)
|
||||
visible: progress.running || mergedProgress < 100 || Boolean(progress.error)
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
import type { ConnectionState } from '@hermes/shared'
|
||||
import { atom, computed } from 'nanostores'
|
||||
|
||||
import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading'
|
||||
@@ -214,7 +213,7 @@ export function mergeSessionPage(
|
||||
}
|
||||
|
||||
export const $connection = atom<HermesConnection | null>(null)
|
||||
export const $gatewayState = atom<ConnectionState>('idle')
|
||||
export const $gatewayState = atom('idle')
|
||||
export const $sessions = atom<SessionInfo[]>([])
|
||||
export const $sessionsTotal = atom<number>(0)
|
||||
// Cron-job sessions (source === 'cron') are fetched as their own list so the
|
||||
@@ -320,7 +319,7 @@ export const $modelPickerOpen = atom(false)
|
||||
export const $sessionPickerOpen = atom(false)
|
||||
|
||||
export const setConnection = (next: Updater<HermesConnection | null>) => updateAtom($connection, next)
|
||||
export const setGatewayState = (next: Updater<ConnectionState>) => updateAtom($gatewayState, next)
|
||||
export const setGatewayState = (next: Updater<string>) => updateAtom($gatewayState, next)
|
||||
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
|
||||
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
|
||||
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
|
||||
|
||||
@@ -5,22 +5,6 @@
|
||||
@import '@vscode/codicons/dist/codicon.css';
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* Blanket reduced-motion override: kill ALL CSS animations and transitions
|
||||
when the user (or the E2E test harness via prefers-reduced-motion) requests
|
||||
it. Per-component @media rules below handle specific cases; this catches
|
||||
everything else so overlays and loading bars resolve instantly instead of
|
||||
being caught mid-fade by a screenshot. */
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
animation-duration: 0.01ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
transition-duration: 0.01ms !important;
|
||||
scroll-behavior: auto !important;
|
||||
}
|
||||
}
|
||||
|
||||
/* Sidebar sections: tall viewports give each its own scroller; compact ones
|
||||
(this variant) flatten everything into one shared scroll. See ChatSidebar. */
|
||||
@custom-variant compact (@media (max-height: 768px));
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"compilerOptions": {
|
||||
"types": ["node", "@playwright/test"],
|
||||
"composite": true
|
||||
},
|
||||
"include": ["e2e", "playwright.config.ts"],
|
||||
"exclude": ["src", "electron"]
|
||||
}
|
||||
@@ -23,6 +23,5 @@
|
||||
}
|
||||
},
|
||||
"include": ["src", "../shared/src"],
|
||||
"exclude": ["e2e", "electron", "playwright.config.ts"],
|
||||
"references": [{ "path": "./tsconfig.electron.json" }]
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
momomojo
|
||||
@@ -0,0 +1 @@
|
||||
ajzrva-sys
|
||||
@@ -0,0 +1 @@
|
||||
Almurat123
|
||||
@@ -0,0 +1 @@
|
||||
geoffreybutler94
|
||||
@@ -0,0 +1 @@
|
||||
gigakun3030
|
||||
@@ -0,0 +1 @@
|
||||
okisdev
|
||||
@@ -0,0 +1 @@
|
||||
githubespresso407
|
||||
@@ -0,0 +1 @@
|
||||
M1racleShih
|
||||
@@ -0,0 +1 @@
|
||||
DatTheMaster
|
||||
@@ -0,0 +1 @@
|
||||
datachainsystems
|
||||
@@ -0,0 +1 @@
|
||||
frizikk
|
||||
@@ -0,0 +1 @@
|
||||
JasonFang1993
|
||||
@@ -0,0 +1 @@
|
||||
F4TB0Yz
|
||||
@@ -0,0 +1 @@
|
||||
kuangmi-bit
|
||||
@@ -0,0 +1 @@
|
||||
ly-wang19
|
||||
@@ -0,0 +1 @@
|
||||
marcolivierlavoie
|
||||
@@ -0,0 +1 @@
|
||||
MarkVLK
|
||||
@@ -0,0 +1 @@
|
||||
Punyko8
|
||||
@@ -0,0 +1 @@
|
||||
RKelln
|
||||
@@ -0,0 +1 @@
|
||||
sjiangtao2024
|
||||
@@ -0,0 +1 @@
|
||||
TurgutKural
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.2 KiB |
@@ -1,126 +0,0 @@
|
||||
#Requires AutoHotkey v2.0
|
||||
#SingleInstance Force
|
||||
|
||||
|
||||
logPath := A_Args.Length >= 1 ? A_Args[1] : "ahk.log"
|
||||
|
||||
Log(text) {
|
||||
msg := Format("[autohotkey] {}`n", text)
|
||||
ToolTip(text)
|
||||
FileAppend(msg, '*')
|
||||
FileAppend(msg, logPath)
|
||||
}
|
||||
|
||||
OnError(LogError)
|
||||
|
||||
LogError(err, mode) {
|
||||
Log(Format("Unhandled error: {}", err.Message))
|
||||
ExitApp(1)
|
||||
return -1 ; suppress the standard error dialog
|
||||
}
|
||||
|
||||
SetWorkingDir(A_ScriptDir)
|
||||
CoordMode("Pixel", "Screen")
|
||||
CoordMode("Mouse", "Screen")
|
||||
|
||||
|
||||
ClickWithMarker(x, y, button := "Left") {
|
||||
Click(x, y, button)
|
||||
|
||||
Sleep(10)
|
||||
MouseMove(30, 30)
|
||||
Log(Format("Clicking at {1}, {2}", x, y))
|
||||
size := 20
|
||||
g := Gui("-Caption +AlwaysOnTop +ToolWindow")
|
||||
g.BackColor := "Red"
|
||||
g.Show(Format(
|
||||
"x{} y{} w{} h{} NoActivate"
|
||||
, x - size // 2
|
||||
, y - size // 2
|
||||
, size
|
||||
, size
|
||||
))
|
||||
hRegion := DllCall(
|
||||
"CreateEllipticRgn"
|
||||
, "Int", 0
|
||||
, "Int", 0
|
||||
, "Int", size
|
||||
, "Int", size
|
||||
, "Ptr"
|
||||
)
|
||||
DllCall("SetWindowRgn", "Ptr", g.Hwnd, "Ptr", hRegion, "Int", true)
|
||||
WinSetTransparent(255, g.Hwnd)
|
||||
SetTimer(() => g.Destroy(), -500)
|
||||
}
|
||||
|
||||
FindImageInWindow(winTitle, imageFile, &outX, &outY, timeoutMs := 10000, intervalMs := 250)
|
||||
{
|
||||
WinGetPos(&wx, &wy, &ww, &wh, winTitle)
|
||||
|
||||
hBitmap := LoadPicture(imageFile)
|
||||
|
||||
if !hBitmap {
|
||||
throw Error("LoadPicture failed: " imageFile)
|
||||
}
|
||||
bm := Buffer(32, 0) ; BITMAP structure on x64
|
||||
DllCall("GetObject", "Ptr", hBitmap, "Int", bm.Size, "Ptr", bm)
|
||||
|
||||
width := NumGet(bm, 4, "Int")
|
||||
height := NumGet(bm, 8, "Int")
|
||||
|
||||
|
||||
startTime := A_TickCount
|
||||
|
||||
timeLeft := 1
|
||||
|
||||
Log(Format("Searching for button file {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2)))
|
||||
searchImage := Format("*10 {}", imageFile)
|
||||
Log("SearchImage: " searchImage)
|
||||
while (timeLeft > 0)
|
||||
{
|
||||
if ImageSearch(&x, &y, wx, wy, wx + ww, wy + wh, searchImage)
|
||||
{
|
||||
outX := x + Floor(width / 2)
|
||||
outY := y + Floor(height / 2)
|
||||
Log("Found button!")
|
||||
return
|
||||
}
|
||||
|
||||
Sleep intervalMs
|
||||
timeLeft := timeoutMs - (A_TickCount - startTime)
|
||||
ToolTip(Format("Searching for button {} in window {}... {}s left", imageFile, winTitle, Round(timeLeft / 1000, 2)))
|
||||
}
|
||||
|
||||
throw Error(Format("Failed to find button {} in window {}", imageFile, winTitle))
|
||||
}
|
||||
|
||||
ClickCenterOfImageInWindow(winTitle, imageFile, timeoutMs := 10000, intervalMs := 250)
|
||||
{
|
||||
FindImageInWindow(winTitle, imageFile, &x, &y, timeoutMs, intervalMs)
|
||||
ClickWithMarker(x, y)
|
||||
}
|
||||
|
||||
|
||||
Log("Waiting for the installer window to appear...")
|
||||
winTitle := "Hermes"
|
||||
try {
|
||||
WinWait(winTitle, , 30)
|
||||
} catch {
|
||||
throw Error("Hermes installer window did not appear within 30s")
|
||||
}
|
||||
WinGetPos(&x, &y, &w, &h, winTitle)
|
||||
Log(Format("Window found at x={1} y={2} w={3} h={4}`n", x, y, w, h))
|
||||
|
||||
ClickCenterOfImageInWindow(winTitle, A_ScriptDir "\install-button.png")
|
||||
|
||||
; wait for install to finish
|
||||
FindImageInWindow(winTitle, A_ScriptDir "\launch-button.png", &launchX, &launchY, 1000 * 60 * 8)
|
||||
|
||||
; after we find the install button, close the window so we don't launch hermes.
|
||||
WinClose(winTitle)
|
||||
|
||||
; yay :D
|
||||
Sleep(2000)
|
||||
|
||||
; done
|
||||
ExitApp(0)
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 1.5 KiB |
@@ -14,8 +14,8 @@ Provides subcommands for:
|
||||
import os
|
||||
import sys
|
||||
|
||||
__version__ = "0.18.2"
|
||||
__release_date__ = "2026.7.7.2"
|
||||
__version__ = "0.19.0"
|
||||
__release_date__ = "2026.7.20"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
|
||||
@@ -1720,7 +1720,6 @@ def list_authenticated_providers(
|
||||
|
||||
results: List[dict] = []
|
||||
seen_slugs: set = set() # lowercase-normalized to catch case variants (#9545)
|
||||
seen_mdev_ids: set = set() # prevent duplicate entries for aliases (e.g. kimi-coding + kimi-coding-cn)
|
||||
_current_provider_norm = str(current_provider or "").strip().lower()
|
||||
_current_base_url_norm = str(current_base_url or "").strip().rstrip("/").lower()
|
||||
|
||||
@@ -1849,6 +1848,7 @@ def list_authenticated_providers(
|
||||
|
||||
# --- 1. Check Hermes-mapped providers ---
|
||||
from hermes_cli.models import _AGGREGATOR_PROVIDERS as _AGG_PROVIDERS
|
||||
from hermes_cli.models import _PROVIDER_ALIASES as _CANON_ALIASES
|
||||
from hermes_cli.providers import ALIASES as _PROVIDER_ALIAS_TABLE
|
||||
for hermes_id, mdev_id in PROVIDER_TO_MODELS_DEV.items():
|
||||
# Skip vendor names that are merely aliases routing through an
|
||||
@@ -1866,10 +1866,30 @@ def list_authenticated_providers(
|
||||
and _alias_target in _AGG_PROVIDERS
|
||||
):
|
||||
continue
|
||||
# Skip aliases that map to the same models.dev provider (e.g.
|
||||
# kimi-coding and kimi-coding-cn both → kimi-for-coding).
|
||||
# The first one with valid credentials wins (#10526).
|
||||
if mdev_id in seen_mdev_ids:
|
||||
# Resolve the canonical provider profile name. Skip hermes_ids
|
||||
# that are mere aliases resolving to a different canonical profile
|
||||
# (e.g. "kimi" and "moonshot" both → "kimi-coding"). Only process
|
||||
# entries whose hermes_id matches the canonical profile name so
|
||||
# distinct profiles (e.g. kimi-coding, kimi-coding-cn) each get
|
||||
# their own picker row.
|
||||
_canonical = hermes_id
|
||||
try:
|
||||
from providers import get_provider_profile as _gpp
|
||||
_prof = _gpp(hermes_id)
|
||||
if _prof is not None:
|
||||
_canonical = _prof.name
|
||||
except Exception:
|
||||
pass
|
||||
if _canonical != hermes_id:
|
||||
continue
|
||||
|
||||
# Skip duplicates: another entry with the same slug was already
|
||||
# emitted (e.g. two PROVIDER_TO_MODELS_DEV entries routing to the
|
||||
# same hermes_id). Distinct canonical profiles that share a
|
||||
# models.dev ID (e.g. kimi-coding and kimi-coding-cn → kimi-for-coding)
|
||||
# are both allowed through since they have different slugs.
|
||||
slug = hermes_id
|
||||
if slug.lower() in seen_slugs:
|
||||
continue
|
||||
if hermes_id.lower() in _excluded or mdev_id.lower() in _excluded:
|
||||
continue
|
||||
@@ -1940,21 +1960,23 @@ def list_authenticated_providers(
|
||||
else:
|
||||
top = model_ids[:max_models] if max_models is not None else model_ids
|
||||
|
||||
slug = hermes_id
|
||||
pinfo = _mdev_pinfo(mdev_id)
|
||||
display_name = pinfo.name if pinfo else mdev_id
|
||||
display_name = pconfig.name if pconfig and pconfig.name else (pinfo.name if pinfo else mdev_id)
|
||||
|
||||
results.append({
|
||||
"slug": slug,
|
||||
"name": display_name,
|
||||
"is_current": slug == current_provider or mdev_id == current_provider,
|
||||
"is_current": (
|
||||
slug == current_provider
|
||||
or hermes_id == current_provider
|
||||
or mdev_id == current_provider
|
||||
),
|
||||
"is_user_defined": False,
|
||||
"models": top,
|
||||
"total_models": total,
|
||||
"source": "built-in",
|
||||
})
|
||||
seen_slugs.add(slug.lower())
|
||||
seen_mdev_ids.add(mdev_id)
|
||||
_record_builtin_endpoint(slug)
|
||||
|
||||
# --- 2. Check Hermes-only providers (nous, openai-codex, copilot, opencode-go) ---
|
||||
|
||||
@@ -319,6 +319,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"minimaxai/minimax-m3",
|
||||
],
|
||||
"kimi-coding": [
|
||||
"kimi-k3",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
@@ -330,6 +331,9 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"kimi-k2-0905-preview",
|
||||
],
|
||||
"kimi-coding-cn": [
|
||||
"kimi-k3",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.7-code-highspeed",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2-thinking",
|
||||
@@ -341,6 +345,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"step-3.5-flash-2603",
|
||||
],
|
||||
"moonshot": [
|
||||
"kimi-k3",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
"kimi-k2-thinking",
|
||||
@@ -463,6 +468,7 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
|
||||
"nemotron-3-ultra-free",
|
||||
],
|
||||
"opencode-go": [
|
||||
"kimi-k3",
|
||||
"kimi-k2.7-code",
|
||||
"kimi-k2.6",
|
||||
"kimi-k2.5",
|
||||
|
||||
@@ -765,6 +765,36 @@ def resolve_provider_full(
|
||||
if user_pdef is not None:
|
||||
return user_pdef
|
||||
|
||||
# 0.5 Exact Hermes provider IDs must win over LOSSY alias collapsing.
|
||||
# Example: kimi-coding-cn should stay distinct from kimi-coding instead of
|
||||
# normalizing through the shared models.dev alias "kimi-for-coding".
|
||||
# A collapse is lossy only when MULTIPLE distinct registry providers
|
||||
# normalize to the same canonical name — resolving through the alias
|
||||
# would then lose which one the caller meant. Single-entry rewrites
|
||||
# (e.g. "copilot" → "github-copilot") are correct routing and must keep
|
||||
# resolving through the built-in chain below so overlay transports apply.
|
||||
if canonical != raw:
|
||||
try:
|
||||
from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY
|
||||
_pcfg = _AUTH_PROVIDER_REGISTRY.get(raw)
|
||||
if _pcfg is not None:
|
||||
_collapsed_siblings = [
|
||||
_rid
|
||||
for _rid in _AUTH_PROVIDER_REGISTRY
|
||||
if normalize_provider(_rid) == canonical
|
||||
]
|
||||
if len(_collapsed_siblings) > 1:
|
||||
return ProviderDef(
|
||||
id=_pcfg.id,
|
||||
name=_pcfg.name,
|
||||
transport="openai_chat",
|
||||
api_key_env_vars=tuple(_pcfg.api_key_env_vars or ()),
|
||||
base_url=_pcfg.inference_base_url or "",
|
||||
source="hermes-auth-registry",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 1. Built-in (models.dev + overlays)
|
||||
pdef = get_provider(canonical)
|
||||
if pdef is not None:
|
||||
|
||||
+3
-3
@@ -100,15 +100,15 @@ _DEFAULT_PROVIDER_MODELS = {
|
||||
"google/gemini-2.5-pro", "google/gemini-2.5-flash",
|
||||
],
|
||||
"zai": ["glm-5.2", "glm-5.1", "glm-5", "glm-4.7", "glm-4.5", "glm-4.5-flash"],
|
||||
"kimi-coding": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"kimi-coding-cn": ["kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"kimi-coding": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"kimi-coding-cn": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "kimi-k2-thinking", "kimi-k2-turbo-preview"],
|
||||
"stepfun": ["step-3.5-flash", "step-3.5-flash-2603"],
|
||||
"arcee": ["trinity-large-thinking", "trinity-large-preview", "trinity-mini"],
|
||||
"minimax": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
|
||||
"minimax-cn": ["MiniMax-M2.7", "MiniMax-M2.5", "MiniMax-M2.1", "MiniMax-M2"],
|
||||
"kilocode": ["anthropic/claude-sonnet-5", "anthropic/claude-opus-4.6", "anthropic/claude-sonnet-4.6", "openai/gpt-5.4", "google/gemini-3-pro-preview", "google/gemini-3-flash-preview"],
|
||||
"opencode-zen": ["gpt-5.4", "gpt-5.3-codex", "claude-sonnet-5", "claude-sonnet-4-6", "gemini-3-flash", "glm-5", "kimi-k2.5", "minimax-m2.7"],
|
||||
"opencode-go": ["kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"],
|
||||
"opencode-go": ["kimi-k3", "kimi-k2.6", "kimi-k2.5", "glm-5.1", "glm-5", "mimo-v2.5-pro", "mimo-v2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5", "qwen3.7-max", "qwen3.6-plus", "qwen3.5-plus"],
|
||||
"huggingface": [
|
||||
"Qwen/Qwen3.5-397B-A17B", "Qwen/Qwen3-235B-A22B-Thinking-2507",
|
||||
"Qwen/Qwen3-Coder-480B-A35B-Instruct", "deepseek-ai/DeepSeek-R1-0528",
|
||||
|
||||
@@ -1346,7 +1346,12 @@ def _apply_main_model_assignment(
|
||||
if api_key.strip():
|
||||
model_cfg["api_key"] = api_key.strip()
|
||||
model_cfg.pop("api", None)
|
||||
elif model_cfg.get("api_key") and new_provider != prev_provider:
|
||||
elif (model_cfg.get("api_key") or model_cfg.get("api")) and new_provider != prev_provider:
|
||||
# A stale endpoint secret can live under the legacy ``api`` alias with
|
||||
# no ``api_key`` (the resolver still reads ``model.api`` as a key), so
|
||||
# the switch-clears-the-key path must trigger on either field — else the
|
||||
# old endpoint's secret survives in config.yaml and contaminates a later
|
||||
# custom resolution. clear_model_endpoint_credentials scrubs both.
|
||||
clear_model_endpoint_credentials(model_cfg, clear_api_mode=False)
|
||||
if new_provider != prev_provider:
|
||||
clear_model_endpoint_credentials(model_cfg, clear_api_key=False)
|
||||
|
||||
@@ -5066,6 +5066,47 @@ class SessionDB:
|
||||
)
|
||||
return model_history, display_history
|
||||
|
||||
def get_ancestor_display_prefix(self, session_id: str) -> List[Dict[str, Any]]:
|
||||
"""Return the ancestor-only display messages for a session lineage.
|
||||
|
||||
These are messages from parent/grandparent sessions (compression
|
||||
ancestors) that appear in the display transcript but NOT in the
|
||||
tip session's model-fed history. Used by ``session.resume`` to
|
||||
build the ``display_history_prefix`` that ``_live_session_payload``
|
||||
prepends to the live model history.
|
||||
|
||||
Previously the prefix was calculated as
|
||||
``display_history[:len(display) - len(raw)]``, but that overcounts
|
||||
when ``repair_message_sequence`` removes messages from the MIDDLE
|
||||
of the tip history (e.g. verification candidates collapsed by the
|
||||
consecutive-assistant merge) — the length difference includes both
|
||||
ancestor messages AND repair-removed tip messages, but the slice
|
||||
only captures the first N display messages (which are tip messages
|
||||
when there are no ancestors), causing duplication. This method
|
||||
returns ONLY the genuine ancestor messages, identified by
|
||||
``session_id != tip_session_id``. (#65919)
|
||||
"""
|
||||
session_ids = self._session_lineage_root_to_tip(session_id)
|
||||
if len(session_ids) <= 1:
|
||||
return []
|
||||
with self._lock:
|
||||
placeholders = ",".join("?" for _ in session_ids)
|
||||
rows = self._conn.execute(
|
||||
f"SELECT session_id, {self._CONVERSATION_ROW_COLUMNS} "
|
||||
f"FROM messages WHERE session_id IN ({placeholders}) AND active = 1 "
|
||||
"ORDER BY id",
|
||||
tuple(session_ids),
|
||||
).fetchall()
|
||||
ancestor_rows = [r for r in rows if r["session_id"] != session_id]
|
||||
if not ancestor_rows:
|
||||
return []
|
||||
return self._rows_to_conversation(
|
||||
ancestor_rows,
|
||||
session_id=session_id,
|
||||
include_ancestors=True,
|
||||
repair_alternation=False,
|
||||
)
|
||||
|
||||
def get_conversation_root(self, session_id: str) -> str:
|
||||
"""Return the ROOT id of *session_id*'s lineage chain.
|
||||
|
||||
|
||||
@@ -43,9 +43,6 @@
|
||||
${combinedNonNpm}
|
||||
${hermesNpmLib.mkNpmDevShellHook npmPackageJsonPaths}
|
||||
|
||||
# Force Node to use Nix's playwright-test binary instead of node_modules/.bin
|
||||
export PATH="${pkgs.playwright-test}/bin:$PATH"
|
||||
|
||||
# for the devshell to pick up the src
|
||||
export HERMES_PYTHON_SRC_ROOT=$(git rev-parse --show-toplevel)
|
||||
echo "Hermes Agent dev shell in $HERMES_PYTHON_SRC_ROOT"
|
||||
|
||||
Generated
-64
@@ -155,7 +155,6 @@
|
||||
"devDependencies": {
|
||||
"@electron/rebuild": "^4.0.6",
|
||||
"@eslint/js": "^9.39.4",
|
||||
"@playwright/test": "=1.58.2",
|
||||
"@testing-library/dom": "^10.4.0",
|
||||
"@testing-library/react": "^16.3.2",
|
||||
"@types/d3-force": "^3.0.10",
|
||||
@@ -377,22 +376,6 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@playwright/test": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.58.2.tgz",
|
||||
"integrity": "sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/@types/node": {
|
||||
"version": "22.20.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.0.tgz",
|
||||
@@ -403,53 +386,6 @@
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/fsevents": {
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
|
||||
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/playwright": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.58.2.tgz",
|
||||
"integrity": "sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"playwright-core": "1.58.2"
|
||||
},
|
||||
"bin": {
|
||||
"playwright": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "2.3.2"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/playwright-core": {
|
||||
"version": "1.58.2",
|
||||
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.58.2.tgz",
|
||||
"integrity": "sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
"playwright-core": "cli.js"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"apps/desktop/node_modules/undici-types": {
|
||||
"version": "6.21.0",
|
||||
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz",
|
||||
|
||||
@@ -152,11 +152,24 @@ logger = logging.getLogger(__name__)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MARKDOWN_HINT_RE = re.compile(
|
||||
r"(^#{1,6}\s)|(^\s*[-*]\s)|(^\s*\d+\.\s)|(^\s*---+\s*$)|(```)|(`[^`\n]+`)|(\*\*[^*\n].+?\*\*)|(~~[^~\n].+?~~)|(<u>.+?</u>)|(\*[^*\n]+\*)|(\[[^\]]+\]\([^)]+\))|(^>\s)",
|
||||
# Pipe table: any header line + separator line both starting with '|'.
|
||||
r"(^\|.*\|\s*\n\|[-:|\s]+\|)"
|
||||
# Headings, lists, code, bold/italic/strike/underline, links, blockquotes.
|
||||
r"|(^#{1,6}\s)"
|
||||
r"|(^\s*[-*]\s)"
|
||||
r"|(^\s*\d+\.\s)"
|
||||
r"|(^\s*---+\s*$)"
|
||||
r"|(```)"
|
||||
r"|(`[^`\n]+`)"
|
||||
r"|(\*\*[^*\n].+?\*\*)"
|
||||
r"|(~~[^~\n].+?~~)"
|
||||
r"|(<u>.+?</u>)"
|
||||
r"|(\*[^*\n]+\*)"
|
||||
r"|(\[[^\]]+\]\([^)]+\))"
|
||||
r"|(^>\s)",
|
||||
re.MULTILINE,
|
||||
)
|
||||
# Detect markdown tables: a line starting with | followed by a separator line.
|
||||
# Feishu post-type 'md' elements do not render tables, so we force text mode.
|
||||
# Backwards-compatible alias retained because external callers reference it.
|
||||
_MARKDOWN_TABLE_RE = re.compile(r"^\|.*\|\n\|[-|: ]+\|", re.MULTILINE)
|
||||
_MARKDOWN_LINK_RE = re.compile(r"\[([^\]]+)\]\(([^)]+)\)")
|
||||
_MARKDOWN_FENCE_OPEN_RE = re.compile(r"^```([^\n`]*)\s*$")
|
||||
@@ -1897,11 +1910,21 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, self.MAX_MESSAGE_LENGTH)
|
||||
# When chunking splits a long markdown response, an individual chunk
|
||||
# can end up as plain prose that doesn't match the per-chunk hint
|
||||
# regex — so it would be sent as ``msg_type=text`` and the user would
|
||||
# see literal ``**bold``/``## heading``/code fences in the Feishu
|
||||
# client while other chunks render correctly. Lock the markdown
|
||||
# decision at the whole-message level so every chunk consistently
|
||||
# uses ``post``. See #26841.
|
||||
prefer_post = bool(_MARKDOWN_HINT_RE.search(formatted))
|
||||
last_response = None
|
||||
|
||||
try:
|
||||
for chunk in chunks:
|
||||
msg_type, payload = self._build_outbound_payload(chunk)
|
||||
msg_type, payload = self._build_outbound_payload(
|
||||
chunk, prefer_post=prefer_post,
|
||||
)
|
||||
try:
|
||||
response = await self._feishu_send_with_retry(
|
||||
chat_id=chat_id,
|
||||
@@ -4532,14 +4555,21 @@ class FeishuAdapter(BasePlatformAdapter):
|
||||
# Outbound payload construction and send pipeline
|
||||
# =========================================================================
|
||||
|
||||
def _build_outbound_payload(self, content: str) -> tuple[str, str]:
|
||||
# Feishu post-type 'md' elements do not render markdown tables; sending
|
||||
# table content as post causes the message to appear blank on the client.
|
||||
# Force plain text for anything that looks like a markdown table.
|
||||
if _MARKDOWN_TABLE_RE.search(content):
|
||||
text_payload = {"text": content}
|
||||
return "text", json.dumps(text_payload, ensure_ascii=False)
|
||||
if _MARKDOWN_HINT_RE.search(content):
|
||||
def _build_outbound_payload(
|
||||
self, content: str, *, prefer_post: bool = False,
|
||||
) -> tuple[str, str]:
|
||||
# Empirically (issue #52786), current Feishu clients render markdown
|
||||
# tables inside ``post``-type ``md`` elements natively. The previous
|
||||
# table-downgrade branch forced any table-containing message to
|
||||
# ``text``, which left Feishu readers seeing the raw pipe-and-dash
|
||||
# source instead of a rendered table. Trust the common markdown path
|
||||
# for table content too.
|
||||
#
|
||||
# ``prefer_post`` lets ``send`` treat the chunk as part of a larger
|
||||
# markdown document: when a long markdown reply is split at
|
||||
# MAX_MESSAGE_LENGTH, the per-chunk regex would otherwise
|
||||
# mis-classify a plain-prose chunk as ``text``. See #26841.
|
||||
if prefer_post or _MARKDOWN_HINT_RE.search(content):
|
||||
return "post", _build_markdown_post_payload(content)
|
||||
text_payload = {"text": content}
|
||||
return "text", json.dumps(text_payload, ensure_ascii=False)
|
||||
|
||||
@@ -41,6 +41,7 @@ Environment variables:
|
||||
MATRIX_RECOVERY_KEY Recovery key for cross-signing verification after device key rotation
|
||||
MATRIX_DM_MENTION_THREADS Create a thread when bot is @mentioned in a DM (default: false)
|
||||
MATRIX_ALLOW_PUBLIC_ROOMS Allow Matrix tools to create public rooms (default: false)
|
||||
MATRIX_MAX_MESSAGE_LENGTH Outbound message chunk size in characters (default: 16000)
|
||||
MATRIX_APPROVAL_REQUIRE_SENDER
|
||||
Require reaction controls to come from the original requester
|
||||
when requester metadata is available (default: true)
|
||||
@@ -352,9 +353,39 @@ class _MatrixChoicePickerPrompt:
|
||||
bot_reaction_events: dict[str, str] = field(default_factory=dict)
|
||||
|
||||
|
||||
# Matrix message size limit (4000 chars practical, spec has no hard limit
|
||||
# but clients render poorly above this).
|
||||
MAX_MESSAGE_LENGTH = 4000
|
||||
# Matrix message size limit. The spec allows large events (~65 KB), but very
|
||||
# large bodies can render poorly in some clients. The previous 4,000-char
|
||||
# default was overly conservative and split Markdown tables mid-row (#53026).
|
||||
DEFAULT_MAX_MESSAGE_LENGTH = 16000
|
||||
MATRIX_MAX_MESSAGE_LENGTH_CEILING = 65535
|
||||
|
||||
|
||||
def _resolve_max_message_length(config) -> int:
|
||||
"""Resolve outbound chunk size from config, env, or plugin registry."""
|
||||
extra = getattr(config, "extra", {}) or {}
|
||||
raw = extra.get("max_message_length")
|
||||
if raw is None:
|
||||
raw = os.getenv("MATRIX_MAX_MESSAGE_LENGTH")
|
||||
if raw is None:
|
||||
try:
|
||||
from gateway.platform_registry import platform_registry
|
||||
|
||||
entry = platform_registry.get("matrix")
|
||||
if entry and entry.max_message_length:
|
||||
raw = entry.max_message_length
|
||||
except Exception:
|
||||
pass
|
||||
if raw is None:
|
||||
return DEFAULT_MAX_MESSAGE_LENGTH
|
||||
try:
|
||||
value = int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return DEFAULT_MAX_MESSAGE_LENGTH
|
||||
return max(500, min(value, MATRIX_MAX_MESSAGE_LENGTH_CEILING))
|
||||
|
||||
|
||||
# Back-compat alias for callers/tests that import the module constant.
|
||||
MAX_MESSAGE_LENGTH = DEFAULT_MAX_MESSAGE_LENGTH
|
||||
|
||||
# Store directory for E2EE keys and sync state.
|
||||
# Uses get_hermes_home() so each profile gets its own Matrix store.
|
||||
@@ -799,21 +830,28 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
"""Gateway adapter for Matrix (any homeserver)."""
|
||||
|
||||
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
|
||||
splits_long_messages = True # send() chunks via truncate_message(MAX_MESSAGE_LENGTH)
|
||||
splits_long_messages = True # send() chunks via truncate_message(max_message_length)
|
||||
|
||||
# Matrix clients commonly reserve typed "/" for client-local commands;
|
||||
# the adapter accepts "!command" as the alias that always reaches Hermes
|
||||
# (see _normalize_matrix_bang_command), so instruction text shows "!".
|
||||
typed_command_prefix = "!"
|
||||
|
||||
# Threshold for detecting Matrix client-side message splits.
|
||||
# When a chunk is near the ~4000-char practical limit, a continuation
|
||||
# is almost certain.
|
||||
_SPLIT_THRESHOLD = 3900
|
||||
# Class-level defaults so partially-constructed instances (tests build
|
||||
# adapters via object.__new__ without __init__) keep working; __init__
|
||||
# overrides both from _resolve_max_message_length().
|
||||
max_message_length = DEFAULT_MAX_MESSAGE_LENGTH
|
||||
_split_threshold = DEFAULT_MAX_MESSAGE_LENGTH - 100
|
||||
|
||||
def __init__(self, config: PlatformConfig):
|
||||
super().__init__(config, Platform.MATRIX)
|
||||
|
||||
self.max_message_length = _resolve_max_message_length(config)
|
||||
# Mirror other platform adapters for tests/tooling that read MAX_MESSAGE_LENGTH.
|
||||
self.MAX_MESSAGE_LENGTH = self.max_message_length
|
||||
# When a chunk is near the outbound limit, a continuation is almost certain.
|
||||
self._split_threshold = max(100, self.max_message_length - 100)
|
||||
|
||||
self._homeserver: str = (
|
||||
config.extra.get("homeserver", "") or os.getenv("MATRIX_HOMESERVER", "")
|
||||
).rstrip("/")
|
||||
@@ -1612,7 +1650,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
return SendResult(success=True)
|
||||
|
||||
formatted = self.format_message(content)
|
||||
chunks = self.truncate_message(formatted, MAX_MESSAGE_LENGTH)
|
||||
chunks = self.truncate_message(formatted, self.max_message_length)
|
||||
|
||||
last_event_id = None
|
||||
for i, chunk in enumerate(chunks):
|
||||
@@ -3607,7 +3645,7 @@ class MatrixAdapter(BasePlatformAdapter):
|
||||
try:
|
||||
pending = self._pending_text_batches.get(key)
|
||||
last_len = getattr(pending, "_last_chunk_len", 0) if pending else 0
|
||||
if last_len >= self._SPLIT_THRESHOLD:
|
||||
if last_len >= self._split_threshold:
|
||||
delay = self._text_batch_split_delay_seconds
|
||||
else:
|
||||
delay = self._text_batch_delay_seconds
|
||||
@@ -4690,6 +4728,8 @@ def _apply_yaml_config(yaml_cfg: dict, matrix_cfg: dict) -> dict | None:
|
||||
os.environ["MATRIX_AUTO_THREAD"] = str(matrix_cfg["auto_thread"]).lower()
|
||||
if "dm_mention_threads" in matrix_cfg and not os.getenv("MATRIX_DM_MENTION_THREADS"):
|
||||
os.environ["MATRIX_DM_MENTION_THREADS"] = str(matrix_cfg["dm_mention_threads"]).lower()
|
||||
if "max_message_length" in matrix_cfg and not os.getenv("MATRIX_MAX_MESSAGE_LENGTH"):
|
||||
os.environ["MATRIX_MAX_MESSAGE_LENGTH"] = str(matrix_cfg["max_message_length"])
|
||||
return None
|
||||
|
||||
|
||||
@@ -4735,7 +4775,7 @@ def register(ctx) -> None:
|
||||
allow_all_env="MATRIX_ALLOW_ALL_USERS",
|
||||
cron_deliver_env_var="MATRIX_HOME_ROOM",
|
||||
standalone_sender_fn=_standalone_send,
|
||||
max_message_length=4000,
|
||||
max_message_length=DEFAULT_MAX_MESSAGE_LENGTH,
|
||||
emoji="🔐",
|
||||
allow_update_command=True,
|
||||
)
|
||||
|
||||
+1
-1
@@ -7,7 +7,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hermes-agent"
|
||||
version = "0.18.2"
|
||||
version = "0.19.0"
|
||||
description = "The self-improving AI agent — creates skills from experience, improves them during use, and runs anywhere"
|
||||
readme = "README.md"
|
||||
# Upper bound is load-bearing, not cosmetic. uv resolves the project's
|
||||
|
||||
@@ -49,6 +49,7 @@ IGNORED_PATTERNS = [
|
||||
re.compile(r"^dependabot", re.IGNORECASE),
|
||||
re.compile(r"^renovate", re.IGNORECASE),
|
||||
re.compile(r"^Hermes\s+(Agent|Audit)$", re.IGNORECASE),
|
||||
re.compile(r"^nousbot(-eng)?$", re.IGNORECASE),
|
||||
re.compile(r"^Ubuntu$", re.IGNORECASE),
|
||||
]
|
||||
|
||||
@@ -59,6 +60,7 @@ IGNORED_EMAILS = {
|
||||
"cursoragent@cursor.com",
|
||||
"hermes@nousresearch.com",
|
||||
"hermes-audit@example.com",
|
||||
"nousbot@nousresearch.com",
|
||||
"hermes@habibilabs.dev",
|
||||
"omx@oh-my-codex.dev",
|
||||
"codex@openai.com",
|
||||
|
||||
@@ -17,6 +17,14 @@ Behaviour (all behaviours selectable via env var ``MOCK_LSP_SCRIPT``):
|
||||
(simulates a crashing server).
|
||||
- ``"slow"`` — same as ``clean`` but sleeps 1s before responding to
|
||||
``initialize`` (lets us test timeout behaviour).
|
||||
- ``"stale"`` — pushes one error on ``didOpen``, then goes SILENT on
|
||||
``didChange`` (no push) and rejects the pull endpoint with
|
||||
method-not-found. Models a slow tsserver that hasn't re-checked
|
||||
the edited content yet — the ghost-diagnostics scenario.
|
||||
- ``"slow_push"`` — like ``stale`` on didOpen (one error) but on
|
||||
``didChange`` sleeps ``MOCK_LSP_PUSH_DELAY`` seconds (default 1.0)
|
||||
and then pushes EMPTY diagnostics. Models a server that fixes
|
||||
the ghost if you actually wait for it. Pull endpoint rejects.
|
||||
|
||||
The script writes JSON-RPC framed messages to stdout and reads from
|
||||
stdin. No third-party dependencies — uses only stdlib so it runs
|
||||
@@ -96,20 +104,47 @@ def main():
|
||||
td = params.get("textDocument") or {}
|
||||
uri = td.get("uri", "")
|
||||
version = td.get("version", 0)
|
||||
is_change = msg.get("method") == "textDocument/didChange"
|
||||
error_diag = [
|
||||
{
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 5},
|
||||
},
|
||||
"severity": 1,
|
||||
"code": "MOCK001",
|
||||
"source": "mock-lsp",
|
||||
"message": "synthetic error from mock-lsp",
|
||||
}
|
||||
]
|
||||
if script == "stale":
|
||||
# Ghost scenario: publish an error for the ORIGINAL
|
||||
# content, then never publish again after edits.
|
||||
if not is_change:
|
||||
write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/publishDiagnostics",
|
||||
"params": {"uri": uri, "version": version, "diagnostics": error_diag},
|
||||
}
|
||||
)
|
||||
continue
|
||||
if script == "slow_push":
|
||||
diagnostics = error_diag
|
||||
if is_change:
|
||||
time.sleep(float(os.environ.get("MOCK_LSP_PUSH_DELAY", "1.0")))
|
||||
diagnostics = []
|
||||
write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"method": "textDocument/publishDiagnostics",
|
||||
"params": {"uri": uri, "version": version, "diagnostics": diagnostics},
|
||||
}
|
||||
)
|
||||
continue
|
||||
diagnostics = []
|
||||
if script == "errors":
|
||||
diagnostics = [
|
||||
{
|
||||
"range": {
|
||||
"start": {"line": 0, "character": 0},
|
||||
"end": {"line": 0, "character": 5},
|
||||
},
|
||||
"severity": 1,
|
||||
"code": "MOCK001",
|
||||
"source": "mock-lsp",
|
||||
"message": "synthetic error from mock-lsp",
|
||||
}
|
||||
]
|
||||
diagnostics = error_diag
|
||||
write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
@@ -124,6 +159,17 @@ def main():
|
||||
continue
|
||||
|
||||
if msg.get("method") == "textDocument/diagnostic":
|
||||
if script in {"stale", "slow_push"}:
|
||||
# These scripts model push-only servers so the ghost
|
||||
# can't be papered over by the pull channel.
|
||||
write_message(
|
||||
{
|
||||
"jsonrpc": "2.0",
|
||||
"id": msg["id"],
|
||||
"error": {"code": -32601, "message": "method not found"},
|
||||
}
|
||||
)
|
||||
continue
|
||||
# Pull endpoint — return empty.
|
||||
write_message(
|
||||
{
|
||||
|
||||
@@ -0,0 +1,251 @@
|
||||
"""Regression tests for the "ghost diagnostics" staleness bug.
|
||||
|
||||
Scenario: the agent edits a TypeScript file, tsserver takes a long
|
||||
time to re-check it, and the old diagnostics (for the PRE-edit
|
||||
content) were reported as if they were current — the agent then
|
||||
chases errors it already fixed.
|
||||
|
||||
The contract under test:
|
||||
|
||||
- ``wait_for_diagnostics`` must NOT be satisfied by diagnostics left
|
||||
over from a previous edit cycle; it returns True only when fresh
|
||||
(post-didChange) data arrived, False on timeout.
|
||||
- ``diagnostics_for(fresh_only=True)`` must exclude stale stores.
|
||||
- ``LSPService.get_diagnostics_sync`` must return [] ("no data")
|
||||
rather than the stale diagnostics when the server never re-checks
|
||||
within the wait budget, and must NOT mark the server broken.
|
||||
- A slow-but-eventually-correct server ("slow_push") is waited on,
|
||||
honouring the configured ``lsp.wait_timeout``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from agent.lsp.client import LSPClient
|
||||
|
||||
|
||||
MOCK_SERVER = str(Path(__file__).parent / "_mock_lsp_server.py")
|
||||
|
||||
|
||||
def _client(workspace: Path, script: str, **env_extra: str) -> LSPClient:
|
||||
env = {
|
||||
"MOCK_LSP_SCRIPT": script,
|
||||
"PYTHONPATH": os.environ.get("PYTHONPATH", ""),
|
||||
**env_extra,
|
||||
}
|
||||
return LSPClient(
|
||||
server_id=f"mock-{script}",
|
||||
workspace_root=str(workspace),
|
||||
command=[sys.executable, MOCK_SERVER],
|
||||
env=env,
|
||||
cwd=str(workspace),
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_push_does_not_satisfy_wait(tmp_path: Path):
|
||||
"""A push from the previous edit cycle must not end the wait early.
|
||||
|
||||
The 'stale' mock publishes an error for the original content and
|
||||
then goes silent — the wait after the edit must time out (False),
|
||||
not return instantly on the leftover push.
|
||||
"""
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("bad code\n")
|
||||
|
||||
client = _client(tmp_path, "stale")
|
||||
await client.start()
|
||||
try:
|
||||
v0 = await client.open_file(str(f), language_id="python")
|
||||
assert await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
|
||||
assert len(client.diagnostics_for(str(f))) == 1 # pre-edit error is real
|
||||
|
||||
# Fix the file. The stale server never re-checks.
|
||||
f.write_text("good code\n")
|
||||
v1 = await client.open_file(str(f), language_id="python")
|
||||
fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=1.0)
|
||||
assert fresh is False, "wait must not be satisfied by pre-edit leftovers"
|
||||
finally:
|
||||
await client.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_only_excludes_stale_stores(tmp_path: Path):
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("bad code\n")
|
||||
|
||||
client = _client(tmp_path, "stale")
|
||||
await client.start()
|
||||
try:
|
||||
v0 = await client.open_file(str(f), language_id="python")
|
||||
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
|
||||
|
||||
f.write_text("good code\n")
|
||||
await client.open_file(str(f), language_id="python")
|
||||
# Merged legacy view still exposes the leftover push...
|
||||
assert len(client.diagnostics_for(str(f))) == 1
|
||||
# ...but the fresh-only view correctly reports no verdict yet.
|
||||
assert client.diagnostics_for(str(f), fresh_only=True) == []
|
||||
finally:
|
||||
await client.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_slow_push_is_waited_for(tmp_path: Path):
|
||||
"""A server that re-checks slowly (but within budget) gets waited on,
|
||||
and the fresh (clean) result replaces the old error."""
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("bad code\n")
|
||||
|
||||
client = _client(tmp_path, "slow_push", MOCK_LSP_PUSH_DELAY="0.8")
|
||||
await client.start()
|
||||
try:
|
||||
v0 = await client.open_file(str(f), language_id="python")
|
||||
assert await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
|
||||
assert len(client.diagnostics_for(str(f), fresh_only=True)) == 1
|
||||
|
||||
f.write_text("good code\n")
|
||||
v1 = await client.open_file(str(f), language_id="python")
|
||||
fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=5.0)
|
||||
assert fresh is True, "slow push within budget must satisfy the wait"
|
||||
assert client.diagnostics_for(str(f), fresh_only=True) == []
|
||||
finally:
|
||||
await client.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_wait_timeout_param_overrides_mode_budget(tmp_path: Path):
|
||||
"""The explicit timeout must control the wait budget (config plumb)."""
|
||||
import asyncio
|
||||
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("bad code\n")
|
||||
|
||||
client = _client(tmp_path, "stale")
|
||||
await client.start()
|
||||
try:
|
||||
v0 = await client.open_file(str(f), language_id="python")
|
||||
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
|
||||
f.write_text("good code\n")
|
||||
v1 = await client.open_file(str(f), language_id="python")
|
||||
|
||||
loop = asyncio.get_event_loop()
|
||||
start = loop.time()
|
||||
fresh = await client.wait_for_diagnostics(str(f), v1, mode="document", timeout=0.5)
|
||||
elapsed = loop.time() - start
|
||||
assert fresh is False
|
||||
# Must respect ~0.5s, not the 5s document default.
|
||||
assert elapsed < 3.0
|
||||
finally:
|
||||
await client.shutdown()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_stale_pull_result_dropped_when_change_races(tmp_path: Path):
|
||||
"""A pull answered for pre-edit content must not read as fresh after
|
||||
a didChange raced past it (version-tag anchoring)."""
|
||||
f = tmp_path / "x.py"
|
||||
f.write_text("bad code\n")
|
||||
|
||||
client = _client(tmp_path, "clean")
|
||||
await client.start()
|
||||
try:
|
||||
v0 = await client.open_file(str(f), language_id="python")
|
||||
await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0)
|
||||
doc = client._docs[os.path.abspath(str(f))]
|
||||
assert doc.fresh_pull()
|
||||
|
||||
# Simulate an edit racing in: the version bump invalidates the
|
||||
# stored pull without any explicit clearing.
|
||||
f.write_text("good code\n")
|
||||
await client.open_file(str(f), language_id="python")
|
||||
assert not doc.fresh_pull()
|
||||
assert client.diagnostics_for(str(f), fresh_only=True) == []
|
||||
finally:
|
||||
await client.shutdown()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Service-level: stale data must surface as "no data", never as errors
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _install_mock_server(script: str, server_id: str = "pyright"):
|
||||
"""Replace one registered server with a wrapper spawning the mock.
|
||||
|
||||
Mirrors the helper in test_service.py — reuse pyright so .py files
|
||||
route to the mock without a real toolchain.
|
||||
"""
|
||||
from agent.lsp.servers import SERVERS, ServerContext, ServerDef, SpawnSpec
|
||||
|
||||
target_index = next(i for i, s in enumerate(SERVERS) if s.server_id == server_id)
|
||||
original = SERVERS[target_index]
|
||||
|
||||
def _spawn(root: str, ctx: ServerContext) -> SpawnSpec:
|
||||
return SpawnSpec(
|
||||
command=[sys.executable, MOCK_SERVER],
|
||||
workspace_root=root,
|
||||
cwd=root,
|
||||
env={"MOCK_LSP_SCRIPT": script},
|
||||
initialization_options={},
|
||||
)
|
||||
|
||||
SERVERS[target_index] = ServerDef(
|
||||
server_id=server_id,
|
||||
extensions=original.extensions,
|
||||
resolve_root=lambda fp, ws: ws,
|
||||
build_spawn=_spawn,
|
||||
seed_first_push=False,
|
||||
description="mock " + server_id,
|
||||
)
|
||||
return target_index, original
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def stale_repo(monkeypatch, tmp_path):
|
||||
repo = tmp_path / "repo"
|
||||
repo.mkdir()
|
||||
(repo / ".git").mkdir()
|
||||
(repo / "pyproject.toml").write_text("")
|
||||
monkeypatch.chdir(str(repo))
|
||||
idx, original = _install_mock_server("stale")
|
||||
yield repo
|
||||
from agent.lsp.servers import SERVERS
|
||||
|
||||
SERVERS[idx] = original
|
||||
|
||||
|
||||
def test_service_reports_no_data_not_stale_errors(stale_repo):
|
||||
"""When the server never re-checks the edited content in budget,
|
||||
get_diagnostics_sync must return [] and keep the server usable."""
|
||||
from agent.lsp.manager import LSPService
|
||||
|
||||
f = stale_repo / "x.py"
|
||||
f.write_text("bad code\n")
|
||||
|
||||
svc = LSPService(
|
||||
enabled=True,
|
||||
wait_mode="document",
|
||||
wait_timeout=1.0,
|
||||
install_strategy="manual",
|
||||
)
|
||||
try:
|
||||
# First contact: didOpen gets the (real) pre-edit error push.
|
||||
first = svc.get_diagnostics_sync(str(f), delta=False)
|
||||
assert len(first) == 1
|
||||
|
||||
# Edit the file — mock never re-publishes (slow tsserver model).
|
||||
f.write_text("good code\n")
|
||||
ghost = svc.get_diagnostics_sync(str(f), delta=False)
|
||||
assert ghost == [], "stale pre-edit error must not be reported as current"
|
||||
|
||||
# Not marked broken: slow is not dead.
|
||||
assert svc.enabled_for(str(f))
|
||||
status = svc.get_status()
|
||||
assert status["broken"] == []
|
||||
finally:
|
||||
svc.shutdown()
|
||||
@@ -1631,6 +1631,22 @@ class TestBuildAnthropicKwargs:
|
||||
assert _supports_xhigh_effort(m) is True, m
|
||||
assert _forbids_sampling_params(m) is False, m
|
||||
|
||||
def test_bare_k3_coding_plan_slug_is_kimi_family(self):
|
||||
"""Kimi Coding Plan serves K3 as the bare slug ``k3`` — it must be
|
||||
classified as Kimi family (adaptive thinking) even on proxied
|
||||
endpoints where only the model name is available. Lookalike
|
||||
non-Kimi names must NOT match the exact-slug rule."""
|
||||
from agent.anthropic_adapter import (
|
||||
_model_name_is_kimi_family,
|
||||
_supports_adaptive_thinking,
|
||||
)
|
||||
for m in ("k3", "K3", "moonshotai/k3", "k3.1-preview", "k3-turbo"):
|
||||
assert _model_name_is_kimi_family(m) is True, m
|
||||
assert _supports_adaptive_thinking("k3") is True
|
||||
# Prefix-lookalikes without a separator must not be swept in.
|
||||
for m in ("k30", "k3000-chat", "keras-3"):
|
||||
assert _model_name_is_kimi_family(m) is False, m
|
||||
|
||||
def test_fast_mode_omitted_for_unsupported_model(self):
|
||||
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
|
||||
kwargs = build_anthropic_kwargs(
|
||||
|
||||
@@ -12,7 +12,7 @@ MOONSHOT = "https://api.moonshot.cn/anthropic"
|
||||
DEEPSEEK = "https://api.deepseek.com/anthropic"
|
||||
|
||||
|
||||
def _thinking_on_replay(base_url, signature=SIG):
|
||||
def _thinking_on_replay(base_url, signature=SIG, model="k3"):
|
||||
"""Normalize a thinking+text turn, store it, convert to the next-turn request,
|
||||
and return its thinking blocks."""
|
||||
response = SimpleNamespace(
|
||||
@@ -34,7 +34,7 @@ def _thinking_on_replay(base_url, signature=SIG):
|
||||
stored,
|
||||
{"role": "user", "content": "q2"},
|
||||
]
|
||||
_sys, out = convert_messages_to_anthropic(messages, base_url=base_url, model="k3")
|
||||
_sys, out = convert_messages_to_anthropic(messages, base_url=base_url, model=model)
|
||||
assistant = [m for m in out if m.get("role") == "assistant"][0]
|
||||
return [b for b in assistant["content"] if isinstance(b, dict) and b.get("type") == "thinking"]
|
||||
|
||||
@@ -54,7 +54,21 @@ def test_moonshot_keeps_signed_thinking():
|
||||
|
||||
|
||||
def test_deepseek_still_strips_signed_thinking():
|
||||
assert not _thinking_on_replay(DEEPSEEK)
|
||||
# A DeepSeek model on the DeepSeek Anthropic endpoint must strip signed
|
||||
# thinking on replay. (The model must be a real DeepSeek slug: the bare
|
||||
# ``k3`` slug is now classified as Kimi family, and a Kimi-family MODEL
|
||||
# name deliberately preserves thinking regardless of gateway hostname —
|
||||
# the proxied-endpoint path, see _is_kimi_family_endpoint.)
|
||||
assert not _thinking_on_replay(DEEPSEEK, model="deepseek-reasoner")
|
||||
|
||||
|
||||
def test_kimi_model_name_on_foreign_gateway_keeps_thinking():
|
||||
"""A Kimi-family model slug replayed through a non-Kimi gateway hostname
|
||||
keeps its thinking blocks — upstream Kimi still enforces its replay
|
||||
semantics no matter what host fronts it (hermes-agent#13848, #17057).
|
||||
Covers both the named and bare Coding Plan slugs."""
|
||||
for model in ("kimi-k2.5", "k3"):
|
||||
assert _thinking_on_replay(DEEPSEEK, model=model), model
|
||||
|
||||
|
||||
def test_direct_anthropic_keeps_signed_on_latest():
|
||||
|
||||
@@ -175,7 +175,15 @@ class TestEstimateRequestTokensRough:
|
||||
|
||||
class TestDefaultContextLengths:
|
||||
def test_k3_context_is_scoped_to_confirmed_coding_endpoint(self):
|
||||
"""K3's 1 Mi context must not leak to unverified Moonshot endpoints."""
|
||||
"""The bare ``k3`` slug's 1 Mi context must not leak to unverified endpoints.
|
||||
|
||||
The named ``kimi-k3`` / ``kimi-k3-cot`` slugs resolve to 1 Mi
|
||||
EVERYWHERE via DEFAULT_CONTEXT_LENGTHS — the window is a property of
|
||||
the model, served at 1M on api.moonshot.ai and api.moonshot.cn alike
|
||||
(verified against models.dev + OpenRouter live metadata). Only the
|
||||
bare ``k3`` slug, which exists solely on the Kimi Coding Plan
|
||||
endpoint, stays endpoint-scoped.
|
||||
"""
|
||||
with patch("agent.model_metadata.get_cached_context_length", return_value=None), \
|
||||
patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
@@ -199,14 +207,22 @@ class TestDefaultContextLengths:
|
||||
)
|
||||
|
||||
for base_url in accepted_urls:
|
||||
assert get_model_context_length(
|
||||
"k3", provider="kimi-coding", base_url=base_url
|
||||
) == 1_048_576
|
||||
for model in ("k3", "kimi-k3", "kimi-k3-cot"):
|
||||
assert get_model_context_length(
|
||||
model, provider="kimi-coding", base_url=base_url
|
||||
) == 1_048_576
|
||||
|
||||
for base_url in rejected_urls:
|
||||
# Bare slug: endpoint-scoped, must NOT leak off-endpoint.
|
||||
assert get_model_context_length(
|
||||
"k3", provider="kimi-coding", base_url=base_url
|
||||
) != 1_048_576
|
||||
# Named slugs: global DEFAULT_CONTEXT_LENGTHS entry applies
|
||||
# everywhere the model is actually named kimi-k3.
|
||||
for model in ("kimi-k3", "kimi-k3-cot"):
|
||||
assert get_model_context_length(
|
||||
model, provider="kimi-coding", base_url=base_url
|
||||
) == 1_048_576
|
||||
|
||||
def test_grok_substring_matching(self):
|
||||
# Longest-first substring matching must resolve the real xAI model
|
||||
@@ -330,6 +346,32 @@ class TestDefaultContextLengths:
|
||||
assert get_model_context_length("glm-5") == 202752
|
||||
assert get_model_context_length("glm-5.1") == 202752
|
||||
|
||||
def test_kimi_k3_context_1m(self):
|
||||
"""Kimi K3 must resolve to 1M, not the generic Kimi fallback of 256K.
|
||||
|
||||
Context window verified against models.dev and OpenRouter live
|
||||
metadata (1,048,576; 2026-07). Kimi K3 is the current flagship model
|
||||
with a 1M-token context window — the value matches the
|
||||
endpoint-scoped override in _endpoint_scoped_context_length.
|
||||
"""
|
||||
from agent.model_metadata import get_model_context_length
|
||||
from unittest.mock import patch as mock_patch
|
||||
|
||||
assert DEFAULT_CONTEXT_LENGTHS["kimi-k3"] == 1_048_576
|
||||
assert DEFAULT_CONTEXT_LENGTHS["kimi"] == 262144
|
||||
|
||||
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value={}), \
|
||||
mock_patch("agent.model_metadata.fetch_endpoint_model_metadata", return_value={}), \
|
||||
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None):
|
||||
# Kimi K3 (1M) must NOT fall through to the generic 256K entry
|
||||
assert get_model_context_length("kimi-k3") == 1_048_576
|
||||
# Vendor-prefixed forms (kimi provider, openrouter)
|
||||
assert get_model_context_length("kimi/kimi-k3") == 1_048_576
|
||||
assert get_model_context_length("moonshotai/kimi-k3") == 1_048_576
|
||||
# Older/unknown Kimi models still resolve to 256K fallback
|
||||
assert get_model_context_length("kimi-k2.6") == 262144
|
||||
assert get_model_context_length("kimi-k2") == 262144
|
||||
|
||||
def test_openrouter_live_metadata_beats_hardcoded_catchall(self):
|
||||
"""OpenRouter-routed slugs resolve via the live OR catalog before the
|
||||
hardcoded family catch-all.
|
||||
|
||||
@@ -29,6 +29,10 @@ class TestMoonshotModelDetection:
|
||||
[
|
||||
"kimi-k2.6",
|
||||
"kimi-k2-thinking",
|
||||
"k3",
|
||||
"K3",
|
||||
"moonshotai/k3",
|
||||
"k3.1-preview",
|
||||
"moonshotai/Kimi-K2.6",
|
||||
"moonshotai/kimi-k2.6",
|
||||
"nous/moonshotai/kimi-k2.6",
|
||||
@@ -184,9 +188,10 @@ class TestTopLevelGuarantees:
|
||||
"""The returned top-level schema is always a well-formed object."""
|
||||
|
||||
def test_non_dict_input_returns_empty_object(self):
|
||||
assert sanitize_moonshot_tool_parameters(None) == {"type": "object", "properties": {}}
|
||||
assert sanitize_moonshot_tool_parameters("garbage") == {"type": "object", "properties": {}}
|
||||
assert sanitize_moonshot_tool_parameters([]) == {"type": "object", "properties": {}}
|
||||
empty = {"type": "object", "properties": {}, "required": []}
|
||||
assert sanitize_moonshot_tool_parameters(None) == empty
|
||||
assert sanitize_moonshot_tool_parameters("garbage") == empty
|
||||
assert sanitize_moonshot_tool_parameters([]) == empty
|
||||
|
||||
def test_non_object_top_level_coerced(self):
|
||||
params = {"type": "string"}
|
||||
@@ -208,6 +213,66 @@ class TestTopLevelGuarantees:
|
||||
assert "type" not in params["properties"]["q"]
|
||||
|
||||
|
||||
class TestRequiredArray:
|
||||
"""Rule 4: every object schema must carry a ``required`` array (#66835)."""
|
||||
|
||||
def test_empty_object_gets_empty_required(self):
|
||||
out = sanitize_moonshot_tool_parameters({"type": "object", "properties": {}})
|
||||
assert out["required"] == []
|
||||
|
||||
def test_object_with_only_optional_props_gets_empty_required(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["required"] == []
|
||||
|
||||
def test_existing_required_preserved(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
"required": ["q"],
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["required"] == ["q"]
|
||||
|
||||
def test_dangling_required_pruned(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {"q": {"type": "string"}},
|
||||
"required": ["q", "ghost"],
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["required"] == ["q"]
|
||||
|
||||
def test_non_list_required_replaced(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {},
|
||||
"required": "q", # invalid: string, not array
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["required"] == []
|
||||
|
||||
def test_nested_object_property_gets_required(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"filter": {"type": "object", "properties": {}},
|
||||
},
|
||||
}
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
assert out["properties"]["filter"]["required"] == []
|
||||
assert out["required"] == []
|
||||
|
||||
def test_coerced_top_level_gets_required(self):
|
||||
# A non-object top level is forced to object and must gain required.
|
||||
out = sanitize_moonshot_tool_parameters({"type": "string"})
|
||||
assert out["type"] == "object"
|
||||
assert out["required"] == []
|
||||
|
||||
|
||||
class TestToolListSanitizer:
|
||||
"""sanitize_moonshot_tools() walks an OpenAI-format tool list."""
|
||||
|
||||
@@ -235,8 +300,10 @@ class TestToolListSanitizer:
|
||||
]
|
||||
out = sanitize_moonshot_tools(tools)
|
||||
assert out[0]["function"]["parameters"]["properties"]["q"]["type"] == "string"
|
||||
# Second tool already clean — should be structurally equivalent
|
||||
assert out[1]["function"]["parameters"] == {"type": "object", "properties": {}}
|
||||
# Second tool: empty object gains the required-array Moonshot demands
|
||||
assert out[1]["function"]["parameters"] == {
|
||||
"type": "object", "properties": {}, "required": []
|
||||
}
|
||||
|
||||
def test_empty_list_is_passthrough(self):
|
||||
assert sanitize_moonshot_tools([]) == []
|
||||
|
||||
@@ -1169,6 +1169,11 @@ class TestPromptBuilderConstants:
|
||||
assert "Matrix" in hint
|
||||
assert "MEDIA:" in hint
|
||||
assert "Markdown" in hint
|
||||
# Regression (#52552): the hint must steer models away from Markdown
|
||||
# tables — popular Matrix clients don't render HTML tables and the
|
||||
# cells collapse into one continuous line.
|
||||
assert "table" in hint.lower()
|
||||
assert "Do NOT use Markdown tables" in hint
|
||||
|
||||
def test_platform_hints_feishu(self):
|
||||
hint = PLATFORM_HINTS["feishu"]
|
||||
|
||||
@@ -747,6 +747,28 @@ class TestChatCompletionsKimi:
|
||||
)
|
||||
assert kw["tools"][0]["function"]["parameters"]["properties"]["q"]["type"] == "string"
|
||||
|
||||
def test_moonshot_outgoing_schema_carries_required_array(self, transport):
|
||||
"""Moonshot 400s on object schemas without an explicit `required` array
|
||||
(#66835). Assert the wire-level tool schema — what actually leaves the
|
||||
transport — carries `required: []` on a zero-required-param tool."""
|
||||
tools = [
|
||||
{
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "browser_snapshot",
|
||||
"description": "Snapshot",
|
||||
"parameters": {"type": "object", "properties": {}},
|
||||
},
|
||||
},
|
||||
]
|
||||
kw = transport.build_kwargs(
|
||||
model="moonshotai/kimi-k3",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
tools=tools,
|
||||
max_tokens_param_fn=lambda n: {"max_tokens": n},
|
||||
)
|
||||
assert kw["tools"][0]["function"]["parameters"]["required"] == []
|
||||
|
||||
def test_non_moonshot_tools_are_not_mutated(self, transport):
|
||||
"""Other models don't go through the Moonshot sanitizer."""
|
||||
original_params = {
|
||||
|
||||
@@ -2690,6 +2690,127 @@ class TestAdapterBehavior(unittest.TestCase):
|
||||
[[{"tag": "md", "text": "---\n1. 第一项\n 2. 子项\n- 外层\n - 内层\n<u>下划线</u> 和 ~~删除线~~"}]],
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_build_outbound_payload_uses_post_for_markdown_table(self):
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
content = "| Name | Score |\n| --- | ---: |\n| Ada | 10 |"
|
||||
|
||||
msg_type, raw_payload = adapter._build_outbound_payload(content)
|
||||
|
||||
self.assertEqual(msg_type, "post")
|
||||
payload = json.loads(raw_payload)
|
||||
self.assertEqual(
|
||||
payload["zh_cn"]["content"],
|
||||
[[{"tag": "md", "text": content}]],
|
||||
)
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_send_uses_post_for_every_chunk_of_multi_chunk_markdown(self):
|
||||
"""Regression for #26841: when a long Markdown message is split
|
||||
across multiple chunks, every chunk must go out as
|
||||
``msg_type=post`` — including chunk 1. The bug was that the
|
||||
first chunk often had only plain prose (the per-chunk regex
|
||||
didn't match) and was sent as ``text``, so users saw literal
|
||||
``**bold``/``## heading``/code fences while later chunks
|
||||
rendered correctly.
|
||||
"""
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
captured = []
|
||||
|
||||
class _MessageAPI:
|
||||
def create(self, request):
|
||||
captured.append(request)
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(
|
||||
message_id=f"om_chunk_{len(captured)}",
|
||||
),
|
||||
)
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(
|
||||
v1=SimpleNamespace(
|
||||
message=_MessageAPI(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
# Force a deterministic split so the test doesn't depend on the
|
||||
# exact 8000-char limit. Chunk 1 is plain prose; chunk 2 has
|
||||
# the markdown markers. Without the fix, chunk 1 went out as
|
||||
# ``msg_type=text``.
|
||||
first_chunk = "Here is a short intro that has no markdown markers at all."
|
||||
second_chunk = "## Heading\nAnd then some **bold** text."
|
||||
|
||||
with patch.object(
|
||||
adapter, "truncate_message", return_value=[first_chunk, second_chunk],
|
||||
), patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(
|
||||
adapter.send(
|
||||
chat_id="oc_chat",
|
||||
content=first_chunk + "\n" + second_chunk,
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(len(captured), 2)
|
||||
msg_types = [r.request_body.msg_type for r in captured]
|
||||
self.assertEqual(msg_types, ["post", "post"])
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_send_plain_text_message_not_upgraded_by_prefer_post(self):
|
||||
"""A message with no markdown at all must still go out as plain
|
||||
``msg_type=text`` — the whole-message ``prefer_post`` decision
|
||||
only flips on when the formatted message matches the hint regex.
|
||||
"""
|
||||
from gateway.config import PlatformConfig
|
||||
from plugins.platforms.feishu.adapter import FeishuAdapter
|
||||
|
||||
adapter = FeishuAdapter(PlatformConfig())
|
||||
captured = []
|
||||
|
||||
class _MessageAPI:
|
||||
def create(self, request):
|
||||
captured.append(request)
|
||||
return SimpleNamespace(
|
||||
success=lambda: True,
|
||||
data=SimpleNamespace(
|
||||
message_id=f"om_chunk_{len(captured)}",
|
||||
),
|
||||
)
|
||||
|
||||
adapter._client = SimpleNamespace(
|
||||
im=SimpleNamespace(
|
||||
v1=SimpleNamespace(
|
||||
message=_MessageAPI(),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
async def _direct(func, *args, **kwargs):
|
||||
return func(*args, **kwargs)
|
||||
|
||||
with patch("plugins.platforms.feishu.adapter.asyncio.to_thread", side_effect=_direct):
|
||||
result = asyncio.run(
|
||||
adapter.send(
|
||||
chat_id="oc_chat",
|
||||
content="just a plain sentence",
|
||||
)
|
||||
)
|
||||
|
||||
self.assertTrue(result.success)
|
||||
self.assertEqual(len(captured), 1)
|
||||
self.assertEqual(captured[0].request_body.msg_type, "text")
|
||||
|
||||
@patch.dict(os.environ, {}, clear=True)
|
||||
def test_send_uses_post_for_inline_markdown(self):
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
@@ -0,0 +1,133 @@
|
||||
"""Tests for Feishu adapter outbound markdown payload construction.
|
||||
|
||||
Reproduces the bug tracked in hermes-agent issue #52786:
|
||||
`_build_outbound_payload` was force-downgrading any message containing a
|
||||
markdown pipe table to ``msg_type=text``, so Feishu clients rendered the raw
|
||||
pipe-and-dash source instead of a table. Empirically current Feishu clients
|
||||
render ``post``+``md`` tables natively, so the downgrade branch must be removed.
|
||||
|
||||
These tests guard the fix. They invoke the real adapter via the project's
|
||||
plugin-loader helper so that no ``sys.path`` / ``sys.modules`` games are
|
||||
needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
|
||||
from tests.gateway._plugin_adapter_loader import load_plugin_adapter
|
||||
|
||||
_adapter = load_plugin_adapter("feishu")
|
||||
|
||||
|
||||
def _call_build_outbound_payload(content: str) -> tuple[str, str]:
|
||||
"""Invoke ``_build_outbound_payload`` on a bare adapter instance.
|
||||
|
||||
``_build_outbound_payload`` is a method that only uses module-level
|
||||
helpers (``_MARKDOWN_TABLE_RE``, ``_MARKDOWN_HINT_RE``,
|
||||
``_build_markdown_post_payload``) and never touches ``self.*``, so a bare
|
||||
object is sufficient.
|
||||
"""
|
||||
inst = object.__new__(_adapter.FeishuAdapter)
|
||||
return inst._build_outbound_payload(content)
|
||||
|
||||
|
||||
def _md_texts_from_post_payload(payload_str: str) -> list[str]:
|
||||
"""Pull every ``{tag:'md', text:'...'}`` element out of a Feishu post payload.
|
||||
|
||||
Real payload shape::
|
||||
|
||||
{"zh_cn": {"content": [[{"tag": "md", "text": "..."}], ...]}}
|
||||
|
||||
Helpers and tests need to introspect the ``md`` blocks regardless of
|
||||
locale, so we walk the structure generically.
|
||||
"""
|
||||
payload = json.loads(payload_str)
|
||||
if not isinstance(payload, dict):
|
||||
return []
|
||||
texts: list[str] = []
|
||||
for lang_val in payload.values():
|
||||
if not isinstance(lang_val, dict):
|
||||
continue
|
||||
content = lang_val.get("content", [])
|
||||
if not isinstance(content, list):
|
||||
continue
|
||||
for block in content:
|
||||
if isinstance(block, list):
|
||||
candidates = block
|
||||
else:
|
||||
candidates = [block]
|
||||
for el in candidates:
|
||||
if isinstance(el, dict) and el.get("tag") == "md":
|
||||
texts.append(el.get("text", ""))
|
||||
return texts
|
||||
|
||||
|
||||
def test_markdown_table_uses_post_not_text():
|
||||
"""Regression test for issue #52786 (and its older sibling #23938).
|
||||
|
||||
A message whose only markdown is a table must take the ``post`` path,
|
||||
not be downgraded to plain text.
|
||||
"""
|
||||
content = (
|
||||
"| col A | col B |\n"
|
||||
"| ----- | ----- |\n"
|
||||
"| 1 | 2 |"
|
||||
)
|
||||
msg_type, payload_str = _call_build_outbound_payload(content)
|
||||
assert msg_type == "post", (
|
||||
f"expected 'post' for a markdown table (issue #52786), got {msg_type!r}; "
|
||||
"the table-downgrade branch in _build_outbound_payload has been re-introduced"
|
||||
)
|
||||
md_texts = _md_texts_from_post_payload(payload_str)
|
||||
assert md_texts, f"post payload must include at least one md element; got {payload_str!r}"
|
||||
joined = "".join(md_texts)
|
||||
assert "col A" in joined and "|" in joined, (
|
||||
"table text was lost or reformatted when switching from text to post"
|
||||
)
|
||||
|
||||
|
||||
def test_plain_text_without_markdown_still_uses_text():
|
||||
"""Negative control: a message with no markdown hints and no table must
|
||||
still go to plain text. Guards against accidentally promoting everything
|
||||
to ``post``."""
|
||||
msg_type, _ = _call_build_outbound_payload("just a plain sentence with no markup")
|
||||
assert msg_type == "text"
|
||||
|
||||
|
||||
def test_existing_markdown_heading_still_uses_post():
|
||||
"""Sanity: the existing ``post`` path (heading / list / code / bold /
|
||||
link) must still work after the table downgrade is removed."""
|
||||
msg_type, payload_str = _call_build_outbound_payload("# hello world\n")
|
||||
assert msg_type == "post"
|
||||
md_texts = _md_texts_from_post_payload(payload_str)
|
||||
assert md_texts, f"expected at least one md element; got {payload_str!r}"
|
||||
assert any("hello world" in t for t in md_texts), (
|
||||
f"expected 'hello world' in md elements; got {md_texts!r}"
|
||||
)
|
||||
|
||||
|
||||
def test_table_combined_with_other_markdown_does_not_downgrade():
|
||||
"""A message that mixes a table with surrounding markdown must also
|
||||
take the ``post`` path.
|
||||
|
||||
The old ``_MARKDOWN_TABLE_RE`` branch returned ``text`` unconditionally
|
||||
and stripped all the surrounding markdown formatting, so a Feishu
|
||||
reader saw literal pipes and lost the prose framing the table.
|
||||
"""
|
||||
content = (
|
||||
"Here is the data:\n\n"
|
||||
"| col A | col B |\n"
|
||||
"| ----- | ----- |\n"
|
||||
"| 1 | 2 |\n\n"
|
||||
"Let me know."
|
||||
)
|
||||
msg_type, payload_str = _call_build_outbound_payload(content)
|
||||
assert msg_type == "post"
|
||||
md_texts = _md_texts_from_post_payload(payload_str)
|
||||
joined = "\n".join(md_texts)
|
||||
assert "Here is the data" in joined, (
|
||||
"leading prose was lost when downgrading a mixed-table message"
|
||||
)
|
||||
assert "col A" in joined, "table header was lost"
|
||||
assert "Let me know" in joined, "trailing prose was lost"
|
||||
@@ -1033,7 +1033,10 @@ class TestMatrixRenderingPayloads:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_response_split_preserves_thread_context(self):
|
||||
long_text = "Intro\n```python\n" + ("print('hello')\n" * 500) + "```\nDone"
|
||||
# Build a payload guaranteed to exceed the adapter's outbound chunk
|
||||
# size (configurable since #53026) so send() must split it.
|
||||
repeats = (self.adapter.max_message_length // 15) + 200
|
||||
long_text = "Intro\n```python\n" + ("print('hello')\n" * repeats) + "```\nDone"
|
||||
|
||||
result = await self.adapter.send(
|
||||
"!room:example.org",
|
||||
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Tests for Matrix outbound message length configuration (#53026)."""
|
||||
import asyncio
|
||||
import os
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.config import PlatformConfig
|
||||
|
||||
|
||||
def _make_adapter(**extra):
|
||||
from plugins.platforms.matrix.adapter import MatrixAdapter
|
||||
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="syt_test_token",
|
||||
extra={
|
||||
"homeserver": "https://matrix.example.org",
|
||||
"user_id": "@bot:example.org",
|
||||
**extra,
|
||||
},
|
||||
)
|
||||
return MatrixAdapter(config)
|
||||
|
||||
|
||||
class TestMatrixMaxMessageLength:
|
||||
def test_default_limit_is_16000(self):
|
||||
adapter = _make_adapter()
|
||||
assert adapter.max_message_length == 16000
|
||||
assert adapter._split_threshold == 15900
|
||||
|
||||
def test_extra_override(self):
|
||||
adapter = _make_adapter(max_message_length=12000)
|
||||
assert adapter.max_message_length == 12000
|
||||
assert adapter._split_threshold == 11900
|
||||
|
||||
def test_env_override(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000")
|
||||
adapter = _make_adapter()
|
||||
assert adapter.max_message_length == 20000
|
||||
|
||||
def test_extra_beats_env(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "20000")
|
||||
adapter = _make_adapter(max_message_length=10000)
|
||||
assert adapter.max_message_length == 10000
|
||||
|
||||
def test_invalid_values_fall_back_to_default(self, monkeypatch):
|
||||
monkeypatch.setenv("MATRIX_MAX_MESSAGE_LENGTH", "not-a-number")
|
||||
adapter = _make_adapter()
|
||||
assert adapter.max_message_length == 16000
|
||||
|
||||
def test_values_are_clamped(self):
|
||||
adapter = _make_adapter(max_message_length=100)
|
||||
assert adapter.max_message_length == 500
|
||||
adapter = _make_adapter(max_message_length=999999)
|
||||
assert adapter.max_message_length == 65535
|
||||
|
||||
def test_apply_yaml_config_sets_env(self, monkeypatch):
|
||||
from plugins.platforms.matrix.adapter import _apply_yaml_config
|
||||
|
||||
monkeypatch.delenv("MATRIX_MAX_MESSAGE_LENGTH", raising=False)
|
||||
_apply_yaml_config({}, {"max_message_length": 12000})
|
||||
assert os.getenv("MATRIX_MAX_MESSAGE_LENGTH") == "12000"
|
||||
|
||||
def test_register_uses_default_limit(self):
|
||||
from plugins.platforms.matrix.adapter import DEFAULT_MAX_MESSAGE_LENGTH, register
|
||||
|
||||
ctx = MagicMock()
|
||||
register(ctx)
|
||||
kwargs = ctx.register_platform.call_args[1]
|
||||
assert kwargs["max_message_length"] == DEFAULT_MAX_MESSAGE_LENGTH
|
||||
|
||||
def test_send_uses_configured_limit(self):
|
||||
adapter = _make_adapter(max_message_length=5000)
|
||||
adapter._client = MagicMock()
|
||||
adapter._client.send_message_event = AsyncMock(return_value="evt")
|
||||
long_text = "x" * 12000
|
||||
|
||||
async def _run():
|
||||
with patch.object(adapter, "truncate_message", wraps=adapter.truncate_message) as trunc:
|
||||
await adapter.send("!room:example.org", long_text)
|
||||
trunc.assert_called_once()
|
||||
assert trunc.call_args[0][1] == 5000
|
||||
|
||||
asyncio.run(_run())
|
||||
@@ -278,9 +278,9 @@ class TestMatrixTextBatching:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_adaptive_delay_for_near_limit_chunk(self):
|
||||
"""Chunks near the 4000-char limit should trigger longer delay."""
|
||||
"""Chunks near the outbound limit should trigger longer delay."""
|
||||
adapter = _make_matrix_adapter()
|
||||
long_text = "x" * 3950
|
||||
long_text = "x" * (adapter._split_threshold + 50)
|
||||
adapter._enqueue_text_event(_make_event(long_text, Platform.MATRIX))
|
||||
|
||||
await asyncio.sleep(0.15)
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Test that kimi-coding and kimi-coding-cn both appear in the /model picker.
|
||||
|
||||
Both providers share the same models.dev ID (kimi-for-coding) but are distinct
|
||||
profiles with different API keys, base URLs, and endpoints. The /model picker
|
||||
must show both so users can pick the right endpoint for their key type.
|
||||
|
||||
Regression: the original ``seen_mdev_ids`` dedup by mdev_id alone would skip
|
||||
kimi-coding-cn after kimi-coding was emitted because both map to
|
||||
``kimi-for-coding`` (#10526). The fix deduplicates by
|
||||
``(mdev_id, canonical_profile_name)`` instead, allowing distinct profiles
|
||||
through.
|
||||
"""
|
||||
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
from hermes_cli.model_switch import (
|
||||
list_authenticated_providers,
|
||||
parse_model_flags,
|
||||
switch_model,
|
||||
)
|
||||
from hermes_cli.providers import resolve_provider_full
|
||||
|
||||
|
||||
# -- Only KIMI_CN_API_KEY set ------------------------------------------------
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"KIMI_CN_API_KEY": "sk-cn-fake"}, clear=False)
|
||||
def test_kimi_cn_appears_when_only_cn_key_set():
|
||||
"""kimi-coding-cn should appear when only KIMI_CN_API_KEY is set."""
|
||||
providers = list_authenticated_providers(current_provider="kimi-coding-cn")
|
||||
|
||||
# kimi-coding-cn must be listed (it has credentials)
|
||||
cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
|
||||
assert cn is not None, (
|
||||
"kimi-coding-cn should appear when KIMI_CN_API_KEY is set"
|
||||
)
|
||||
assert cn["is_current"] is True
|
||||
assert cn["total_models"] > 0
|
||||
|
||||
# kimi-coding must NOT appear (no KIMI_API_KEY)
|
||||
intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
|
||||
assert intl is None, (
|
||||
"kimi-coding should NOT appear when only KIMI_CN_API_KEY is set"
|
||||
)
|
||||
|
||||
|
||||
# -- Only KIMI_API_KEY set ---------------------------------------------------
|
||||
|
||||
|
||||
@patch.dict(os.environ, {"KIMI_API_KEY": "sk-intl-fake"}, clear=False)
|
||||
def test_kimi_intl_appears_when_only_intl_key_set():
|
||||
"""kimi-coding (international) should appear when only KIMI_API_KEY is set."""
|
||||
providers = list_authenticated_providers(current_provider="kimi-coding")
|
||||
|
||||
intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
|
||||
assert intl is not None, (
|
||||
"kimi-coding should appear when KIMI_API_KEY is set"
|
||||
)
|
||||
assert intl["is_current"] is True
|
||||
|
||||
# kimi-coding-cn must NOT appear (no KIMI_CN_API_KEY)
|
||||
cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
|
||||
assert cn is None, (
|
||||
"kimi-coding-cn should NOT appear when only KIMI_API_KEY is set"
|
||||
)
|
||||
|
||||
|
||||
# -- Both keys set -----------------------------------------------------------
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"KIMI_API_KEY": "sk-intl-fake",
|
||||
"KIMI_CN_API_KEY": "sk-cn-fake",
|
||||
}, clear=False)
|
||||
def test_both_kimi_providers_appear_when_both_keys_set():
|
||||
"""Both kimi-coding and kimi-coding-cn should appear when both keys set.
|
||||
|
||||
They are distinct profiles with different env vars and endpoints. The
|
||||
existing aliases (kimi, moonshot → kimi-coding; kimi-cn, moonshot-cn →
|
||||
kimi-coding-cn) must NOT create additional rows.
|
||||
"""
|
||||
providers = list_authenticated_providers(current_provider="kimi-coding")
|
||||
|
||||
# Both profile slugs must appear
|
||||
intl = next((p for p in providers if p["slug"] == "kimi-coding"), None)
|
||||
assert intl is not None, "kimi-coding should appear when KIMI_API_KEY is set"
|
||||
assert intl["is_current"] is True
|
||||
|
||||
cn = next((p for p in providers if p["slug"] == "kimi-coding-cn"), None)
|
||||
assert cn is not None, (
|
||||
"kimi-coding-cn should appear when KIMI_CN_API_KEY is set"
|
||||
)
|
||||
assert cn["is_current"] is False # `current_provider` is kimi-coding
|
||||
|
||||
# Exactly 2 Kimi entries — no duplicates for aliases (kimi, moonshot,
|
||||
# moonshot-cn, kimi-cn)
|
||||
kimi_slugs = [p["slug"] for p in providers if "kimi" in p["slug"] or "moonshot" in p["slug"]]
|
||||
assert len(kimi_slugs) == 2, (
|
||||
f"Expected exactly 2 Kimi entries (kimi-coding, kimi-coding-cn), "
|
||||
f"got {kimi_slugs}"
|
||||
)
|
||||
|
||||
|
||||
# -- Both aliases deduped correctly ------------------------------------------
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"KIMI_API_KEY": "sk-intl-fake",
|
||||
"KIMI_CN_API_KEY": "sk-cn-fake",
|
||||
}, clear=False)
|
||||
def test_kimi_aliases_not_listed_separately():
|
||||
"""Alias hermes_ids (kimi, moonshot) must NOT create phantom picker rows.
|
||||
|
||||
They resolve to the same canonical profile (kimi-coding) and should be
|
||||
deduped. Only the canonical slug (kimi-coding) should appear.
|
||||
"""
|
||||
providers = list_authenticated_providers(current_provider="kimi-coding-cn")
|
||||
|
||||
slugs = {p["slug"] for p in providers}
|
||||
# These alias slugs must NOT appear
|
||||
for bad_slug in ("kimi", "moonshot", "moonshot-cn", "kimi-cn"):
|
||||
assert bad_slug not in slugs, (
|
||||
f"Alias slug '{bad_slug}' must not appear in picker (resolved to "
|
||||
f"canonical profile)"
|
||||
)
|
||||
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"KIMI_API_KEY": "sk-intl-fake",
|
||||
"KIMI_CN_API_KEY": "sk-cn-fake",
|
||||
}, clear=False)
|
||||
def test_resolve_provider_full_preserves_kimi_cn_provider_identity():
|
||||
"""Explicit kimi-coding-cn must not collapse to shared models.dev alias.
|
||||
|
||||
Regression: resolve_provider_full('kimi-coding-cn') used normalize_provider(),
|
||||
which mapped both kimi-coding and kimi-coding-cn to the models.dev alias
|
||||
'kimi-for-coding'. That silently rewired CN users to the international
|
||||
endpoint and KIMI_API_KEY.
|
||||
"""
|
||||
pdef = resolve_provider_full("kimi-coding-cn", None, None)
|
||||
assert pdef is not None
|
||||
assert pdef.id == "kimi-coding-cn"
|
||||
assert pdef.base_url == "https://api.moonshot.cn/v1"
|
||||
assert pdef.api_key_env_vars == ("KIMI_CN_API_KEY",)
|
||||
|
||||
|
||||
@patch.dict(os.environ, {
|
||||
"KIMI_API_KEY": "sk-intl-fake",
|
||||
"KIMI_CN_API_KEY": "sk-cn-fake",
|
||||
}, clear=False)
|
||||
def test_switch_model_with_explicit_kimi_cn_provider_stays_on_cn_endpoint():
|
||||
"""/model ... --provider kimi-coding-cn must stay on moonshot.cn.
|
||||
|
||||
This hits the real switch path used by gateway /model: parse flags first,
|
||||
then call switch_model() with explicit_provider. The result must not rewrite
|
||||
the target provider/base_url back to the international Kimi endpoint.
|
||||
"""
|
||||
model_input, explicit_provider, *_ = parse_model_flags(
|
||||
"kimi-k2.6 —provider kimi-coding-cn"
|
||||
)
|
||||
result = switch_model(
|
||||
raw_input=model_input,
|
||||
current_provider="deepseek",
|
||||
current_model="deepseek-v4-flash",
|
||||
current_base_url="https://api.deepseek.com/v1",
|
||||
current_api_key="***",
|
||||
is_global=False,
|
||||
explicit_provider=explicit_provider,
|
||||
user_providers={},
|
||||
custom_providers=None,
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.target_provider == "kimi-coding-cn"
|
||||
assert result.new_model == "kimi-k2.6"
|
||||
assert result.base_url == "https://api.moonshot.cn/v1"
|
||||
assert result.api_key == "sk-cn-fake"
|
||||
@@ -299,3 +299,103 @@ def test_current_custom_endpoint_passthrough_marks_current_row(monkeypatch):
|
||||
assert row["slug"] == "custom:ollama"
|
||||
assert row["is_current"] is True
|
||||
assert row["models"] == ["glm-5.1", "qwen3"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# list_authenticated_providers: alias/canonical de-dup for Kimi (#49439)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# A single Kimi credential used to surface TWO picker rows: the alias slug
|
||||
# "kimi" (emitted by the PROVIDER_TO_MODELS_DEV pass) plus its canonical
|
||||
# "kimi-coding" (re-emitted by the CANONICAL_PROVIDERS cross-check pass),
|
||||
# both backed by the same kimi-for-coding models.dev provider. The picker
|
||||
# must list each authenticated credential exactly once, under the CANONICAL
|
||||
# slug ("kimi-coding") — matching list_authenticated_providers' other alias
|
||||
# rows and the overlay slug-resolution contract (see
|
||||
# test_overlay_slug_resolution.py).
|
||||
|
||||
|
||||
def _stub_kimi_discovery(monkeypatch, *, canonical):
|
||||
"""Isolate list_authenticated_providers to the Kimi alias family.
|
||||
|
||||
Restricts the models.dev map / catalog / overlays / canonical list to
|
||||
just the Kimi entries and stubs the model-id fetch so discovery stays
|
||||
offline and deterministic. ``canonical`` is the CANONICAL_PROVIDERS list
|
||||
the 2b cross-check pass should iterate.
|
||||
"""
|
||||
import agent.models_dev as md
|
||||
import hermes_cli.models as hm
|
||||
|
||||
kimi_map = {
|
||||
"kimi": "kimi-for-coding",
|
||||
"kimi-coding": "kimi-for-coding",
|
||||
"moonshot": "kimi-for-coding",
|
||||
"kimi-coding-cn": "kimi-for-coding",
|
||||
}
|
||||
monkeypatch.setattr(md, "PROVIDER_TO_MODELS_DEV", kimi_map)
|
||||
monkeypatch.setattr(
|
||||
md, "fetch_models_dev",
|
||||
lambda *a, **k: {
|
||||
"kimi-for-coding": {"name": "Kimi For Coding", "env": ["KIMI_API_KEY"]},
|
||||
},
|
||||
)
|
||||
|
||||
class _PInfo:
|
||||
name = "Kimi For Coding"
|
||||
|
||||
monkeypatch.setattr(md, "get_provider_info", lambda _pid: _PInfo())
|
||||
monkeypatch.setattr("hermes_cli.providers.HERMES_OVERLAYS", {})
|
||||
monkeypatch.setattr(hm, "CANONICAL_PROVIDERS", canonical)
|
||||
monkeypatch.setattr(hm, "cached_provider_model_ids",
|
||||
lambda *a, **k: ["kimi-k2.6", "kimi-k2.5"])
|
||||
monkeypatch.setattr(hm, "clear_provider_models_cache", lambda *a, **k: None)
|
||||
|
||||
|
||||
def test_single_kimi_credential_yields_one_canonical_row(monkeypatch):
|
||||
"""One Kimi key yields a single row under the canonical 'kimi-coding' slug."""
|
||||
import hermes_cli.models as hm
|
||||
|
||||
_stub_kimi_discovery(
|
||||
monkeypatch,
|
||||
canonical=[hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc")],
|
||||
)
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi")
|
||||
|
||||
rows = model_switch.list_authenticated_providers(max_models=10)
|
||||
slugs = [r["slug"] for r in rows]
|
||||
|
||||
# Exactly one Kimi / kimi-for-coding-backed row, under the canonical slug —
|
||||
# not both the alias ("kimi") and its canonical ("kimi-coding").
|
||||
kimi_rows = [s for s in slugs if s in {"kimi", "kimi-coding"}]
|
||||
assert kimi_rows == ["kimi-coding"], (
|
||||
f"expected a single canonical Kimi row, got: {slugs}"
|
||||
)
|
||||
assert slugs.count("kimi-coding") == 1
|
||||
assert "kimi" not in slugs
|
||||
|
||||
|
||||
def test_distinct_kimi_china_credential_still_listed(monkeypatch):
|
||||
"""A separate China (kimi-coding-cn) credential remains its own row.
|
||||
|
||||
Negative-control guard: the de-dup must collapse only the alias/canonical
|
||||
pair that share a credential, not legitimately distinct providers.
|
||||
"""
|
||||
import hermes_cli.models as hm
|
||||
|
||||
_stub_kimi_discovery(
|
||||
monkeypatch,
|
||||
canonical=[
|
||||
hm.ProviderEntry("kimi-coding", "Kimi / Kimi Coding Plan", "desc"),
|
||||
hm.ProviderEntry("kimi-coding-cn", "Kimi / Moonshot (China)", "desc"),
|
||||
],
|
||||
)
|
||||
monkeypatch.setenv("KIMI_API_KEY", "sk-test-kimi")
|
||||
monkeypatch.setenv("KIMI_CN_API_KEY", "sk-test-kimi-cn")
|
||||
|
||||
rows = model_switch.list_authenticated_providers(max_models=10)
|
||||
slugs = [r["slug"] for r in rows]
|
||||
|
||||
assert "kimi-coding" in slugs # canonical global row
|
||||
assert slugs.count("kimi-coding") == 1
|
||||
assert "kimi" not in slugs # alias collapsed into the canonical row
|
||||
assert "kimi-coding-cn" in slugs # distinct China endpoint preserved
|
||||
|
||||
@@ -97,14 +97,15 @@ class TestProviderModelIdsPreferred:
|
||||
assert "claude-opus-4-7" in out
|
||||
assert "kimi-k2.6" in out
|
||||
|
||||
def test_kimi_coding_offline_catalog_includes_k2_7_code(self):
|
||||
"""Native Kimi users must see the newest Code model without live catalog help."""
|
||||
def test_kimi_coding_offline_catalog_includes_k3(self):
|
||||
"""Native Kimi users must see the newest models without live catalog help."""
|
||||
assert "kimi-coding" not in _MODELS_DEV_PREFERRED
|
||||
with patch("agent.models_dev.list_agentic_models", return_value=[]):
|
||||
out = provider_model_ids("kimi-coding")
|
||||
assert "kimi-k3" in out
|
||||
assert "kimi-k2.7-code" in out
|
||||
|
||||
def test_kimi_coding_live_catalog_does_not_hide_curated_k2_7_code(self):
|
||||
def test_kimi_coding_live_catalog_does_not_hide_curated_k3(self):
|
||||
"""Kimi /models can lag inference; live results must not replace curated."""
|
||||
with (
|
||||
patch(
|
||||
@@ -114,8 +115,8 @@ class TestProviderModelIdsPreferred:
|
||||
patch("providers.base.ProviderProfile.fetch_models", return_value=["kimi-k2.6"]),
|
||||
):
|
||||
out = provider_model_ids("kimi-coding")
|
||||
# Curated-first order; curated newest (k2.7-code) stays ahead of live.
|
||||
assert out[:2] == ["kimi-k2.7-code", "kimi-k2.6"]
|
||||
# Curated-first order; curated newest (k3) stays ahead of live.
|
||||
assert out[:3] == ["kimi-k3", "kimi-k2.7-code", "kimi-k2.6"]
|
||||
|
||||
def test_k3_live_discovery_is_scoped_to_kimi_coding_endpoint(self):
|
||||
"""Coding keys discover K3; legacy Moonshot keys must not advertise it."""
|
||||
@@ -171,7 +172,7 @@ class TestProviderModelIdsPreferred:
|
||||
custom_models = provider_model_ids("kimi-coding")
|
||||
|
||||
assert "k3" in coding_models
|
||||
assert coding_models[0] == "kimi-k2.7-code"
|
||||
assert coding_models[0] == "kimi-k3"
|
||||
assert all(model.lower() != "k3" for model in legacy_models)
|
||||
assert all(model.lower() != "k3" for model in custom_models)
|
||||
|
||||
@@ -194,7 +195,7 @@ class TestProviderModelIdsPreferred:
|
||||
_model_flow_kimi({}, current_model="")
|
||||
|
||||
assert captured["models"] == _PROVIDER_MODELS["kimi-coding"]
|
||||
assert captured["models"][0] == "kimi-k2.7-code"
|
||||
assert captured["models"][0] == "kimi-k3"
|
||||
|
||||
|
||||
class TestOpenRouterAndNousUnchanged:
|
||||
|
||||
@@ -1684,7 +1684,11 @@ def test_launch_tui_worktree_validates_relative_python_against_final_cwd(
|
||||
relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name
|
||||
python_path = worktree / relative_python
|
||||
python_path.parent.mkdir(parents=True)
|
||||
os.link(sys.executable, python_path)
|
||||
# copy2, not os.link: tmp_path may sit on a different filesystem than
|
||||
# the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
|
||||
import shutil
|
||||
|
||||
shutil.copy2(sys.executable, python_path)
|
||||
captured = {}
|
||||
|
||||
monkeypatch.setenv("HERMES_CWD", str(parent_cwd))
|
||||
|
||||
@@ -3989,6 +3989,18 @@ class TestWebServerEndpoints:
|
||||
assert "api_key" not in out
|
||||
assert "api_mode" not in out
|
||||
|
||||
# switching providers when the stale secret lives under the legacy
|
||||
# ``api`` alias only (no api_key) → it must be cleared too. The resolver
|
||||
# reads ``model.api`` as a key, so leaving it behind keeps a secret in
|
||||
# config.yaml that contaminates the next custom resolution.
|
||||
out = _apply_main_model_assignment(
|
||||
{"provider": "custom", "api": "sk-legacy-stale", "base_url": "http://endpoint-a/v1"},
|
||||
"openrouter",
|
||||
"m",
|
||||
)
|
||||
assert "api" not in out
|
||||
assert "api_key" not in out
|
||||
|
||||
def test_parse_model_ids_handles_openai_and_bare_shapes(self):
|
||||
"""Model discovery must tolerate the common /v1/models shapes and
|
||||
never raise (so a slightly non-standard local endpoint still works)."""
|
||||
@@ -8418,7 +8430,9 @@ class TestPtyWebSocket:
|
||||
relative_python = Path(".review-venv") / "bin" / Path(sys.executable).name
|
||||
python_path = tmp_path / relative_python
|
||||
python_path.parent.mkdir(parents=True)
|
||||
os.link(sys.executable, python_path)
|
||||
# copy2, not os.link: tmp_path may sit on a different filesystem than
|
||||
# the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
|
||||
shutil.copy2(sys.executable, python_path)
|
||||
monkeypatch.setenv("HERMES_CWD", str(tmp_path))
|
||||
monkeypatch.setenv("HERMES_PYTHON", str(relative_python))
|
||||
monkeypatch.setattr(
|
||||
@@ -8440,7 +8454,9 @@ class TestPtyWebSocket:
|
||||
bin_dir = tmp_path / "bin"
|
||||
bin_dir.mkdir()
|
||||
executable = bin_dir / command
|
||||
os.link(sys.executable, executable)
|
||||
# copy2, not os.link: tmp_path may sit on a different filesystem than
|
||||
# the venv (tmpfs /tmp vs disk home) where hard links raise EXDEV.
|
||||
shutil.copy2(sys.executable, executable)
|
||||
env = {
|
||||
"HERMES_CWD": str(tmp_path),
|
||||
"HERMES_PYTHON": command,
|
||||
|
||||
@@ -1365,6 +1365,57 @@ class TestMessageStorage:
|
||||
assert model_history == model_expected
|
||||
assert display_history == display_expected
|
||||
|
||||
def test_get_ancestor_display_prefix_single_session_returns_empty(self, db):
|
||||
"""A session with no compression ancestors has an empty prefix."""
|
||||
db.create_session("solo", "cli")
|
||||
db.append_message("solo", role="user", content="hi")
|
||||
db.append_message("solo", role="assistant", content="hello")
|
||||
|
||||
assert db.get_ancestor_display_prefix("solo") == []
|
||||
|
||||
def test_get_ancestor_display_prefix_returns_ancestor_only_messages(self, db):
|
||||
"""The prefix contains ONLY ancestor messages, not tip messages.
|
||||
|
||||
Previously the prefix was calculated as
|
||||
display_history[:len(display) - len(raw)], which overcounts when
|
||||
repair_message_sequence removes messages from the MIDDLE of the
|
||||
tip history — the length difference includes both ancestor messages
|
||||
AND repair-removed tip messages, but the slice captures the first N
|
||||
display messages (tip messages when there are no ancestors),
|
||||
causing duplication in _live_session_payload. (#65919)
|
||||
"""
|
||||
db.create_session("root", "tui")
|
||||
db.append_message("root", role="user", content="ancestor prompt")
|
||||
db.append_message("root", role="assistant", content="ancestor reply")
|
||||
db.create_session("child", "tui", parent_session_id="root")
|
||||
db.append_message("child", role="user", content="tip prompt")
|
||||
db.append_message("child", role="assistant", content="tip reply")
|
||||
# A verification candidate that repair_message_sequence collapses
|
||||
# (consecutive-assistant merge replaces it with the next assistant).
|
||||
db.append_message(
|
||||
"child",
|
||||
role="assistant",
|
||||
content="verification candidate",
|
||||
finish_reason="verification_required",
|
||||
)
|
||||
db.append_message("child", role="assistant", content="post-verification reply")
|
||||
|
||||
prefix = db.get_ancestor_display_prefix("child")
|
||||
# Only the ancestor messages, not any tip messages.
|
||||
assert len(prefix) == 2
|
||||
assert prefix[0]["role"] == "user"
|
||||
assert prefix[0]["content"] == "ancestor prompt"
|
||||
assert prefix[1]["role"] == "assistant"
|
||||
assert prefix[1]["content"] == "ancestor reply"
|
||||
|
||||
# The old broken calculation would produce a non-empty prefix
|
||||
# (because repair collapses the verification candidate, making
|
||||
# len(display) > len(raw)), even though there are 2 ancestor
|
||||
# messages — it would overcount.
|
||||
raw, display = db.get_resume_conversations("child")
|
||||
old_prefix_len = max(0, len(display) - len(raw))
|
||||
assert len(prefix) <= old_prefix_len
|
||||
|
||||
def test_finish_reason_stored(self, db):
|
||||
db.create_session(session_id="s1", source="cli")
|
||||
db.append_message("s1", role="assistant", content="Done", finish_reason="stop")
|
||||
|
||||
@@ -1418,6 +1418,9 @@ def test_session_resume_uses_parent_lineage_for_display(monkeypatch):
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
|
||||
captured.setdefault("history_calls", []).append((target, include_ancestors))
|
||||
return (
|
||||
@@ -1555,6 +1558,9 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, target, include_ancestors=False, repair_alternation=False):
|
||||
return [{"role": "user", "content": "hello"}]
|
||||
|
||||
@@ -1621,6 +1627,9 @@ def test_session_resume_profile_uses_profile_db_cwd(monkeypatch, tmp_path):
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _target, include_ancestors=False, repair_alternation=False):
|
||||
return [{"role": "user", "content": "hello"}]
|
||||
|
||||
@@ -5678,6 +5687,9 @@ def test_slash_exec_r7_read_commands_use_metadata_mirror_flag_on(monkeypatch):
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, key, include_ancestors=True, repair_alternation=False):
|
||||
assert key == "session-key"
|
||||
assert include_ancestors is True
|
||||
|
||||
@@ -609,3 +609,36 @@ class TestMasterCredentialStoresAreNeverMountable:
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(home)}):
|
||||
assert register_credential_file("../../.ssh/id_rsa") is False
|
||||
assert register_credential_file("/etc/passwd") is False
|
||||
|
||||
def test_missing_guard_fails_closed_with_error_log(self, tmp_path, caplog):
|
||||
"""If agent.file_safety can't be imported the mount is refused loudly.
|
||||
|
||||
The fail-closed path must be observable (#67665): a silent deny with
|
||||
no diagnostic reproduces the trust gap the deny-list was added to fix.
|
||||
"""
|
||||
import tools.credential_files as cf
|
||||
|
||||
home = self._home(tmp_path)
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(home)}), \
|
||||
patch.object(cf, "get_read_block_error", None):
|
||||
with caplog.at_level("ERROR", logger="tools.credential_files"):
|
||||
assert cf.register_credential_file("google_token.json") is False
|
||||
assert cf.get_credential_file_mounts() == []
|
||||
assert any("deny-list cannot be consulted" in r.message for r in caplog.records)
|
||||
|
||||
def test_guard_exception_fails_closed_with_traceback(self, tmp_path, caplog):
|
||||
"""A raising guard refuses the mount and logs the stack trace."""
|
||||
import tools.credential_files as cf
|
||||
|
||||
home = self._home(tmp_path)
|
||||
|
||||
def _boom(path):
|
||||
raise RuntimeError("guard exploded")
|
||||
|
||||
with patch.dict(os.environ, {"HERMES_HOME": str(home)}), \
|
||||
patch.object(cf, "get_read_block_error", _boom):
|
||||
with caplog.at_level("ERROR", logger="tools.credential_files"):
|
||||
assert cf.register_credential_file("google_token.json") is False
|
||||
assert cf.get_credential_file_mounts() == []
|
||||
rec = next(r for r in caplog.records if "read guard raised" in r.message)
|
||||
assert rec.exc_info is not None, "traceback must be attached (logger.exception)"
|
||||
|
||||
@@ -352,6 +352,9 @@ def test_session_resume_returns_hydrated_messages(server, monkeypatch):
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
|
||||
return [
|
||||
{"role": "user", "content": "hello"},
|
||||
@@ -418,6 +421,9 @@ def test_session_resume_defaults_to_deferred_build(server, monkeypatch):
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
|
||||
return [
|
||||
{"role": "user", "content": "hello"},
|
||||
@@ -565,6 +571,9 @@ def test_session_resume_handles_multimodal_list_content(server, monkeypatch):
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
|
||||
return [multimodal_user, text_only_assistant]
|
||||
|
||||
@@ -621,6 +630,9 @@ def test_session_resume_lazy_registers_watch_session_without_agent(server, monke
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
|
||||
return [
|
||||
{"role": "user", "content": "delegated goal"},
|
||||
@@ -700,6 +712,9 @@ def test_session_resume_lazy_reports_running_for_inflight_child(server, monkeypa
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
|
||||
return [{"role": "user", "content": "delegated goal"}]
|
||||
|
||||
@@ -757,6 +772,9 @@ def test_session_resume_lazy_tolerates_missing_row_for_active_child(server, monk
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
|
||||
# No rows for an unwritten session.
|
||||
return []
|
||||
@@ -860,6 +878,9 @@ def test_session_resume_reuses_existing_live_session(server, monkeypatch):
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return []
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
|
||||
return [
|
||||
{"role": "user", "content": "hello"},
|
||||
@@ -1083,6 +1104,9 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server,
|
||||
self.get_messages_as_conversation(session_id, include_ancestors=True),
|
||||
)
|
||||
|
||||
def get_ancestor_display_prefix(self, _sid):
|
||||
return list(ancestor_history)
|
||||
|
||||
def get_messages_as_conversation(self, _sid, include_ancestors=False, repair_alternation=False):
|
||||
if include_ancestors:
|
||||
return ancestor_history + current_history
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user