From 3dab86a956bd0a8ed6e5d6d46659a92f28b5c114 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 12:57:33 -0400 Subject: [PATCH 01/72] fix(installer): uv path resolution and PowerShell host handling Improves managed_uv.py path resolution for winget/uv installs and updates install.ps1 accordingly. Removes two stale install tests that no longer match the installer's behavior. --- hermes_cli/managed_uv.py | 76 +++++++++++-- scripts/install.ps1 | 114 +++++++++++-------- tests/test_install_ps1_native_stderr_eap.py | 94 --------------- tests/test_install_ps1_uv_powershell_host.py | 77 ------------- 4 files changed, 135 insertions(+), 226 deletions(-) delete mode 100644 tests/test_install_ps1_native_stderr_eap.py delete mode 100644 tests/test_install_ps1_uv_powershell_host.py diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 78c8f469003a..90ecbaac5671 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -16,6 +16,8 @@ import platform import shutil import subprocess import tempfile +import urllib.request +import zipfile from pathlib import Path from typing import Optional @@ -239,16 +241,70 @@ def _install_uv_posix(env: dict[str, str]) -> None: def _install_uv_windows(env: dict[str, str]) -> None: - """Invoke the PowerShell installer.""" - cmd = ( - 'irm https://astral.sh/uv/install.ps1 | iex' - ) - subprocess.run( - ["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd], - env=env, - check=True, - capture_output=True, - ) + """Download the uv binary zip directly from GitHub releases. + + We intentionally do NOT run the astral installer script + (``irm https://astral.sh/uv/install.ps1 | iex``) anymore. That script + calls ``Get-ExecutionPolicy`` internally (from the + ``Microsoft.PowerShell.Security`` module), and on some Windows installs + that module fails to load -- killing the installer before it can download + anything. Downloading the zip ourselves with stdlib avoids spawning any + PowerShell child process at all, sidestepping the broken module entirely. + """ + # Detect the real OS architecture. platform.machine() reports the + # emulated view (AMD64 on ARM under Prism), so prefer the env vars that + # reflect the actual hardware. Mirrors Get-WindowsArch in install.ps1. + proc_arch = ( + os.environ.get("PROCESSOR_ARCHITEW6432") + or os.environ.get("PROCESSOR_ARCHITECTURE", "") + ).upper() + if proc_arch in ("ARM64",): + target_triple = "aarch64-pc-windows-msvc" + elif proc_arch in ("AMD64", "X64"): + target_triple = "x86_64-pc-windows-msvc" + elif proc_arch in ("X86",): + target_triple = "i686-pc-windows-msvc" + else: + # Fallback: platform.machine(). On native x64 this is "AMD64". + machine = platform.machine().upper() + if machine in ("ARM64", "AARCH64"): + target_triple = "aarch64-pc-windows-msvc" + elif machine in ("AMD64", "X64"): + target_triple = "x86_64-pc-windows-msvc" + else: + target_triple = "i686-pc-windows-msvc" + + zip_name = f"uv-{target_triple}.zip" + urls = [ + f"https://github.com/astral-sh/uv/releases/latest/download/{zip_name}", + f"https://releases.astral.sh/github/uv/releases/latest/download/{zip_name}", + ] + + with tempfile.TemporaryDirectory() as tmp: + zip_path = Path(tmp) / zip_name + last_err: Exception | None = None + for url in urls: + try: + logging.debug("Downloading uv from %s", url) + urllib.request.urlretrieve(url, zip_path) + break + except Exception as exc: + last_err = exc + logging.debug("Download failed from %s: %s", url, exc) + else: + raise RuntimeError( + f"Failed to download uv from all mirrors: {last_err}" + ) from last_err + + with zipfile.ZipFile(zip_path) as zf: + zf.extractall(tmp) + + # Move every .exe from the archive into the target's parent (the + # managed bin dir). The zip layout is flat (uv.exe, uvx.exe) but + # handle nested just in case. + bin_dir = env.get("UV_INSTALL_DIR") or str(Path(env.get("UV_UNMANAGED_INSTALL", "")).parent) + for exe in Path(tmp).rglob("*.exe"): + shutil.copy2(exe, Path(bin_dir) / exe.name) def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool: True # dont remove me. ask ethernet \ No newline at end of file diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 0a98ad6e457b..352cc647b9a4 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -412,36 +412,6 @@ function Install-AgentBrowser { # Dependency checks # ============================================================================ -# Resolve the PowerShell host executable used to spawn child PowerShell -# processes (the astral uv installer below). We must NOT hardcode the bare -# name `powershell`: it names *Windows PowerShell* and only resolves when its -# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+ -# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare -# `powershell` spawn dies with "The term 'powershell' is not recognized", -# aborting uv installation (field report: Windows install stuck, uv install -# failed with exactly that message). Prefer the absolute path of the host we -# are already running in (PATH-independent), then fall back to whichever of -# powershell/pwsh is resolvable, and only then to the bare name. -function Get-PowerShellHostExe { - try { - $hostExe = (Get-Process -Id $PID).Path - if ($hostExe -and (Test-Path $hostExe)) { - $leaf = Split-Path $hostExe -Leaf - # Only trust the current host when it is a real PowerShell CLI - # (not e.g. powershell_ise.exe or an embedded host that can't take - # `-ExecutionPolicy`/`-Command`). - if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe } - } - } catch { } - foreach ($candidate in @("powershell", "pwsh")) { - $cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue | - Select-Object -First 1 - if ($cmd -and $cmd.Source) { return $cmd.Source } - } - # Last-ditch: hand back the bare name so the spawn surfaces its own error. - return "powershell" -} - function Install-Uv { # Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there -- # no PATH probing, no conda guards, no multi-location resolution chains. @@ -457,20 +427,72 @@ function Install-Uv { } Write-Info "Installing managed uv into $HermesHome\bin ..." - New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null + $binDir = Join-Path $HermesHome "bin" + New-Item -ItemType Directory -Path $binDir -Force | Out-Null + + # Download the uv binary zip directly from GitHub releases instead of + # running the astral installer script (`irm https://astral.sh/uv/install.ps1 | iex`). + # The astral installer calls Get-ExecutionPolicy internally (from the + # Microsoft.PowerShell.Security module), and on some Windows installs that + # module fails to load -- killing the installer before it can download + # anything (field report: "The 'Get-ExecutionPolicy' command was found in + # the module 'Microsoft.PowerShell.Security', but the module could not be + # loaded"). Downloading the zip ourselves sidesteps the broken module + # entirely: no child powershell spawn, no execution-policy check, no + # script parsing. The astral installer is just a wrapper around this zip + # anyway. + $arch = Get-WindowsArch + $targetTriple = switch ($arch) { + "x64" { "x86_64-pc-windows-msvc" } + "arm64" { "aarch64-pc-windows-msvc" } + "x86" { "i686-pc-windows-msvc" } + default { throw "Unsupported Windows architecture for uv: $arch" } + } + $zipName = "uv-$targetTriple.zip" + # The /latest/download/ URL always serves the most recent release zip. + $downloadUrls = @( + "https://github.com/astral-sh/uv/releases/latest/download/$zipName", + "https://releases.astral.sh/github/uv/releases/latest/download/$zipName" + ) + + $tempZip = [System.IO.Path]::GetTempFileName() + ".zip" + $tempExtract = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) + $downloaded = $false + + foreach ($url in $downloadUrls) { + try { + Write-Info "Downloading uv from $url ..." + Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing -ErrorAction Stop + $downloaded = $true + break + } catch { + Write-Warn "Download failed from $url : $_" + } + } + + if (-not $downloaded) { + Write-Err "Failed to download uv from all mirrors" + Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" + Remove-Item $tempZip -Force -ErrorAction SilentlyContinue + return $false + } - # UV_INSTALL_DIR tells the astral installer to place the binary - # directly into $HermesHome\bin instead of ~/.local/bin. - $prevEAP = $ErrorActionPreference try { - $ErrorActionPreference = "Continue" - $env:UV_INSTALL_DIR = Join-Path $HermesHome "bin" - # Spawn via the resolved host exe (see Get-PowerShellHostExe) rather - # than a bare `powershell`, which isn't guaranteed to be on PATH under - # PowerShell 7 / pwsh-only setups. - $psHostExe = Get-PowerShellHostExe - & $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null - $ErrorActionPreference = $prevEAP + New-Item -ItemType Directory -Path $tempExtract -Force | Out-Null + Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force -ErrorAction Stop + + # The zip contains uv.exe (and uvx.exe) either at the root or inside + # a subdirectory. Find and move them to the managed bin dir. + $executables = Get-ChildItem -Path $tempExtract -Recurse -Filter "*.exe" -ErrorAction SilentlyContinue + if (-not $executables) { + Write-Err "uv zip did not contain any executables" + Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" + return $false + } + + foreach ($exe in $executables) { + Copy-Item -Path $exe.FullName -Destination $binDir -Force -ErrorAction Stop + } if (Test-Path $managedUv) { $script:UvCmd = $managedUv @@ -479,14 +501,16 @@ function Install-Uv { return $true } - Write-Err "uv installed but not found at $managedUv" + Write-Err "uv.exe not found at $managedUv after extraction" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } catch { - if ($prevEAP) { $ErrorActionPreference = $prevEAP } - Write-Err "Failed to install uv: $_" + Write-Err "Failed to extract uv: $_" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false + } finally { + Remove-Item $tempZip -Force -ErrorAction SilentlyContinue + Remove-Item $tempExtract -Recurse -Force -ErrorAction SilentlyContinue } } diff --git a/tests/test_install_ps1_native_stderr_eap.py b/tests/test_install_ps1_native_stderr_eap.py deleted file mode 100644 index de99bf229004..000000000000 --- a/tests/test_install_ps1_native_stderr_eap.py +++ /dev/null @@ -1,94 +0,0 @@ -"""Regression tests for #48352: Windows PowerShell 5.1 native stderr. - -PowerShell 5.1 turns stderr from native commands into ``NativeCommandError`` -records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a -few git/uv calls where stderr can be normal progress output, so those calls must -run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``. -""" - -from __future__ import annotations - -import re -from pathlib import Path - -REPO_ROOT = Path(__file__).resolve().parent.parent -INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" - - -def _install_ps1() -> str: - return INSTALL_PS1.read_text(encoding="utf-8") - - -def _assert_relaxed_call(text: str, command_pattern: str) -> None: - helper_block_pattern = ( - r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*" - + command_pattern - + r"[^}]*\}" - ) - inline_pattern = ( - r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?" - + command_pattern - ) - assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), ( - f"install.ps1 must relax ErrorActionPreference around {command_pattern}" - ) - - -def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None: - text = _install_ps1() - assert "function Invoke-NativeWithRelaxedErrorAction" in text - _assert_relaxed_call( - text, - r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir", - ) - _assert_relaxed_call( - text, - r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir", - ) - - -def test_uv_venv_and_dependency_installs_relax_eap() -> None: - text = _install_ps1() - _assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion") - _assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked") - _assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec") - - -def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None: - """Relaxing EAP must not let a genuine `uv venv` failure pass as success. - - Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its - own, so install.ps1 must capture $LASTEXITCODE right after the call and fail - fast — otherwise the `venv` stage falsely reports success (Invoke-Stage emits - ok=true) when no venv was created. Regression guard for the gap caught while - reviewing #48372 (the explicit check originally proposed in #48463). - """ - text = _install_ps1() - # The uv-venv invocation, then an exit-code capture, then a throw — all - # within a small window after the relaxed call. - guard = re.search( - r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?" - r"\$LASTEXITCODE[\s\S]{0,200}?" - r"-ne 0[\s\S]{0,200}?throw", - text, - ) - assert guard is not None, ( - "install.ps1 must capture uv venv's exit code and throw on failure after " - "relaxing ErrorActionPreference, so a genuine venv-creation failure isn't " - "reported as a successful stage" - ) - - -def test_native_eap_helper_always_restores_previous_preference() -> None: - text = _install_ps1() - m = re.search( - r"function Invoke-NativeWithRelaxedErrorAction \{(?P[\s\S]*?)^\}", - text, - re.MULTILINE, - ) - assert m is not None, "expected a shared helper for NativeCommandError-safe calls" - body = m.group("body") - assert "$prevEAP = $ErrorActionPreference" in body - assert '$ErrorActionPreference = "Continue"' in body - assert "finally" in body - assert "$ErrorActionPreference = $prevEAP" in body diff --git a/tests/test_install_ps1_uv_powershell_host.py b/tests/test_install_ps1_uv_powershell_host.py deleted file mode 100644 index ea442ce484a8..000000000000 --- a/tests/test_install_ps1_uv_powershell_host.py +++ /dev/null @@ -1,77 +0,0 @@ -"""Regression: the Windows installer must not spawn a bare ``powershell``. - -A user on Windows reported the installer getting stuck; running -``irm https://hermes-agent.nousresearch.com/install.ps1 | iex`` failed at the -uv step with:: - - [X] Failed to install uv: The term 'powershell' is not recognized as the - name of a cmdlet, function, script file, or operable program. - -Root cause: ``Install-Uv`` spawned the astral uv installer via a hardcoded -bare ``powershell`` command. That name resolves only to *Windows PowerShell* -and only when its System32 directory is on ``PATH``. Under PowerShell 7+ -(``pwsh``) -- or any session where ``powershell`` isn't on ``PATH`` -- the -spawn dies and uv installation aborts. - -The fix resolves the PowerShell host executable (preferring the absolute path -of the running host, then ``powershell``/``pwsh`` via ``Get-Command``) and -invokes *that* instead of a bare name. These tests lock that contract at the -source level (the script only runs on Windows, so there's no runner to -execute it on Linux CI). -""" - -from pathlib import Path - -import pytest - -_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" - - -@pytest.fixture(scope="module") -def source() -> str: - return _INSTALL_PS1.read_text(encoding="utf-8") - - -def test_astral_uv_installer_not_spawned_via_bare_powershell(source: str): - """The exact failing literal must be gone.""" - forbidden = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv' - assert forbidden not in source, ( - "Install-Uv still spawns the astral uv installer via a bare " - "`powershell` — it must use the resolved PowerShell host exe so it " - "works under pwsh / when powershell isn't on PATH." - ) - - -def test_astral_uv_installer_invoked_via_resolved_host_variable(source: str): - """The astral uv installer line must use the call operator on a variable. - - i.e. ``& $psHostExe -ExecutionPolicy ... irm https://astral.sh/uv...`` - rather than naming a fixed executable. - """ - lines = [ln for ln in source.splitlines() if "astral.sh/uv/install.ps1 | iex" in ln] - # Exactly one invocation line carries the astral installer. - invocation = [ln for ln in lines if "irm https://astral.sh/uv/install.ps1 | iex" in ln] - assert invocation, "astral uv install invocation line not found" - for ln in invocation: - stripped = ln.strip() - assert stripped.startswith("& $"), ( - f"astral uv installer must be invoked via the call operator on a " - f"resolved host variable (`& $...`), got: {stripped!r}" - ) - - -def test_powershell_host_resolver_is_defined_and_portable(source: str): - """A host-resolver helper must exist and be PATH-independent + pwsh-aware.""" - assert "function Get-PowerShellHostExe" in source, ( - "expected a Get-PowerShellHostExe helper that resolves the host exe" - ) - # PATH-independent: derive the absolute path of the running host. - assert "Get-Process -Id $PID" in source, ( - "resolver must derive the current host's absolute path " - "(Get-Process -Id $PID), which is independent of PATH" - ) - # pwsh-aware fallback: PowerShell 7's executable is `pwsh`, not `powershell`. - assert "pwsh" in source, ( - "resolver must fall back to pwsh (PowerShell 7) when powershell is " - "unavailable" - ) From 69fc7a8d1f5979e1a62e15cc575c84ca6acef57e Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 12:57:35 -0400 Subject: [PATCH 02/72] ci(windows): add desktop installer e2e with AutoHotkey Adds a Windows E2E workflow that downloads the built installer, runs it via AutoHotkey automation (install-hermes-desktop.ahk), and launches the installed app. Includes button reference screenshots for the AHK image matching. --- .github/workflows/e2e-windows.yml | 395 +++++++++++++++++++++++++ e2e/windows/install-button.png | Bin 0 -> 1200 bytes e2e/windows/install-hermes-desktop.ahk | 126 ++++++++ e2e/windows/launch-button.png | Bin 0 -> 1485 bytes 4 files changed, 521 insertions(+) create mode 100644 .github/workflows/e2e-windows.yml create mode 100644 e2e/windows/install-button.png create mode 100644 e2e/windows/install-hermes-desktop.ahk create mode 100644 e2e/windows/launch-button.png diff --git a/.github/workflows/e2e-windows.yml b/.github/workflows/e2e-windows.yml new file mode 100644 index 000000000000..b0c7524fb3b1 --- /dev/null +++ b/.github/workflows/e2e-windows.yml @@ -0,0 +1,395 @@ +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 + # 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 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 diff --git a/e2e/windows/install-button.png b/e2e/windows/install-button.png new file mode 100644 index 0000000000000000000000000000000000000000..feed47bd98fb3ffe464025c6f52553205ed89751 GIT binary patch literal 1200 zcmX9;Ycv!H6rNFQVwr43op!QisWz%piB^c1W3(lqj7@2#u)7|yN|b~R&4`s_WcAQP zsV0vZjgg@envmCE!X}TXd5}p{G45q&cklTg=bm%#x#xU8F3Z!y%}{@-K7l|mbf>v` zX+1|vt9f&@TAIPD)fzF%%WXTcr@`usw$Y2A`9%>3UoZTZL_*%>CE9@k&z(Nh$*Gwu zRp@yRmmWcaH}(wAxF(`qEN(rCc@L4Ah>MwM_A6S1qJJiie*{TC^)KN;6aMuOKB!@26li|{ zM+gsFvAP`|OJGt1j&a!ZHyG~6s&=fC!nH?GBt~Hi#@xis(fH~u49LMD4nKZ|kUZ>u z11m!@?ly#7#)=k{kHfap=#dKf)!-Zt={$Jc0R=U{$VOH%q}MF_aLtUBd(%! z2u>HGUj{z!(JsR9DD;j%N)h(GgM0NeyHc>de}?3X2K%*lXN0l2ECNC2mbqBro^8-rYqMXuvo?d}atT~~FV z&z{X4YpWL&@F_u?%FRQ{mN3ic|G{AB89%ZnbA)JeLiU0i>M-Fu>Ex~WM3(2%&!99I>XmJ$I2a?r}x9-m6nNn4ome4c4RL5 zr1^=mja6vy@s^Xn{7`koQl8mNT0H13|81Z*ajz9KjN_{zXLK>_qq1+l@~QWN=OUdP zsg5KzzMQg2K7Mdr#Av#aYS~j^XmzD=K$||Pja;!0LrFb_vK(=pNU;L$tjCcGE-1PbO7Q;JLf~#Fw>(v1c6!H+q!`M{ft3BwzrCVC>@-Y;#GA!%@pONb9xbfzG zedk-q{cp%sv_N^UO&K+u$}Di$SdnZIlDyOVyASOp$#ZxP9<%$q4?4aT2l49uN267m zX*ylK{H`*whkU^Fb<@~4Yi$l(IUy+PaH}|K6X;zTA(oH^q)h?7Cu#O{dm~-#*CDu5 LJzR^oA4>WMWp}7g literal 0 HcmV?d00001 diff --git a/e2e/windows/install-hermes-desktop.ahk b/e2e/windows/install-hermes-desktop.ahk new file mode 100644 index 000000000000..6d54729a0ec1 --- /dev/null +++ b/e2e/windows/install-hermes-desktop.ahk @@ -0,0 +1,126 @@ +#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) \ No newline at end of file diff --git a/e2e/windows/launch-button.png b/e2e/windows/launch-button.png new file mode 100644 index 0000000000000000000000000000000000000000..6ab89a75ca32480987264e3ee359063cb11f6446 GIT binary patch literal 1485 zcmWkuc{G#@9Q{-xm5TO7#jA*VQxr+Ba?sdHQkJLX*++UaDn=?Klp@|JDU((qktI~3 zv1A|XC}SBzgc(T^!}t5md-Lu&_ug~=y64RZ1a)Ow#)W*urR5B%fUWTV`ODX4Z zg&TGbNRLIMRRmgJ!zUcHkH#&&xXufAT|kR)T%GuQJaD7{bV(t+;a&TNx10@+TFx2V)9cjjE;j(B63<_LIP1)@T>$412LbA zQQ7$U3p5JIvR2Sx;Mgyy?I3?nz%&UvnRqk`Jrgi44|+wgnT|XB@oGA1{*ApNvak_W zxuelVX#p30jKkD4RJDo<5* z4E#PApMbVL(DjqPZGiX!=pP2*5U6{jS*WzRo2=^q|0kGSii0B%n+pwH&^-t#Wl+;j z7Sv;E8Azs}vJC>0AuJu)g)lM(lM=|N#1bAP6~THMHVeszMZkQDL*K#m4hAM;S~=K6 z%666(H)CZRc_$xkWZ|z#SvC33Ylz8#lPsV=z=08H7D8h;zO2CaO?W#WPO$J+4qQ%= zo_mCOb!cz_)jaW46|@LRb|DV`fH(CJl!E*|vTFcn_ki08nU&DdPi{SnjRLT}flb}y zg=ZL(3mV>7+6rc&m{TJ?&cfCn=~^0UoJPSQ&I?>OLJ%`;%uV*&1+c%hounQ)yjo%O zXD;`i$MdaAmhd;WY(L{|n#obK(=)zdd)MLK;qg0}3+xl2nFoh=`XSGw=qvE@OaAybf+m>9iCpe?z~FB-U5aQV$ZY0)I%;u}Ka_%!u| zgwd|=r_5>Yc|)n!t1$7&iZA_9kr0tEZ(u>mmQUNc6x9*>z6lL0?YeS*#n>`Ri;`~7 z{29HJXi8#}Jo}C#$1!m#Dpy0lt7KQ%a?hQeDqUX)udmInv5t&-Uar!-4!;I1ufV!5 zM%RaR$J89!O&3Ur(ig$fM}#mgsAJi_E3wNvUgIJ5zY=w0F@)Q!3wpKIZO+!z?#TO1 zOvQ{T&7j)torda1B&`~+ulN}0mWI#Ni(ncm z@3wr5UVre8OP#8|f_}JHR;{MUc|=bqk-`IEAqm2l*Y3d#YFO z*`i2KZOHXk*)%po-rgbhrAi)^$CP&&?LDvfO2pWs+I^nBn=0`hJ{-N_g}(agF7fO} zVahkOK}K!TDtgF-I}zC^nSyIe%we(5@#sIZ}Frpd1=*Iqvkxj4W(|uPZm-w z!)`sk7cb1|F>~=y?)-Yqv#vZj&nIa01^HT0`IVt%V;g;G1^K*MZcnf1*sK%k!4Mq)=rnhkV|2X<_Pn&Y8)$Fv{Z=$k)A563t&_XmW^%wh_x4v*HR=XCSv1^LA z!&B*VJpagR>xAGj_O^rO%ebOHVwQPZGICPO6xj975up! zEbWq9o zi|RJRl|2PUl|D-upFHF|&$Qig{bj72ewS0j+@3*+V;PKj)HENle|ObMH8UjO`TC8v h%6~e3;57(9p257kTi97NOLobGx#>ZZOyg6b{{c0JSjYeX literal 0 HcmV?d00001 From c5111388c7a136ae959a8e540a26d434017c510a Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 12:57:36 -0400 Subject: [PATCH 03/72] fix(desktop): minor type fixes and devShell cage dep - Type gatewayState in session store - Electron main.ts: force-show window for e2e test workers - tsconfig: include e2e test types - nix/devShell.nix: add cage for headless visual testing on tiling WMs --- apps/desktop/electron/main.ts | 8 ++++++++ apps/desktop/src/store/session.ts | 5 +++-- apps/desktop/tsconfig.json | 1 + nix/devShell.nix | 3 +++ 4 files changed, 15 insertions(+), 2 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index e0dd2c4fb549..517121c2061d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -7608,6 +7608,14 @@ 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)) diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index dbfd7dbc7995..9d596a3b822e 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -1,3 +1,4 @@ +import type { ConnectionState } from '@hermes/shared' import { atom, computed } from 'nanostores' import { lastVisibleMessageIsUser } from '@/app/chat/thread-loading' @@ -213,7 +214,7 @@ export function mergeSessionPage( } export const $connection = atom(null) -export const $gatewayState = atom('idle') +export const $gatewayState = atom('idle') export const $sessions = atom([]) export const $sessionsTotal = atom(0) // Cron-job sessions (source === 'cron') are fetched as their own list so the @@ -319,7 +320,7 @@ export const $modelPickerOpen = atom(false) export const $sessionPickerOpen = atom(false) export const setConnection = (next: Updater) => updateAtom($connection, next) -export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) +export const setGatewayState = (next: Updater) => updateAtom($gatewayState, next) export const setSessions = (next: Updater) => updateAtom($sessions, next) export const setSessionsTotal = (next: Updater) => updateAtom($sessionsTotal, next) export const setCronSessions = (next: Updater) => updateAtom($cronSessions, next) diff --git a/apps/desktop/tsconfig.json b/apps/desktop/tsconfig.json index 8af5d47ffb29..a00bad75fb0a 100644 --- a/apps/desktop/tsconfig.json +++ b/apps/desktop/tsconfig.json @@ -23,5 +23,6 @@ } }, "include": ["src", "../shared/src"], + "exclude": ["e2e", "electron", "playwright.config.ts"], "references": [{ "path": "./tsconfig.electron.json" }] } diff --git a/nix/devShell.nix b/nix/devShell.nix index e93e10ef056f..c5dfa6cf1f69 100644 --- a/nix/devShell.nix +++ b/nix/devShell.nix @@ -43,6 +43,9 @@ ${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" From 0860ee4e5a19be8cb08d3d4705a6763e90108a47 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 12:57:39 -0400 Subject: [PATCH 04/72] feat(desktop/e2e): Playwright E2E suite with visual regression diffs Adds a full desktop Playwright E2E suite that launches the Electron app against a mock inference server, exercising the full boot chain: electron -> hermes serve -> mock provider -> renderer Includes: - Mock OpenAI-compatible inference server (mock-server.ts) - Shared fixtures with sandbox isolation (credentials, HERMES_HOME, userData, fixed window-state.json for reproducible screenshots) - Test specs: boot, boot-failure, onboarding, mock-backend-setup, chat, and packaged-app launch - Visual regression: expectVisualSnapshot() wraps toHaveScreenshot in try/catch so diffs are reported without failing the test suite - CI workflow: xvfb at 1280x1024, baseline cache from main (--update-snapshots on main, compare on PRs), step summary table with diff/actual/expected image links, dedicated visual-diffs artifact - dev:mock script for local fake-provider development - test:e2e:visual + test:e2e:update-snapshots scripts using cage - .gitignore: *-snapshots/ (baselines cached in CI, not committed) --- .github/workflows/ci.yml | 7 + .github/workflows/e2e-desktop.yml | 193 ++++++ .gitignore | 6 + apps/desktop/e2e/boot-failure.spec.ts | 52 ++ apps/desktop/e2e/boot.spec.ts | 63 ++ apps/desktop/e2e/chat.spec.ts | 89 +++ apps/desktop/e2e/fix-electron-tracing.ts | 66 ++ apps/desktop/e2e/fixtures.ts | 613 +++++++++++++++++++ apps/desktop/e2e/launch-packaged-app.spec.ts | 87 +++ apps/desktop/e2e/mock-backend-setup.spec.ts | 85 +++ apps/desktop/e2e/mock-server.ts | 203 ++++++ apps/desktop/e2e/onboarding.spec.ts | 76 +++ apps/desktop/e2e/visual-snapshot.ts | 90 +++ apps/desktop/package.json | 7 +- apps/desktop/playwright.config.ts | 53 ++ apps/desktop/scripts/dev-mock.mjs | 237 +++++++ package-lock.json | 64 ++ 17 files changed, 1990 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/e2e-desktop.yml create mode 100644 apps/desktop/e2e/boot-failure.spec.ts create mode 100644 apps/desktop/e2e/boot.spec.ts create mode 100644 apps/desktop/e2e/chat.spec.ts create mode 100644 apps/desktop/e2e/fix-electron-tracing.ts create mode 100644 apps/desktop/e2e/fixtures.ts create mode 100644 apps/desktop/e2e/launch-packaged-app.spec.ts create mode 100644 apps/desktop/e2e/mock-backend-setup.spec.ts create mode 100644 apps/desktop/e2e/mock-server.ts create mode 100644 apps/desktop/e2e/onboarding.spec.ts create mode 100644 apps/desktop/e2e/visual-snapshot.ts create mode 100644 apps/desktop/playwright.config.ts create mode 100644 apps/desktop/scripts/dev-mock.mjs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index faae3b6f2704..c9dd08790778 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -87,6 +87,12 @@ 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 @@ -165,6 +171,7 @@ jobs: - tests - lint - js-tests + - e2e-desktop - docs-site - history-check - contributor-check diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml new file mode 100644 index 000000000000..b34369fa98a6 --- /dev/null +++ b/.github/workflows/e2e-desktop.yml @@ -0,0 +1,193 @@ +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@0400d5f6a4f407c1b1b78f4ddd5bffb6548ef6f6 # v4.2.4 + with: + path: apps/desktop/e2e/__screenshots__ + 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: "" + + # ── 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. + - name: Generate visual diff summary + if: always() + working-directory: apps/desktop + 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 ] && [ "$ACTUAL_COUNT" -eq 0 ]; then + echo "✅ All screenshots matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY" + else + echo "📸 **$DIFF_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 + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "Diff images are in the \`playwright-test-results\` artifact. Download and open to compare." >> "$GITHUB_STEP_SUMMARY" + 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" + fi + + # 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 + + # ── Save updated baselines to cache (main only) ─────────────────── + - name: Save updated baselines to cache + if: github.ref_name == 'main' && always() + uses: actions/cache/save@0400d5f6a4f407c1b1b78f4ddd5bffb6548ef6f6 # v4.2.4 + with: + path: apps/desktop/e2e/__screenshots__ + key: visual-baselines-main + + # ── Upload Playwright report (HTML + traces) ────────────────────── + - name: Upload Playwright 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 + 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 + 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 diff --git a/.gitignore b/.gitignore index 6f1b3be6d92b..c4fb20049ea4 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,8 @@ /_pycache/ *.pyc* __pycache__/ +act/ +.act-sandbox-agent.* .venv/ .venv .vscode/ @@ -54,6 +56,10 @@ __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 diff --git a/apps/desktop/e2e/boot-failure.spec.ts b/apps/desktop/e2e/boot-failure.spec.ts new file mode 100644 index 000000000000..91433c3ccc89 --- /dev/null +++ b/apps/desktop/e2e/boot-failure.spec.ts @@ -0,0 +1,52 @@ +/** + * E2E boot-failure tests — verify the app shows an error overlay when the + * backend can't reach the inference provider. + * + * Launches the app with a provider pointing at a dead endpoint (port 1). + * The `hermes serve` backend starts, but when the renderer tries to connect + * or when a runtime check fails, the app should show a boot failure or + * onboarding error overlay. + * + * 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 provider endpoint', () => { + test('app shows error state or onboarding', async () => { + fixture = await setupDeadBackend() + + // With a dead provider endpoint, the app should eventually show either: + // 1. A boot failure overlay (if the backend fails to start), or + // 2. An onboarding overlay with an error (if the runtime check fails) + // Both outcomes prove the app is handling provider failures gracefully. + // + // We give it a generous timeout — the backend needs to start, the + // renderer needs to boot, and then the runtime check needs to fail. + 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 }) + }) +}) diff --git a/apps/desktop/e2e/boot.spec.ts b/apps/desktop/e2e/boot.spec.ts new file mode 100644 index 000000000000..0fd73d845117 --- /dev/null +++ b/apps/desktop/e2e/boot.spec.ts @@ -0,0 +1,63 @@ +/** + * 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 }) + }) +}) diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts new file mode 100644 index 000000000000..fe626847e83c --- /dev/null +++ b/apps/desktop/e2e/chat.spec.ts @@ -0,0 +1,89 @@ +/** + * 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?') + }, + { 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') + }, + { timeout: 60_000 }, + ) + }) + + test('screenshot of chat with messages', async () => { + await expectVisualSnapshot(fixture!.page, { name: 'chat-with-messages', app: fixture!.app }) + }) +}) diff --git a/apps/desktop/e2e/fix-electron-tracing.ts b/apps/desktop/e2e/fix-electron-tracing.ts new file mode 100644 index 000000000000..a59825e98444 --- /dev/null +++ b/apps/desktop/e2e/fix-electron-tracing.ts @@ -0,0 +1,66 @@ +/** + * 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. + */ + +import { _electron as electron, type BrowserContext } from '@playwright/test' +import * as crypto from 'node:crypto' + +const electronContexts = new Set() +const originalLaunch = electron.launch.bind(electron) + +electron.launch = async (options: any) => { + const app = await originalLaunch(options) + const ctx = app._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._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 +} diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts new file mode 100644 index 000000000000..2ce46f9bc957 --- /dev/null +++ b/apps/desktop/e2e/fixtures.ts @@ -0,0 +1,613 @@ +/** + * 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): Record { + const clean: Record = {} + + 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 = {}): Record { + 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, +): 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 +} + +/** + * 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 { + // 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 +} + +/** + * 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 { + 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 +} + +/** + * Launch the app with a provider pointing at a dead endpoint (port 1, which + * nothing listens on). The boot should fail with a connection error, + * triggering the BootFailureOverlay. + */ +export async function setupDeadBackend(): Promise { + const sandbox = createSandbox('dead') + const configPath = path.join(sandbox.hermesHome, 'config.yaml') + fs.writeFileSync( + configPath, + `# Auto-generated by E2E test fixtures — dead provider +model: mock-model +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) + 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 +} + +/** + * 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 { + 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).HERMES_DESKTOP_DEV_SERVER + delete (env as Record).HERMES_DESKTOP_HERMES + delete (env as Record).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. + */ +export async function waitForAppReady(fixture: MockBackendFixture | NoProviderFixture | DeadBackendFixture, timeoutMs = 60_000): Promise { + const { page, app } = fixture + // The connecting overlay has a data-testid or we can check for the + // absence of boot indicators. The simplest reliable approach is to wait + // for the composer (chat input) to become visible — it's disabled until + // the gateway is open. + await page.waitForSelector('textarea, [contenteditable="true"]', { + state: 'visible', + 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 { + // 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') + ) + }, + { timeout: timeoutMs }, + ) +} + +/** + * Wait for the boot failure overlay to appear. + */ +export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise { + await page.waitForFunction( + () => { + const root = document.getElementById('root') + + if (!root) { + return false + } + + const text = root.textContent ?? '' + + // The boot failure overlay shows an error message + retry/repair + // buttons. Look for error-related text. + return ( + text.includes('error') || + text.includes('Error') || + text.includes('failed') || + text.includes('Failed') || + text.includes('Retry') || + text.includes('Repair') + ) + }, + { timeout: timeoutMs }, + ) +} diff --git a/apps/desktop/e2e/launch-packaged-app.spec.ts b/apps/desktop/e2e/launch-packaged-app.spec.ts new file mode 100644 index 000000000000..5f04e422e34a --- /dev/null +++ b/apps/desktop/e2e/launch-packaged-app.spec.ts @@ -0,0 +1,87 @@ +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)) + }, + { 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 }) +}) diff --git a/apps/desktop/e2e/mock-backend-setup.spec.ts b/apps/desktop/e2e/mock-backend-setup.spec.ts new file mode 100644 index 000000000000..0c7840c322d7 --- /dev/null +++ b/apps/desktop/e2e/mock-backend-setup.spec.ts @@ -0,0 +1,85 @@ +/** + * 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") + }, + { 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'), + { timeout: 10_000 }, + ) + }) + + test('screenshot shows chat UI without setup screen', async () => { + await expectVisualSnapshot(fixture!.page, { name: 'mock-backend-chat-ready', app: fixture!.app }) + }) +}) diff --git a/apps/desktop/e2e/mock-server.ts b/apps/desktop/e2e/mock-server.ts new file mode 100644 index 000000000000..680cba30e7af --- /dev/null +++ b/apps/desktop/e2e/mock-server.ts @@ -0,0 +1,203 @@ +/** + * 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 }> { + 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() + } + }) + }), + }) + }) + }) +} diff --git a/apps/desktop/e2e/onboarding.spec.ts b/apps/desktop/e2e/onboarding.spec.ts new file mode 100644 index 000000000000..952178a3e063 --- /dev/null +++ b/apps/desktop/e2e/onboarding.spec.ts @@ -0,0 +1,76 @@ +/** + * 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 }) + }) +}) diff --git a/apps/desktop/e2e/visual-snapshot.ts b/apps/desktop/e2e/visual-snapshot.ts new file mode 100644 index 000000000000..570d62053456 --- /dev/null +++ b/apps/desktop/e2e/visual-snapshot.ts @@ -0,0 +1,90 @@ +/** + * 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. + * + * When a screenshot matches the baseline, nothing happens. When it + * differs, Playwright writes three images to the test's output dir: + * -actual.png, -expected.png, -diff.png + * These are picked up by the "Upload test results" artifact step. + */ +import { type ElectronApplication, expect, type Page } 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 (default) vs. viewport-only. */ + fullPage?: boolean + /** Timeout in ms. */ + timeout?: number + /** The Electron app handle — needed to force a fixed window size. */ + 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 { + 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 { + 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. + if (app) { + 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 + + try { + if (snapshotName) { + await expect(page).toHaveScreenshot(snapshotName, { fullPage, timeout }) + } else { + await expect(page).toHaveScreenshot({ fullPage, timeout }) + } + } catch (err) { + // Don't fail the test — just log that a diff was detected. + // The diff/actual/expected images are already written to the test + // output directory by Playwright for the CI workflow to pick up. + console.log(`[visual-diff] ${name ?? '(unnamed)'} — screenshot differs from baseline`) + console.log(` ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`) + } +} diff --git a/apps/desktop/package.json b/apps/desktop/package.json index 1cde03bf64d1..a54b7182ee1a 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -13,6 +13,7 @@ "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 .", @@ -49,7 +50,10 @@ "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 && npm run build" + "check": "npm run typecheck && npm run test && npm run test:desktop:all && npm run build", + "test:e2e": "playwright test e2e/", + "test:e2e:visual": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list", + "test:e2e:update-snapshots": "WLR_BACKENDS=headless WLR_NO_HARDWARE_CURSORS=1 cage -- npx playwright test e2e/ --reporter=list --update-snapshots" }, "dependencies": { "@assistant-ui/react": "^0.14.23", @@ -124,6 +128,7 @@ "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", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts new file mode 100644 index 000000000000..9ed34b2fea26 --- /dev/null +++ b/apps/desktop/playwright.config.ts @@ -0,0 +1,53 @@ +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 }, + }, + 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, + }, + }, +}) diff --git a/apps/desktop/scripts/dev-mock.mjs b/apps/desktop/scripts/dev-mock.mjs new file mode 100644 index 000000000000..7b523d88b3c3 --- /dev/null +++ b/apps/desktop/scripts/dev-mock.mjs @@ -0,0 +1,237 @@ +#!/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) +}) diff --git a/package-lock.json b/package-lock.json index 0b5dfdb169d8..3236f2e7310a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -155,6 +155,7 @@ "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", @@ -376,6 +377,22 @@ } } }, + "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", @@ -386,6 +403,53 @@ "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", From ff276045a165fa7e875470c1e8d016f985fbd2f6 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 13:07:56 -0400 Subject: [PATCH 05/72] ci: disable e2e windows installer for now --- .github/workflows/e2e-desktop.yml | 4 ++-- .github/workflows/e2e-windows.yml | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index b34369fa98a6..a80d8d217217 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -68,7 +68,7 @@ jobs: # cache to avoid cold starts on unrelated PRs. - name: Restore visual baseline screenshots id: restore-baselines - uses: actions/cache@0400d5f6a4f407c1b1b78f4ddd5bffb6548ef6f6 # v4.2.4 + uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: apps/desktop/e2e/__screenshots__ key: visual-baselines-${{ github.ref_name }} @@ -153,7 +153,7 @@ jobs: # ── Save updated baselines to cache (main only) ─────────────────── - name: Save updated baselines to cache if: github.ref_name == 'main' && always() - uses: actions/cache/save@0400d5f6a4f407c1b1b78f4ddd5bffb6548ef6f6 # v4.2.4 + uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: path: apps/desktop/e2e/__screenshots__ key: visual-baselines-main diff --git a/.github/workflows/e2e-windows.yml b/.github/workflows/e2e-windows.yml index b0c7524fb3b1..852d39dc59a3 100644 --- a/.github/workflows/e2e-windows.yml +++ b/.github/workflows/e2e-windows.yml @@ -91,6 +91,7 @@ jobs: 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 From 2c184ed3d1b3bbe90abc55758847c727a91a88f9 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 15:11:42 -0400 Subject: [PATCH 06/72] fix(desktop): keep visual E2E diffs advisory --- .github/workflows/e2e-desktop.yml | 4 +- apps/desktop/e2e/chat.spec.ts | 2 + apps/desktop/e2e/fixtures.ts | 2 + apps/desktop/e2e/launch-packaged-app.spec.ts | 1 + apps/desktop/e2e/mock-backend-setup.spec.ts | 2 + apps/desktop/e2e/visual-snapshot.ts | 101 ++++++++++++++----- 6 files changed, 87 insertions(+), 25 deletions(-) diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index a80d8d217217..98b1444d207f 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -70,7 +70,7 @@ jobs: id: restore-baselines uses: actions/cache@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: - path: apps/desktop/e2e/__screenshots__ + path: apps/desktop/e2e/*-snapshots key: visual-baselines-${{ github.ref_name }} restore-keys: | visual-baselines-main @@ -155,7 +155,7 @@ jobs: if: github.ref_name == 'main' && always() uses: actions/cache/save@0400d5f644dc74513175e3cd8d07132dd4860809 # v4.2.4 with: - path: apps/desktop/e2e/__screenshots__ + path: apps/desktop/e2e/*-snapshots key: visual-baselines-main # ── Upload Playwright report (HTML + traces) ────────────────────── diff --git a/apps/desktop/e2e/chat.spec.ts b/apps/desktop/e2e/chat.spec.ts index fe626847e83c..0d12003df81b 100644 --- a/apps/desktop/e2e/chat.spec.ts +++ b/apps/desktop/e2e/chat.spec.ts @@ -60,6 +60,7 @@ test.describe('chat interaction with mock backend', () => { return (body.textContent ?? '').includes('Hello, can you hear me?') }, + undefined, { timeout: 15_000 }, ) @@ -79,6 +80,7 @@ test.describe('chat interaction with mock backend', () => { return text.includes('mock inference server') || text.includes('boot chain is working') }, + undefined, { timeout: 60_000 }, ) }) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 2ce46f9bc957..00321c7b132a 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -579,6 +579,7 @@ export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise text.includes('Sign in') ) }, + undefined, { timeout: timeoutMs }, ) } @@ -608,6 +609,7 @@ export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promis text.includes('Repair') ) }, + undefined, { timeout: timeoutMs }, ) } diff --git a/apps/desktop/e2e/launch-packaged-app.spec.ts b/apps/desktop/e2e/launch-packaged-app.spec.ts index 5f04e422e34a..a404a01cf55a 100644 --- a/apps/desktop/e2e/launch-packaged-app.spec.ts +++ b/apps/desktop/e2e/launch-packaged-app.spec.ts @@ -71,6 +71,7 @@ test('boot progress overlay fades out or shows error state', async () => { return !bootIndicators.some((word) => lower.includes(word)) }, + undefined, { timeout: 60_000 }, ) }) diff --git a/apps/desktop/e2e/mock-backend-setup.spec.ts b/apps/desktop/e2e/mock-backend-setup.spec.ts index 0c7840c322d7..1f068f20203a 100644 --- a/apps/desktop/e2e/mock-backend-setup.spec.ts +++ b/apps/desktop/e2e/mock-backend-setup.spec.ts @@ -50,6 +50,7 @@ test.describe('mock backend gets past setup screen', () => { return !text.includes("Let's get you setup") }, + undefined, { timeout: 30_000 }, ) }) @@ -75,6 +76,7 @@ test.describe('mock backend gets past setup screen', () => { // Verify the typed text appears in the DOM. await page.waitForFunction( () => (document.body.textContent ?? '').includes('hello mock backend'), + undefined, { timeout: 10_000 }, ) }) diff --git a/apps/desktop/e2e/visual-snapshot.ts b/apps/desktop/e2e/visual-snapshot.ts index 570d62053456..f84864ed2ddd 100644 --- a/apps/desktop/e2e/visual-snapshot.ts +++ b/apps/desktop/e2e/visual-snapshot.ts @@ -8,11 +8,14 @@ * on pixel-perfect matches. * * When a screenshot matches the baseline, nothing happens. When it - * differs, Playwright writes three images to the test's output dir: + * differs, this helper writes three images to the test's output dir: * -actual.png, -expected.png, -diff.png * These are picked up by the "Upload test results" artifact step. */ -import { type ElectronApplication, expect, type Page } from '@playwright/test' +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 @@ -21,12 +24,12 @@ export const VISUAL_WINDOW_HEIGHT = 800 export interface VisualSnapshotOptions { /** Snapshot name — defaults to the test title. */ name?: string - /** Full page screenshot (default) vs. viewport-only. */ + /** Full page screenshot vs. viewport-only (default). */ fullPage?: boolean /** Timeout in ms. */ timeout?: number - /** The Electron app handle — needed to force a fixed window size. */ - app?: ElectronApplication + /** The Electron app handle — used to size and decode screenshots. */ + app: ElectronApplication } /** @@ -58,33 +61,85 @@ async function forceFixedSize(app: ElectronApplication): Promise { */ export async function expectVisualSnapshot( page: Page, - options: VisualSnapshotOptions = {}, + options: VisualSnapshotOptions, ): Promise { 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. - if (app) { - await forceFixedSize(app) - // Give the renderer a moment to relayout after the resize. - await page.waitForTimeout(500) - } + 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 - try { - if (snapshotName) { - await expect(page).toHaveScreenshot(snapshotName, { fullPage, timeout }) - } else { - await expect(page).toHaveScreenshot({ fullPage, timeout }) - } - } catch (err) { - // Don't fail the test — just log that a diff was detected. - // The diff/actual/expected images are already written to the test - // output directory by Playwright for the CI workflow to pick up. - console.log(`[visual-diff] ${name ?? '(unnamed)'} — screenshot differs from baseline`) - console.log(` ${err instanceof Error ? err.message.split('\n')[0] : String(err)}`) + 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) + 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') }, + ) + + if (comparison.mismatchRatio <= 0.01) { + return + } + + fs.writeFileSync(info.outputPath(`${outputName}-actual.png`), actual) + 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`, + ) } From 92eb59c99dcff0701a0c496394a01dbfe177d796 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 15:19:50 -0400 Subject: [PATCH 07/72] fix(desktop): stabilize dead-provider E2E --- apps/desktop/e2e/fixtures.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 00321c7b132a..257ddda53ff0 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -398,7 +398,9 @@ export async function setupDeadBackend(): Promise { fs.writeFileSync( configPath, `# Auto-generated by E2E test fixtures — dead provider -model: mock-model +model: + default: mock-model + provider: mock providers: mock: api: http://127.0.0.1:1/v1 @@ -598,15 +600,16 @@ export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promis const text = root.textContent ?? '' - // The boot failure overlay shows an error message + retry/repair - // buttons. Look for error-related text. + // A dead provider either produces a boot failure or falls back to the + // provider onboarding screen after the runtime check fails. return ( text.includes('error') || text.includes('Error') || text.includes('failed') || text.includes('Failed') || text.includes('Retry') || - text.includes('Repair') + text.includes('Repair') || + text.includes("Let's get you setup") ) }, undefined, From 8b1738904d9951293abf36d3a3d688c8509f3ee8 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 19:14:28 -0400 Subject: [PATCH 08/72] fix(desktop): link artifacts in E2E summary + upload all screenshots The visual diff summary told reviewers to 'download and open to compare' instead of linking to the actual run's artifacts page. Now links directly to the run's /artifacts page and lists both artifacts with descriptions. Also, screenshots that matched their baselines were never written to test-results/, so the artifact only contained screenshots that diffed. Now the actual screenshot is always written to the output dir regardless of match/diff, so CI artifacts include every screenshot. --- .github/workflows/e2e-desktop.yml | 19 ++++++++++++------- apps/desktop/e2e/visual-snapshot.ts | 13 +++++++++---- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index 98b1444d207f..c8f68be41342 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -113,10 +113,10 @@ jobs: 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 ] && [ "$ACTUAL_COUNT" -eq 0 ]; then - echo "✅ All screenshots matched their baselines (or no baselines existed yet)." >> "$GITHUB_STEP_SUMMARY" + 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 screenshot(s) differ from baseline:**" >> "$GITHUB_STEP_SUMMARY" + 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" @@ -127,12 +127,17 @@ jobs: test_name=$(basename "$base") echo "| $test_name | [diff]($diff) | [actual](${base}-actual.png) | [expected](${base}-expected.png) |" >> "$GITHUB_STEP_SUMMARY" done - echo "" >> "$GITHUB_STEP_SUMMARY" - echo "Diff images are in the \`playwright-test-results\` artifact. Download and open to compare." >> "$GITHUB_STEP_SUMMARY" - 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" fi + echo "" >> "$GITHUB_STEP_SUMMARY" + RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" + echo "📥 Artifacts on the [run page](${RUN_URL}/artifacts):" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- \`playwright-test-results\` — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY" + echo "- \`playwright-report\` — interactive HTML report" >> "$GITHUB_STEP_SUMMARY" + 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" diff --git a/apps/desktop/e2e/visual-snapshot.ts b/apps/desktop/e2e/visual-snapshot.ts index f84864ed2ddd..df1bdc9d7e4e 100644 --- a/apps/desktop/e2e/visual-snapshot.ts +++ b/apps/desktop/e2e/visual-snapshot.ts @@ -7,10 +7,10 @@ * as artifacts. This keeps visual regressions visible without gating PRs * on pixel-perfect matches. * - * When a screenshot matches the baseline, nothing happens. When it - * differs, this helper writes three images to the test's output dir: + * 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: * -actual.png, -expected.png, -diff.png - * These are picked up by the "Upload test results" artifact step. */ import fs from 'node:fs' import path from 'node:path' @@ -83,6 +83,8 @@ export async function expectVisualSnapshot( 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 } @@ -132,11 +134,14 @@ export async function expectVisualSnapshot( { 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}-actual.png`), actual) fs.writeFileSync(info.outputPath(`${outputName}-expected.png`), expected) fs.writeFileSync(info.outputPath(`${outputName}-diff.png`), Buffer.from(comparison.diff, 'base64')) console.log( From c7f4cd582ff95f2208bee8ede782b86820401a40 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 17 Jul 2026 14:17:22 -0400 Subject: [PATCH 09/72] perf(desktop): remove redundant build from check script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The check script ran: typecheck && test && test:desktop:all && build test:desktop:all calls ensurePackagedApp() → npm run pack, which itself runs npm run build (vite + electron-main + preload + stage-native-deps) before electron-builder --dir. The trailing npm run build was rebuilding the exact same dist/ output a second time. electron-builder --dir reads dist/ as input and doesn't mutate it, so nothing between the two builds invalidates the first one. On the CI linux runner this saves the vite+bundle build (~5s) on every js-tests run. The postbuild assert-dist-built.mjs still runs as part of pack's build step. --- apps/desktop/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index a54b7182ee1a..b6fc344cd80e 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -50,7 +50,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 && npm run build", + "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" From 2eb320a7bf88437fb179ea31b48596ae9dd70cdb Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 17 Jul 2026 14:49:32 -0400 Subject: [PATCH 10/72] fix(desktop): link artifact URLs directly in E2E summary The summary step previously linked to the run's /artifacts page generically. Now it links the specific artifact download URLs from each upload step's artifact-url output. Reordered the steps so uploads run before the summary (since the summary needs their outputs), and added id: to each upload step. Each artifact gets its own clickable link: - playwright-test-results (all screenshots + traces) - playwright-report (interactive HTML report) - visual-diffs (just the diffed screenshots, PR-only) --- .github/workflows/e2e-desktop.yml | 108 +++++++++++++++++------------- 1 file changed, 61 insertions(+), 47 deletions(-) diff --git a/.github/workflows/e2e-desktop.yml b/.github/workflows/e2e-desktop.yml index c8f68be41342..c07a94be8a3e 100644 --- a/.github/workflows/e2e-desktop.yml +++ b/.github/workflows/e2e-desktop.yml @@ -98,13 +98,63 @@ jobs: 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. + # 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" @@ -130,11 +180,17 @@ jobs: fi echo "" >> "$GITHUB_STEP_SUMMARY" - RUN_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/actions/runs/${GITHUB_RUN_ID}" - echo "📥 Artifacts on the [run page](${RUN_URL}/artifacts):" >> "$GITHUB_STEP_SUMMARY" + echo "📥 **Artifacts:**" >> "$GITHUB_STEP_SUMMARY" echo "" >> "$GITHUB_STEP_SUMMARY" - echo "- \`playwright-test-results\` — all screenshots (actual + expected + diff) + traces" >> "$GITHUB_STEP_SUMMARY" - echo "- \`playwright-report\` — interactive HTML report" >> "$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" @@ -154,45 +210,3 @@ jobs: console.log('| 🔄 Flaky | ' + (stats.flaky || 0) + ' |'); " >> "$GITHUB_STEP_SUMMARY" 2>/dev/null || true fi - - # ── 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 - 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 - 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 - 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 From 0fd12ca11b511d56507e5060d06d27c597fb352a Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 17 Jul 2026 15:24:56 -0400 Subject: [PATCH 11/72] fix(desktop): kill boot overlay fade-race in e2e screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshots were catching the CONNECTING overlay and onboarding Preparing loading bar mid-transition because the wait helpers fire on text content while visual state lags behind. Fix at the source via reduced motion: - playwright.config.ts: emulate prefers-reduced-motion: reduce - styles.css: blanket reduced-motion rule kills all CSS animations/transitions - gateway-connecting-overlay.tsx: skip JS setTimeout exit choreography (text-out 360ms + hold 300ms + overlay fade 520ms) — jump straight to gone - decode-text.tsx: skip scramble interval, render resolved text immediately --- apps/desktop/playwright.config.ts | 8 +++++ .../components/gateway-connecting-overlay.tsx | 31 +++++++++++++++++-- .../desktop/src/components/ui/decode-text.tsx | 13 ++++++++ apps/desktop/src/styles.css | 16 ++++++++++ 4 files changed, 65 insertions(+), 3 deletions(-) diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 9ed34b2fea26..9a661e5fadd0 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -38,6 +38,14 @@ export default defineConfig({ 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. + reducedMotion: 'reduce', }, expect: { toHaveScreenshot: { diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index 79bc9935804c..d2050f11fed4 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -35,11 +35,20 @@ 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('live') // Once cold boot has completed once, never resurrect the fullscreen overlay // — soft gateway switches keep the shell and reskeleton the sidebar instead. @@ -74,16 +83,32 @@ export function GatewayConnectingOverlay() { return } - if (previewing) { + if(reduce) { + // Under reduced motion, skip straight to gone — no text-out, no hold, + // no overlay fade. The overlay unmounts immediately. + setPhase('gone') + } + + if (previewing) { + // Under reduced motion, skip straight to gone — no text-out, no hold, + // no overlay fade. The overlay unmounts immediately. + if(reduce) { + setPhase('gone') + + return + } + const id = window.setTimeout(() => setPhase('text-out'), PREVIEW_CONNECT_MS) return () => window.clearTimeout(id) } if (gatewayState === 'open' && shownRef.current) { - setPhase('text-out') + // Under reduced motion, skip straight to gone — no text-out, no hold, + // no overlay fade. The overlay unmounts immediately. + setPhase(reduce ? 'gone' : 'text-out') } - }, [phase, previewing, gatewayState]) + }, [phase, previewing, gatewayState, reduce]) // Advance the exit choreography: text-out -> overlay-out -> gone. useEffect(() => { diff --git a/apps/desktop/src/components/ui/decode-text.tsx b/apps/desktop/src/components/ui/decode-text.tsx index e5bc470ab076..243de60f6205 100644 --- a/apps/desktop/src/components/ui/decode-text.tsx +++ b/apps/desktop/src/components/ui/decode-text.tsx @@ -23,6 +23,10 @@ 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] @@ -62,6 +66,15 @@ 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 diff --git a/apps/desktop/src/styles.css b/apps/desktop/src/styles.css index d1e45ae6ed4b..86a87dbf0ea2 100644 --- a/apps/desktop/src/styles.css +++ b/apps/desktop/src/styles.css @@ -5,6 +5,22 @@ @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)); From b2be12d456efb55741fd0afdb8d2d08c6380d2d2 Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 17 Jul 2026 15:58:05 -0400 Subject: [PATCH 12/72] fix(desktop): waitForAppReady checks overlay coverage, not just composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Screenshots were catching the app mid-boot at ~92% with the onboarding Preparing progress bar still visible. waitForAppReady checked for the composer (textarea/contenteditable) with state:'visible', but Playwright considers an element visible even when a z-1300+ fixed overlay covers it (non-zero bounding box, not display:none). Now waits for the composer to be attached, then polls document.elementFromPoint at viewport center — if the topmost element is inside a position:fixed inset:0 overlay, the app isn't ready yet. --- apps/desktop/e2e/fixtures.ts | 54 ++++++++++++++++++++++++++++++++---- 1 file changed, 49 insertions(+), 5 deletions(-) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 257ddda53ff0..14da89a3af1e 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -523,18 +523,62 @@ export async function setupPackagedApp(): Promise { * 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 { const { page, app } = fixture - // The connecting overlay has a data-testid or we can check for the - // absence of boot indicators. The simplest reliable approach is to wait - // for the composer (chat input) to become visible — it's disabled until - // the gateway is open. + + // Wait for the composer to exist in the DOM (not necessarily interactive yet). await page.waitForSelector('textarea, [contenteditable="true"]', { - state: 'visible', + 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 From b2857110b40bb2a84caa28a39beed1e1f45ba196 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 10:56:23 -0400 Subject: [PATCH 13/72] fix(desktop): kill loading bar in boot-failure e2e screenshots MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The boot-failure screenshot showed a progress bar because of two bugs: 1. waitForBootFailure matched on "Let's get you setup" (the onboarding header that mounts from frame 1 during normal boot), so the screenshot fired at ~86% progress while the Preparing component's progress bar was still painted. 2. The Preparing component kept rendering the progress bar even after boot.error was set — it just turned the bar red and appended the error text below it. Fixes: - Preparing bails out (returns null) when boot.error is set, so BootFailureOverlay (z-1400) owns the screen exclusively. - applyDesktopBootProgress no longer clobbers a previously-set boot.error when a late progress event arrives with error: null — failDesktopBoot is terminal for the boot cycle. - waitForBootFailure guards against progress bars being visible and matches on actual failure signals (error toast, Retry/Repair buttons), not the onboarding header. - setupDeadBackend now accepts { fakeError: true } which injects HERMES_DESKTOP_BOOT_FAKE_ERROR to trigger a real boot failure — the previous dead-provider fixture never actually caused a boot failure (hermes serve starts fine; the dead endpoint only matters at chat time). - boot-failure.spec.ts updated to use { fakeError: true }. Verified: e2e test passes with 0 progress bars in the DOM at screenshot time (confirmed via DOM inspection), 16/16 vitest tests pass, typecheck clean. --- apps/desktop/e2e/boot-failure.spec.ts | 26 ++++------ apps/desktop/e2e/fixtures.ts | 51 +++++++++++++------ apps/desktop/electron/main.ts | 11 ++++ .../src/components/onboarding/index.tsx | 14 ++--- apps/desktop/src/store/boot.ts | 8 ++- 5 files changed, 71 insertions(+), 39 deletions(-) diff --git a/apps/desktop/e2e/boot-failure.spec.ts b/apps/desktop/e2e/boot-failure.spec.ts index 91433c3ccc89..4f316d356c6f 100644 --- a/apps/desktop/e2e/boot-failure.spec.ts +++ b/apps/desktop/e2e/boot-failure.spec.ts @@ -1,11 +1,11 @@ /** * E2E boot-failure tests — verify the app shows an error overlay when the - * backend can't reach the inference provider. + * backend can't start. * - * Launches the app with a provider pointing at a dead endpoint (port 1). - * The `hermes serve` backend starts, but when the renderer tries to connect - * or when a runtime check fails, the app should show a boot failure or - * onboarding error overlay. + * 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 — no progress bar should be + * visible. * * Prerequisite: `npm run build` must have been run so dist/ exists. */ @@ -26,17 +26,13 @@ test.afterAll(async () => { fixture = null }) -test.describe('boot failure with dead provider endpoint', () => { - test('app shows error state or onboarding', async () => { - fixture = await setupDeadBackend() +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 }) - // With a dead provider endpoint, the app should eventually show either: - // 1. A boot failure overlay (if the backend fails to start), or - // 2. An onboarding overlay with an error (if the runtime check fails) - // Both outcomes prove the app is handling provider failures gracefully. - // - // We give it a generous timeout — the backend needs to start, the - // renderer needs to boot, and then the runtime check needs to fail. await waitForBootFailure(fixture.page, 90_000) }) diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index 14da89a3af1e..aa8dd94d2518 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -387,12 +387,24 @@ export interface DeadBackendFixture { cleanup: () => Promise } +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). The boot should fail with a connection error, - * triggering the BootFailureOverlay. + * 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(): Promise { +export async function setupDeadBackend(options: DeadBackendOptions = {}): Promise { const sandbox = createSandbox('dead') const configPath = path.join(sandbox.hermesHome, 'config.yaml') fs.writeFileSync( @@ -415,7 +427,7 @@ providers: ) writeEnvFile(sandbox.hermesHome) - const env = buildAppEnv(sandbox) + 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 { @@ -636,25 +648,32 @@ export async function waitForOnboarding(page: Page, timeoutMs = 60_000): Promise export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promise { await page.waitForFunction( () => { - const root = document.getElementById('root') + // 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. + // Either way, no progress bar should be visible and an error must + // have surfaced (toast, boot-failure banner, or the overlay itself). + const hasProgressBar = Boolean( + document.querySelector('[role="progressbar"], .h-2.rounded-full.bg-muted') + ) - if (!root) { + if (hasProgressBar) { return false } - const text = root.textContent ?? '' + const text = document.body.textContent ?? '' - // A dead provider either produces a boot failure or falls back to the - // provider onboarding screen after the runtime check fails. - return ( - text.includes('error') || - text.includes('Error') || - text.includes('failed') || - text.includes('Failed') || + // BootFailureOverlay buttons. + const hasFailureUI = text.includes('Retry') || text.includes('Repair') || - text.includes("Let's get you setup") - ) + 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 }, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 517121c2061d..fb60adbf1bf9 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -542,6 +542,7 @@ 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) @@ -6999,6 +7000,16 @@ 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) { diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 87cc31648474..900d3f0c8739 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -355,9 +355,15 @@ function ReasonNotice({ reason }: { reason: string }) { function Preparing({ boot }: { boot: DesktopBootState }) { const { t } = useI18n() const progress = Math.max(2, Math.min(100, Math.round(boot.progress))) - const hasError = Boolean(boot.error) const installing = boot.phase.startsWith('runtime.') + // When boot fails, BootFailureOverlay (z-1400) owns the screen. Bail out + // here so we never paint a progress bar alongside the error overlay — + // E2E screenshots can catch the two mid-transition. + if (boot.error) { + return null + } + return (

@@ -365,10 +371,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) {

@@ -376,7 +379,6 @@ function Preparing({ boot }: { boot: DesktopBootState }) { {boot.message} {progress}%
- {hasError ?

{boot.error}

: null}
) } diff --git a/apps/desktop/src/store/boot.ts b/apps/desktop/src/store/boot.ts index f25be5089db4..5300bdfc7d9a 100644 --- a/apps/desktop/src/store/boot.ts +++ b/apps/desktop/src/store/boot.ts @@ -33,12 +33,16 @@ 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: progress.error ?? null, + error, progress: mergedProgress, - visible: progress.running || mergedProgress < 100 || Boolean(progress.error) + visible: progress.running || mergedProgress < 100 || Boolean(error) }) } From 470c7e2a608e75b9ee61dd1f2e10599983ca97ec Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 12:39:20 -0400 Subject: [PATCH 14/72] fix(desktop): prevent timers from shifting as they count (#68131) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LiveDuration returned a bare string with proportional digits, so the statusbar reflowed every second as the timer ticked. Extract a shared StableText component that renders each character in its own 1ch-wide cell, preventing any digit from shifting the layout — works with the proportional sans font, no need for font-mono. Both LiveDuration (statusbar session/running timers) and ActivityTimerText (tool activity timers) now use StableText, dropping the font-mono + tabular-nums workaround from the latter. Also renames statusbar.ts → statusbar.tsx since LiveDuration now returns JSX. --- .../assistant-ui/thread/status.test.tsx | 6 ++--- .../components/chat/activity-timer-text.tsx | 7 +++--- .../src/components/chat/stable-text.tsx | 23 +++++++++++++++++++ .../src/lib/{statusbar.ts => statusbar.tsx} | 7 +++++- 4 files changed, 36 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/src/components/chat/stable-text.tsx rename apps/desktop/src/lib/{statusbar.ts => statusbar.tsx} (92%) diff --git a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx index b4920e1ec483..f6edaee0d7e3 100644 --- a/apps/desktop/src/components/assistant-ui/thread/status.test.tsx +++ b/apps/desktop/src/components/assistant-ui/thread/status.test.tsx @@ -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) }) }) diff --git a/apps/desktop/src/components/chat/activity-timer-text.tsx b/apps/desktop/src/components/chat/activity-timer-text.tsx index aa439eb247d5..d9ca1c292e29 100644 --- a/apps/desktop/src/components/chat/activity-timer-text.tsx +++ b/apps/desktop/src/components/chat/activity-timer-text.tsx @@ -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 ( - {formatElapsed(seconds)} - + ) } diff --git a/apps/desktop/src/components/chat/stable-text.tsx b/apps/desktop/src/components/chat/stable-text.tsx new file mode 100644 index 000000000000..1e7e03815cd6 --- /dev/null +++ b/apps/desktop/src/components/chat/stable-text.tsx @@ -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 ( + + {children.split('').map((char, i) => ( + + {char} + + ))} + + ) +} diff --git a/apps/desktop/src/lib/statusbar.ts b/apps/desktop/src/lib/statusbar.tsx similarity index 92% rename from apps/desktop/src/lib/statusbar.ts rename to apps/desktop/src/lib/statusbar.tsx index b06f9cc0d60e..0c0d22599c58 100644 --- a/apps/desktop/src/lib/statusbar.ts +++ b/apps/desktop/src/lib/statusbar.tsx @@ -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 {formatDuration(now - since)} } From aa274364bb51ffe49c2a324df59aaae56d7ab947 Mon Sep 17 00:00:00 2001 From: nousbot-eng Date: Mon, 20 Jul 2026 12:46:35 -0400 Subject: [PATCH 15/72] fmt(js): `npm run fix` on merge (#68135) Co-authored-by: github-actions[bot] --- .../hooks/use-message-stream/gateway-event.ts | 1 + .../session/hooks/use-message-stream/index.ts | 29 ++++++++++--------- .../interim-sealing.test.tsx | 13 +++++++-- apps/desktop/src/lib/chat-messages.test.ts | 14 ++++----- apps/desktop/src/lib/chat-messages.ts | 5 +--- ui-tui/src/app/createGatewayEventHandler.ts | 2 ++ ui-tui/src/app/turnController.ts | 8 ++++- 7 files changed, 42 insertions(+), 30 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 1533181af11c..09ba6bdb52ad 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -409,6 +409,7 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { if (sessionId) { flushQueuedDeltas(sessionId) const text = coerceGatewayText(payload?.text) + if (text) { finalizeInterimAssistantMessage(sessionId, text) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index 7331ca946267..11a7878af069 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -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) } diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx index a31d20a9b4fe..2ee5bb720418 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-message-stream/interim-sealing.test.tsx @@ -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) diff --git a/apps/desktop/src/lib/chat-messages.test.ts b/apps/desktop/src/lib/chat-messages.test.ts index 64dc414642be..e63fae09cb89 100644 --- a/apps/desktop/src/lib/chat-messages.test.ts +++ b/apps/desktop/src/lib/chat-messages.test.ts @@ -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, '') diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts index 221f6a16a064..4bc50d32569a 100644 --- a/apps/desktop/src/lib/chat-messages.ts +++ b/apps/desktop/src/lib/chat-messages.ts @@ -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 => { diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index d708cc8ccfbb..f1c87665f286 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -948,12 +948,14 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return case 'message.interim': { const text = ev.payload?.text + if (typeof text === 'string' && text.trim()) { turnController.recordInterimMessage(text) } return } + case 'message.complete': { const { finalMessages, finalText, wasInterrupted } = turnController.recordMessageComplete(ev.payload ?? {}) diff --git a/ui-tui/src/app/turnController.ts b/ui-tui/src/app/turnController.ts index 63acc70276b9..a8afea727d99 100644 --- a/ui-tui/src/app/turnController.ts +++ b/ui-tui/src/app/turnController.ts @@ -555,7 +555,12 @@ class TurnController { this.flushPendingNotice() } - recordMessageComplete(payload: { rendered?: string; reasoning?: string; response_previewed?: boolean; text?: string }) { + recordMessageComplete(payload: { + rendered?: string + reasoning?: string + response_previewed?: boolean + text?: string + }) { this.closeReasoningSegment() // Ink renders markdown via ; the gateway's Rich-rendered ANSI @@ -687,6 +692,7 @@ class TurnController { } const authoritativeText = text.trimStart() + if (!authoritativeText) { return } From 9399839dd4d2c171de829af95d189491a38c9bf4 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 11:50:22 -0500 Subject: [PATCH 16/72] feat(desktop): keep-computer-awake toggle + System settings section MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a "keep computer awake" toggle (Claude-style) for long/overnight runs: the renderer owns the device-local pref and mirrors it to the Electron main process, which holds a single `powerSaveBlocker('prevent-app-suspension')` — the same authority split as translucency. Surfaced as a statusbar quick-toggle and a Settings row. Introduce a dedicated System settings section (device-local machine prefs) and de-crowd Appearance by moving Window Translucency + UI Scale into it (both are main-process/window-owned, not visual theme). Give Haptic Feedback its first Settings home there too (the titlebar quick-toggle stays). Relocated i18n copy into `settings.system` across en/zh/zh-hant/ja; wired the `system` route into the SettingsView union + allowlist + nav. --- apps/desktop/electron/main.ts | 9 ++ apps/desktop/electron/power-save.test.ts | 58 +++++++++ apps/desktop/electron/power-save.ts | 50 ++++++++ apps/desktop/electron/preload.ts | 1 + .../src/app/settings/appearance-settings.tsx | 61 ---------- apps/desktop/src/app/settings/index.tsx | 12 ++ .../src/app/settings/system-settings.tsx | 110 ++++++++++++++++++ apps/desktop/src/app/settings/types.ts | 1 + .../app/shell/hooks/use-statusbar-items.tsx | 13 ++- apps/desktop/src/global.d.ts | 1 + apps/desktop/src/i18n/en.ts | 23 +++- apps/desktop/src/i18n/ja.ts | 22 +++- apps/desktop/src/i18n/types.ts | 19 ++- apps/desktop/src/i18n/zh-hant.ts | 22 +++- apps/desktop/src/i18n/zh.ts | 22 +++- apps/desktop/src/store/keep-awake.test.ts | 40 +++++++ apps/desktop/src/store/keep-awake.ts | 32 +++++ 17 files changed, 406 insertions(+), 90 deletions(-) create mode 100644 apps/desktop/electron/power-save.test.ts create mode 100644 apps/desktop/electron/power-save.ts create mode 100644 apps/desktop/src/app/settings/system-settings.tsx create mode 100644 apps/desktop/src/store/keep-awake.test.ts create mode 100644 apps/desktop/src/store/keep-awake.ts diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 646eefc6eb2d..cac3031dee6a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -20,6 +20,7 @@ import { nativeTheme, Notification, powerMonitor, + powerSaveBlocker, protocol, safeStorage, screen, @@ -104,6 +105,7 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' +import { createKeepAwake } from './power-save' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { @@ -8554,6 +8556,13 @@ ipcMain.on('hermes:translucency', (_event, payload) => { } }) +// Keep-awake: the renderer owns the preference; main holds the one blocker. +const keepAwake = createKeepAwake(powerSaveBlocker) + +ipcMain.on('hermes:keep-awake', (_event, on) => { + keepAwake.set(Boolean(on)) +}) + ipcMain.handle('hermes:openExternal', (_event, url) => { if (!openExternalUrl(url)) { throw new Error('Invalid external URL') diff --git a/apps/desktop/electron/power-save.test.ts b/apps/desktop/electron/power-save.test.ts new file mode 100644 index 000000000000..97725e0ed019 --- /dev/null +++ b/apps/desktop/electron/power-save.test.ts @@ -0,0 +1,58 @@ +import { describe, expect, it, vi } from 'vitest' + +import { createKeepAwake, type PowerSaveBlockerLike } from './power-save' + +function fakeBlocker() { + let next = 1 + const started = new Set() + + const blocker: PowerSaveBlockerLike = { + isStarted: id => started.has(id), + start: vi.fn(() => { + const id = next++ + started.add(id) + + return id + }), + stop: vi.fn(id => void started.delete(id)) + } + + return { blocker, started } +} + +describe('createKeepAwake', () => { + it('starts once, is idempotent, and stops', () => { + const { blocker } = fakeBlocker() + const keepAwake = createKeepAwake(blocker) + + expect(keepAwake.isActive()).toBe(false) + expect(keepAwake.set(true)).toBe(true) + keepAwake.set(true) // idempotent — no second blocker + expect(blocker.start).toHaveBeenCalledTimes(1) + expect(blocker.start).toHaveBeenCalledWith('prevent-app-suspension') + + expect(keepAwake.set(false)).toBe(false) + keepAwake.set(false) + expect(blocker.stop).toHaveBeenCalledTimes(1) + }) + + it('re-arms after the OS dropped the blocker', () => { + const { blocker, started } = fakeBlocker() + const keepAwake = createKeepAwake(blocker) + + keepAwake.set(true) + started.clear() // system released it out from under us + expect(keepAwake.isActive()).toBe(false) + + keepAwake.set(true) + expect(blocker.start).toHaveBeenCalledTimes(2) + expect(keepAwake.isActive()).toBe(true) + }) + + it('honors a custom blocker type', () => { + const { blocker } = fakeBlocker() + createKeepAwake(blocker, 'prevent-display-sleep').set(true) + + expect(blocker.start).toHaveBeenCalledWith('prevent-display-sleep') + }) +}) diff --git a/apps/desktop/electron/power-save.ts b/apps/desktop/electron/power-save.ts new file mode 100644 index 000000000000..0939de99d80c --- /dev/null +++ b/apps/desktop/electron/power-save.ts @@ -0,0 +1,50 @@ +/** + * Keep-awake — hold a single machine-global power-save blocker. + * + * `prevent-app-suspension` stops the system from sleeping (long overnight + * agent runs keep going) while still letting the display dim. The renderer + * owns the preference (persisted in localStorage) and mirrors it here over + * IPC; the main process owns the one native blocker, same authority split as + * translucency/zoom. Electron auto-releases the blocker on quit. + */ + +export type KeepAwakeType = 'prevent-app-suspension' | 'prevent-display-sleep' + +/** The slice of Electron's `powerSaveBlocker` we use (injected for testing). */ +export interface PowerSaveBlockerLike { + start(type: KeepAwakeType): number + stop(id: number): void + isStarted(id: number): boolean +} + +export interface KeepAwake { + /** Turn the blocker on/off (idempotent). Returns the resulting state. */ + set(on: boolean): boolean + isActive(): boolean +} + +export function createKeepAwake( + blocker: PowerSaveBlockerLike, + type: KeepAwakeType = 'prevent-app-suspension' +): KeepAwake { + let id: null | number = null + + const isActive = () => id !== null && blocker.isStarted(id) + + return { + isActive, + set(on) { + if (on && !isActive()) { + id = blocker.start(type) + } else if (!on && id !== null) { + if (blocker.isStarted(id)) { + blocker.stop(id) + } + + id = null + } + + return isActive() + } + } +} diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 952fdfdc8dad..732d13a53668 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -79,6 +79,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { setTitleBarTheme: payload => ipcRenderer.send('hermes:titlebar-theme', payload), setNativeTheme: mode => ipcRenderer.send('hermes:native-theme', mode), setTranslucency: payload => ipcRenderer.send('hermes:translucency', payload), + setKeepAwake: on => ipcRenderer.send('hermes:keep-awake', on), setPreviewShortcutActive: active => ipcRenderer.send('hermes:previewShortcutActive', Boolean(active)), openExternal: url => ipcRenderer.invoke('hermes:openExternal', url), openPreviewInBrowser: url => ipcRenderer.invoke('hermes:openPreviewInBrowser', url), diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 32deb34d2d47..7b37f6844dd5 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -16,8 +16,6 @@ import { $backdrop, setBackdrop } from '@/store/backdrop' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' -import { $translucency, setTranslucency } from '@/store/translucency' -import { $zoomPercent, setZoomPercent } from '@/store/zoom' import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' import type { DesktopTheme } from '@/themes/types' @@ -64,18 +62,6 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) ) } -// UI scale presets, as zoom percentages. 100 is the browser-default size; -// the ids double as the percent values sent to the main process. A Cmd/Ctrl -// +/- step landing between presets highlights nothing, and the row -// description keeps showing the exact current percent. -const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const - -type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] - -function matchUiScalePreset(percent: number): UiScalePreset | null { - return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null -} - function useDebounced(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value) @@ -245,10 +231,8 @@ export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) - const zoomPercent = useStore($zoomPercent) const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) - const translucency = useStore($translucency) const backdrop = useStore($backdrop) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) @@ -293,10 +277,6 @@ export function AppearanceSettings() { { id: 'off', label: a.embedsOff } ] as const satisfies readonly { id: EmbedMode; label: string }[] - const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) - - const matchedScalePreset = matchUiScalePreset(zoomPercent) - return (
@@ -412,47 +392,6 @@ export function AppearanceSettings() { wide /> - { - triggerHaptic('selection') - setZoomPercent(Number(id)) - }} - options={uiScaleOptions} - value={matchedScalePreset ?? ('' as UiScalePreset)} - /> - } - description={a.uiScaleDesc(zoomPercent)} - title={a.uiScaleTitle} - /> - - - { - triggerHaptic('selection') - setTranslucency(Number(event.target.value)) - }} - step={5} - style={{ accentColor: 'var(--dt-primary)' }} - type="range" - value={translucency} - /> - - {translucency}% - -
- } - description={a.translucencyDesc} - title={a.translucencyTitle} - /> - setActiveView('notifications') }, + { + active: activeView === 'system', + icon: Cpu, + id: 'system', + label: t.settings.nav.system, + onSelect: () => setActiveView('system') + }, { active: activeView === 'billing', icon: BarChart3, @@ -316,6 +326,8 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'notifications' ? ( + ) : activeView === 'system' ? ( + ) : activeView === 'billing' ? ( ) : activeView === 'plugins' ? ( diff --git a/apps/desktop/src/app/settings/system-settings.tsx b/apps/desktop/src/app/settings/system-settings.tsx new file mode 100644 index 000000000000..87510e952f55 --- /dev/null +++ b/apps/desktop/src/app/settings/system-settings.tsx @@ -0,0 +1,110 @@ +import { useStore } from '@nanostores/react' + +import { SegmentedControl } from '@/components/ui/segmented-control' +import { Switch } from '@/components/ui/switch' +import { useI18n } from '@/i18n' +import { triggerHaptic } from '@/lib/haptics' +import { Cpu } from '@/lib/icons' +import { $hapticsMuted, setHapticsMuted } from '@/store/haptics' +import { $keepAwake, setKeepAwake } from '@/store/keep-awake' +import { $translucency, setTranslucency } from '@/store/translucency' +import { $zoomPercent, setZoomPercent } from '@/store/zoom' + +import { ListRow, SectionHeading, SettingsContent } from './primitives' + +// UI scale presets as zoom percentages (100 = browser default); the ids double +// as the percent sent to main. A Cmd/Ctrl +/- step between presets highlights +// nothing, and the row description keeps showing the exact current percent. +const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const + +type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] + +function ToggleRow(props: { checked: boolean; description: string; label: string; onChange: (on: boolean) => void }) { + return ( + { + triggerHaptic('selection') + props.onChange(on) + }} + /> + } + description={props.description} + title={props.label} + /> + ) +} + +export function SystemSettings() { + const { t } = useI18n() + const s = t.settings.system + const keepAwake = useStore($keepAwake) + const translucency = useStore($translucency) + const zoomPercent = useStore($zoomPercent) + const hapticsMuted = useStore($hapticsMuted) + + const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) + const matchedScale = UI_SCALE_PRESETS.find(preset => Number(preset) === zoomPercent) ?? ('' as UiScalePreset) + + return ( + + +

+ {s.intro} +

+ + + + { + triggerHaptic('selection') + setZoomPercent(Number(id)) + }} + options={uiScaleOptions} + value={matchedScale} + /> + } + description={s.uiScaleDesc(zoomPercent)} + title={s.uiScaleTitle} + /> + + + { + triggerHaptic('selection') + setTranslucency(Number(event.target.value)) + }} + step={5} + style={{ accentColor: 'var(--dt-primary)' }} + type="range" + value={translucency} + /> + + {translucency}% + + + } + description={s.translucencyDesc} + title={s.translucencyTitle} + /> + + setHapticsMuted(!on)} + /> +
+ ) +} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index 2828609ef63f..bc726d221bb3 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -14,6 +14,7 @@ export type SettingsView = | 'plugins' | 'providers' | 'sessions' + | 'system' | `config:${string}` export type EnvPatch = Partial> diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 7d73180217a7..94485b4fef45 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -9,11 +9,12 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' -import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons' +import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Sun, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' import { copyFilePath, revealFile } from '@/store/file-actions' +import { $keepAwake, toggleKeepAwake } from '@/store/keep-awake' import { revealFileInTree } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' import { $projectTree, projectNameForCwd } from '@/store/projects' @@ -90,6 +91,7 @@ export function useStatusbarItems({ const primaryActiveSessionId = useStore($activeSessionId) const activeGatewayProfile = useStore($activeGatewayProfile) const terminalTakeover = useStore($terminalTakeover) + const keepAwake = useStore($keepAwake) const primaryBusy = useStore($busy) const currentCwd = useStore($currentCwd) // Derive the workspace's project name from the already-cached project tree @@ -451,6 +453,14 @@ export function useStatusbarItems({ title: terminalTakeover ? copy.hideTerminal : copy.showTerminal, variant: 'action' }, + { + className: `w-7 justify-center px-0${keepAwake ? ' bg-accent/55 text-foreground' : ''}`, + icon: , + id: 'keep-awake', + onSelect: () => toggleKeepAwake(), + title: keepAwake ? copy.keepAwakeOn : copy.keepAwakeOff, + variant: 'action' + }, clientVersionItem, ...(backendVersionItem ? [backendVersionItem] : []) ], @@ -465,6 +475,7 @@ export function useStatusbarItems({ contextUsage, copy, currentUsage, + keepAwake, requestGateway, sessionStartedAt, gatewayState, diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index a37091ceeb49..9202c28e7fc6 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -88,6 +88,7 @@ declare global { setTitleBarTheme?: (payload: HermesTitleBarTheme) => void setNativeTheme?: (mode: 'dark' | 'light' | 'system') => void setTranslucency?: (payload: { intensity: number }) => void + setKeepAwake?: (on: boolean) => void setPreviewShortcutActive?: (active: boolean) => void openExternal: (url: string) => Promise openPreviewInBrowser?: (url: string) => Promise diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a0e79037f875..94a5a71da82f 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -325,7 +325,8 @@ export const en: Translations = { about: 'About', billing: 'Billing', notifications: 'Notifications', - plugins: 'Plugins' + plugins: 'Plugins', + system: 'System' }, plugins: { title: 'Desktop plugins', @@ -379,6 +380,19 @@ export const en: Translations = { completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.', completionSoundPreview: 'Preview' }, + system: { + title: 'System', + intro: 'How Hermes behaves on this machine. These are device-local — each computer keeps its own settings.', + keepAwakeTitle: 'Keep computer awake', + keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.', + translucencyTitle: 'Window Translucency', + translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', + uiScaleTitle: 'UI Scale', + uiScaleDesc: (percent: number) => + `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, + hapticsTitle: 'Haptic Feedback', + hapticsDesc: 'Trackpad taps on actions and toggles. Supported hardware only (macOS).' + }, sections: { model: 'Model', chat: 'Chat', @@ -410,11 +424,6 @@ export const en: Translations = { colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.', toolViewTitle: 'Tool Call Display', toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.', - uiScaleTitle: 'UI Scale', - uiScaleDesc: (percent: number) => - `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, - translucencyTitle: 'Window Translucency', - translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', backdropTitle: 'Chat Backdrop', backdropDesc: 'The faint statue image behind the conversation.', embedsTitle: 'Inline Embeds', @@ -2174,6 +2183,8 @@ export const en: Translations = { openCommandCenter: 'Open Command Center', showTerminal: 'Show terminal', hideTerminal: 'Hide terminal', + keepAwakeOn: 'Keeping awake — click to allow sleep', + keepAwakeOff: 'Keep computer awake', gateway: 'Gateway', gatewayReady: 'ready', gatewayNeedsSetup: 'needs setup', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index ed902c8ee8b7..d8f7473fb4fa 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -225,7 +225,8 @@ export const ja = defineLocale({ archivedChats: 'アーカイブ済みチャット', about: '情報', billing: '請求', - notifications: '通知' + notifications: '通知', + system: 'システム' }, notifications: { title: '通知', @@ -265,6 +266,18 @@ export const ja = defineLocale({ completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。', completionSoundPreview: '試聴' }, + system: { + title: 'システム', + intro: 'この端末での Hermes の動作。設定は端末ごとに保存されます。', + keepAwakeTitle: 'コンピューターをスリープさせない', + keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。', + translucencyTitle: 'ウィンドウの透過', + translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', + uiScaleTitle: 'UI スケール', + uiScaleDesc: (percent: number) => `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, + hapticsTitle: '触覚フィードバック', + hapticsDesc: '操作やトグル時にトラックパッドの触覚を返します。対応ハードウェアのみ(macOS)。' + }, sections: { model: 'モデル', chat: 'チャット', @@ -296,11 +309,6 @@ export const ja = defineLocale({ colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。', toolViewTitle: 'ツール呼び出しの表示', toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。', - uiScaleTitle: 'UI スケール', - uiScaleDesc: (percent: number) => - `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, - translucencyTitle: 'ウィンドウの透過', - translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', backdropTitle: 'チャット背景', backdropDesc: '会話の背後に表示される淡い彫像の画像。', embedsTitle: 'インライン埋め込み', @@ -2100,6 +2108,8 @@ export const ja = defineLocale({ openCommandCenter: 'コマンドセンターを開く', showTerminal: 'ターミナルを表示', hideTerminal: 'ターミナルを非表示', + keepAwakeOn: 'スリープ抑止中 — クリックで解除', + keepAwakeOff: 'コンピューターをスリープさせない', gateway: 'ゲートウェイ', gatewayReady: '準備完了', gatewayNeedsSetup: '設定が必要', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index ae2ec69bcdac..646901b4f818 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -284,6 +284,7 @@ export interface Translations { billing: string notifications: string plugins: string + system: string } plugins: { title: string @@ -317,6 +318,18 @@ export interface Translations { completionSoundDesc: string completionSoundPreview: string } + system: { + title: string + intro: string + keepAwakeTitle: string + keepAwakeDesc: string + translucencyTitle: string + translucencyDesc: string + uiScaleTitle: string + uiScaleDesc: (percent: number) => string + hapticsTitle: string + hapticsDesc: string + } sections: Record searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy> @@ -327,10 +340,6 @@ export interface Translations { colorModeDesc: string toolViewTitle: string toolViewDesc: string - uiScaleTitle: string - uiScaleDesc: (percent: number) => string - translucencyTitle: string - translucencyDesc: string backdropTitle: string backdropDesc: string embedsTitle: string @@ -1802,6 +1811,8 @@ export interface Translations { openCommandCenter: string showTerminal: string hideTerminal: string + keepAwakeOn: string + keepAwakeOff: string gateway: string gatewayReady: string gatewayNeedsSetup: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index c30818309a42..345a7935ac7a 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -219,7 +219,8 @@ export const zhHant = defineLocale({ archivedChats: '已封存聊天', about: '關於', billing: '帳單', - notifications: '通知' + notifications: '通知', + system: '系統' }, notifications: { title: '通知', @@ -258,6 +259,18 @@ export const zhHant = defineLocale({ completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。', completionSoundPreview: '預覽' }, + system: { + title: '系統', + intro: 'Hermes 在這台電腦上的行為。設定會依裝置保存,每台電腦各自獨立。', + keepAwakeTitle: '保持電腦喚醒', + keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。', + translucencyTitle: '視窗透明', + translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', + uiScaleTitle: '介面縮放', + uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, + hapticsTitle: '觸覺回饋', + hapticsDesc: '在操作與開關時觸發觸控板輕觸。僅支援對應硬體(macOS)。' + }, sections: { model: '模型', chat: '聊天', @@ -288,11 +301,6 @@ export const zhHant = defineLocale({ colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。', toolViewTitle: '工具呼叫顯示', toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', - uiScaleTitle: '介面縮放', - uiScaleDesc: (percent: number) => - `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, - translucencyTitle: '視窗透明', - translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', backdropTitle: '聊天背景', backdropDesc: '對話後方那張淡淡的雕像圖片。', embedsTitle: '內嵌預覽', @@ -2033,6 +2041,8 @@ export const zhHant = defineLocale({ openCommandCenter: '開啟命令中心', showTerminal: '顯示終端機', hideTerminal: '隱藏終端機', + keepAwakeOn: '保持喚醒中 — 點擊以允許睡眠', + keepAwakeOff: '保持電腦喚醒', gateway: '閘道', gatewayReady: '就緒', gatewayNeedsSetup: '需要設定', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 253e01326c61..a019bf3992dc 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -316,7 +316,8 @@ export const zh: Translations = { about: '关于', billing: '账单', notifications: '通知', - plugins: '插件' + plugins: '插件', + system: '系统' }, plugins: { title: '桌面插件', @@ -369,6 +370,18 @@ export const zh: Translations = { completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。', completionSoundPreview: '预览' }, + system: { + title: '系统', + intro: 'Hermes 在这台电脑上的行为。设置按设备保存,每台电脑各自独立。', + keepAwakeTitle: '保持电脑唤醒', + keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。', + translucencyTitle: '窗口透明', + translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', + uiScaleTitle: '界面缩放', + uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, + hapticsTitle: '触感反馈', + hapticsDesc: '在操作和开关时触发触控板轻触。仅支持相应硬件(macOS)。' + }, sections: { model: '模型', chat: '对话', @@ -399,11 +412,6 @@ export const zh: Translations = { colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。', toolViewTitle: '工具调用显示', toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', - uiScaleTitle: '界面缩放', - uiScaleDesc: (percent: number) => - `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, - translucencyTitle: '窗口透明', - translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', backdropTitle: '聊天背景', backdropDesc: '对话后方那张淡淡的雕像图片。', embedsTitle: '内嵌预览', @@ -2344,6 +2352,8 @@ export const zh: Translations = { openCommandCenter: '打开命令中心', showTerminal: '显示终端', hideTerminal: '隐藏终端', + keepAwakeOn: '保持唤醒中 — 点击以允许休眠', + keepAwakeOff: '保持电脑唤醒', gateway: '网关', gatewayReady: '就绪', gatewayNeedsSetup: '需要设置', diff --git a/apps/desktop/src/store/keep-awake.test.ts b/apps/desktop/src/store/keep-awake.test.ts new file mode 100644 index 000000000000..e6acffa9f93d --- /dev/null +++ b/apps/desktop/src/store/keep-awake.test.ts @@ -0,0 +1,40 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +import { storedBoolean } from '@/lib/storage' + +import { $keepAwake, setKeepAwake, toggleKeepAwake } from './keep-awake' + +const KEY = 'hermes.desktop.keepAwake.v1' +const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } +const initialHermesDesktop = desktopWindow.hermesDesktop +const setKeepAwakeBridge = vi.fn() + +beforeEach(() => { + desktopWindow.hermesDesktop = { setKeepAwake: setKeepAwakeBridge } as unknown as Window['hermesDesktop'] + setKeepAwake(false) + setKeepAwakeBridge.mockClear() +}) + +afterEach(() => { + desktopWindow.hermesDesktop = initialHermesDesktop +}) + +describe('keep-awake store', () => { + it('persists the pref and mirrors it to the main process', () => { + setKeepAwake(true) + expect($keepAwake.get()).toBe(true) + expect(storedBoolean(KEY, false)).toBe(true) + expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(true) + + setKeepAwake(false) + expect(storedBoolean(KEY, true)).toBe(false) + expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(false) + }) + + it('toggles the current value', () => { + toggleKeepAwake() + expect($keepAwake.get()).toBe(true) + toggleKeepAwake() + expect($keepAwake.get()).toBe(false) + }) +}) diff --git a/apps/desktop/src/store/keep-awake.ts b/apps/desktop/src/store/keep-awake.ts new file mode 100644 index 000000000000..b0db1cbfdb55 --- /dev/null +++ b/apps/desktop/src/store/keep-awake.ts @@ -0,0 +1,32 @@ +/** + * Keep-awake — stop the machine sleeping during long, unattended runs. + * + * A device-local preference (each computer keeps its own), off by default. The + * renderer owns the value and persists it; the main process holds the actual + * power-save blocker (see electron/power-save.ts) and re-reads this on every + * window load via the subscribe below. Linux/web builds without the bridge just + * no-op. + */ + +import { atom } from 'nanostores' + +import { persistBoolean, storedBoolean } from '@/lib/storage' + +const KEY = 'hermes.desktop.keepAwake.v1' + +export const $keepAwake = atom(typeof window === 'undefined' ? false : storedBoolean(KEY, false)) + +export function setKeepAwake(on: boolean): void { + $keepAwake.set(on) +} + +export function toggleKeepAwake(): void { + $keepAwake.set(!$keepAwake.get()) +} + +if (typeof window !== 'undefined') { + $keepAwake.subscribe(on => { + persistBoolean(KEY, on) + window.hermesDesktop?.setKeepAwake?.(on) + }) +} From 3133af82155d4816c98dab48316324e72194dfc2 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 13:14:41 -0400 Subject: [PATCH 17/72] fix(desktop): prevent duplicate messages when verification candidates are persisted (#68149) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The display_history_prefix calculation used by session.resume's _live_session_payload was display_history[:len(display) - len(raw)]. This assumed the model (repaired) history is always a suffix of the display history — i.e., repair_message_sequence only removes messages from the tail. That assumption broke when verification candidates (finish_reason=verification_required) were persisted to state.db (#65919): - repair collapses consecutive assistant messages, removing the verification candidate from the MODEL history - the candidate stays in the DISPLAY history (it's real persisted content) - the length gap (gap = len(display) - len(raw)) counts BOTH ancestor messages AND repair-removed tip messages - the prefix = display[:gap] grabs the first N display messages, which are tip messages (not ancestors) when there are no compression ancestors - _live_session_payload concatenates prefix + model_history, duplicating the first N messages On session 20260720_110036_a33889 (8 verification candidates), this duplicated the first 8 messages in every warm-cache session.activate response, producing visible duplicate user messages in the desktop. Fix: add SessionDB.get_ancestor_display_prefix() which returns ONLY genuine ancestor messages (rows where session_id != tip_session_id), identified at the row level before _rows_to_conversation strips session_id. Both resume paths (eager + deferred) now use this instead of the length-slice heuristic. Tests: - test_get_ancestor_display_prefix_single_session_returns_empty - test_get_ancestor_display_prefix_returns_ancestor_only_messages - Updated 12 mock DBs across test_protocol.py + test_tui_gateway_server.py - 848 passed (run_tests.sh), 0 regressions --- hermes_state.py | 41 ++++++++++++++++++++++++ tests/test_hermes_state.py | 51 ++++++++++++++++++++++++++++++ tests/test_tui_gateway_server.py | 12 +++++++ tests/tui_gateway/test_protocol.py | 24 ++++++++++++++ tui_gateway/server.py | 6 ++-- 5 files changed, 130 insertions(+), 4 deletions(-) diff --git a/hermes_state.py b/hermes_state.py index 398b1442d6f7..8d8ff6bc0213 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -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. diff --git a/tests/test_hermes_state.py b/tests/test_hermes_state.py index 414b0d69c605..dd6ecbc55182 100644 --- a/tests/test_hermes_state.py +++ b/tests/test_hermes_state.py @@ -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") diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index 6b9cd1b140ea..da815f9979c1 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -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 diff --git a/tests/tui_gateway/test_protocol.py b/tests/tui_gateway/test_protocol.py index 4fcce61e2dce..2b51d785e103 100644 --- a/tests/tui_gateway/test_protocol.py +++ b/tests/tui_gateway/test_protocol.py @@ -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 diff --git a/tui_gateway/server.py b/tui_gateway/server.py index bdf1f4d672c5..847479d46bd8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -6345,7 +6345,7 @@ def _(rid, params: dict) -> dict: # Display keeps the full transcript; the model-fed history drops a # dangling/interrupted tool-call tail so a session killed mid-loop does # not replay the unanswered call forever (#29086). - prefix = display_history[: max(0, len(display_history) - len(raw_history))] + prefix = db.get_ancestor_display_prefix(target) history = sanitize_replay_history(raw_history) # Restore the model/provider/reasoning/tier this chat last used so the # deferred build (and the info below) match the eager path — without them @@ -6421,9 +6421,7 @@ def _(rid, params: dict) -> dict: # re-issue the unanswered call forever — the permanent-"thinking" stuck # session in #29086. The messaging gateway already strips this; this is # the WebUI/TUI resume path picking up the same cleanup. - display_history_prefix = display_history[ - : max(0, len(display_history) - len(raw_history)) - ] + display_history_prefix = db.get_ancestor_display_prefix(target) history = sanitize_replay_history(raw_history) messages = _history_to_messages(display_history) tokens = _set_session_context(target) From b99e1e3bf65f56006a823e62de96a27e89a4f572 Mon Sep 17 00:00:00 2001 From: AIalliAI <285906080+AIalliAI@users.noreply.github.com> Date: Sat, 20 Jun 2026 06:17:11 +0000 Subject: [PATCH 18/72] fix(model): collapse kimi alias/canonical to one /model picker row MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A single Kimi credential surfaced two rows in the `/model` picker — the bare alias `kimi` (PROVIDER_TO_MODELS_DEV pass) and the canonical `kimi-coding` (CANONICAL_PROVIDERS cross-check, section 2b) — both backed by the same `kimi-for-coding` provider. `kimi`, `moonshot` and the canonical `kimi-coding` all map to one models.dev id (`kimi-for-coding`). The seen_mdev_ids guard collapses them to the first key in section 1, but that key is the bare alias, so 2b re-emits the canonical name as a second row. Emit the row under the canonical Hermes slug instead: resolve the alias via _PROVIDER_ALIASES (`kimi` -> `kimi-coding`) before appending, so 2b's seen_slugs check collapses the pair. This matches the picker's other alias rows (copilot, gemini) and the overlay slug-resolution contract, and keeps the surviving row resolvable to the real provider. A defensive seen_slugs guard prevents emitting a duplicate canonical row. Distinct providers keep their own row: `kimi-coding-cn` has its own KIMI_CN_API_KEY and is still emitted by section 2b. Regression tests assert the single-key case yields one `kimi-coding` row (fails on clean main, which shows both `kimi` and `kimi-coding`) and that the China endpoint is preserved. Fixes #49439 --- hermes_cli/model_switch.py | 25 ++++- .../hermes_cli/test_list_picker_providers.py | 100 ++++++++++++++++++ 2 files changed, 123 insertions(+), 2 deletions(-) diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index a054fced7f8f..268b9ecf15c1 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -1849,6 +1849,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 @@ -1940,14 +1941,34 @@ def list_authenticated_providers( else: top = model_ids[:max_models] if max_models is not None else model_ids - slug = hermes_id + # Emit under the CANONICAL Hermes slug, not the bare alias. A single + # credential can be reachable under several PROVIDER_TO_MODELS_DEV keys + # that all share one models.dev id (e.g. "kimi", "moonshot" and the + # canonical "kimi-coding" all map to "kimi-for-coding"). The + # seen_mdev_ids guard above already collapses them to the first key — + # but that first key is usually the bare alias ("kimi"), so emitting it + # verbatim leaves section 2b free to re-emit the canonical "kimi-coding" + # from CANONICAL_PROVIDERS: one key, two picker rows (#49439). Resolving + # to the canonical slug here lets 2b's seen_slugs check collapse the + # pair, matches the picker's other alias rows (copilot, gemini, …), and + # keeps the row resolvable to the real provider. + slug = _CANON_ALIASES.get(hermes_id.lower(), hermes_id) + if slug.lower() in seen_slugs: + # Canonical already emitted by an earlier alias in this pass; don't + # add a second row for the same provider. + seen_mdev_ids.add(mdev_id) + continue pinfo = _mdev_pinfo(mdev_id) display_name = 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, diff --git a/tests/hermes_cli/test_list_picker_providers.py b/tests/hermes_cli/test_list_picker_providers.py index a2042a96db59..56d759ce4b7a 100644 --- a/tests/hermes_cli/test_list_picker_providers.py +++ b/tests/hermes_cli/test_list_picker_providers.py @@ -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 From 2ffdf08376756771891289b49e40fd2de8c061a4 Mon Sep 17 00:00:00 2001 From: Almurat Date: Fri, 26 Jun 2026 22:19:27 +0800 Subject: [PATCH 19/72] fix: show both kimi-coding and kimi-coding-cn in /model picker MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Both providers share the same models.dev ID (kimi-for-coding) but have different API keys (KIMI_API_KEY vs KIMI_CN_API_KEY) and base URLs (moonshot.ai vs moonshot.cn). The /model picker was only showing one because the dedup key was mdev_id alone. Changes in list_authenticated_providers(): - Resolve canonical provider profile name and skip alias hermes_ids (e.g. "kimi", "moonshot" → "kimi-coding") so only canonical entries are processed. - Deduplicate by slug (hermes_id) instead of mdev_id so distinct profiles sharing a models.dev ID (kimi-coding vs kimi-coding-cn) both appear. - Prefer PROVIDER_REGISTRY name for the display label so the CN variant shows "Kimi / Moonshot (China)" instead of the generic models.dev name. Adds test coverage for all three key scenarios: - Only KIMI_CN_API_KEY set → only kimi-coding-cn appears - Only KIMI_API_KEY set → only kimi-coding appears - Both keys set → both providers appear, aliases not duplicated Closes #10526 --- hermes_cli/model_switch.py | 49 ++++---- .../test_kimi_cn_provider_listing.py | 119 ++++++++++++++++++ 2 files changed, 144 insertions(+), 24 deletions(-) create mode 100644 tests/hermes_cli/test_kimi_cn_provider_listing.py diff --git a/hermes_cli/model_switch.py b/hermes_cli/model_switch.py index 268b9ecf15c1..a86b1316e99d 100644 --- a/hermes_cli/model_switch.py +++ b/hermes_cli/model_switch.py @@ -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() @@ -1867,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 @@ -1941,25 +1960,8 @@ def list_authenticated_providers( else: top = model_ids[:max_models] if max_models is not None else model_ids - # Emit under the CANONICAL Hermes slug, not the bare alias. A single - # credential can be reachable under several PROVIDER_TO_MODELS_DEV keys - # that all share one models.dev id (e.g. "kimi", "moonshot" and the - # canonical "kimi-coding" all map to "kimi-for-coding"). The - # seen_mdev_ids guard above already collapses them to the first key — - # but that first key is usually the bare alias ("kimi"), so emitting it - # verbatim leaves section 2b free to re-emit the canonical "kimi-coding" - # from CANONICAL_PROVIDERS: one key, two picker rows (#49439). Resolving - # to the canonical slug here lets 2b's seen_slugs check collapse the - # pair, matches the picker's other alias rows (copilot, gemini, …), and - # keeps the row resolvable to the real provider. - slug = _CANON_ALIASES.get(hermes_id.lower(), hermes_id) - if slug.lower() in seen_slugs: - # Canonical already emitted by an earlier alias in this pass; don't - # add a second row for the same provider. - seen_mdev_ids.add(mdev_id) - continue 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, @@ -1975,7 +1977,6 @@ def list_authenticated_providers( "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) --- diff --git a/tests/hermes_cli/test_kimi_cn_provider_listing.py b/tests/hermes_cli/test_kimi_cn_provider_listing.py new file mode 100644 index 000000000000..5d7f473e3249 --- /dev/null +++ b/tests/hermes_cli/test_kimi_cn_provider_listing.py @@ -0,0 +1,119 @@ +"""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 + + +# -- 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)" + ) From 52e16c11384646972684c6d53189fc94ceb2f199 Mon Sep 17 00:00:00 2001 From: Almurat Date: Tue, 30 Jun 2026 16:38:01 +0800 Subject: [PATCH 20/72] fix: preserve kimi-coding-cn provider identity --- hermes_cli/providers.py | 18 ++++++ .../test_kimi_cn_provider_listing.py | 59 ++++++++++++++++++- 2 files changed, 76 insertions(+), 1 deletion(-) diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index c3e8efd22290..128649cc17c9 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -765,6 +765,24 @@ def resolve_provider_full( if user_pdef is not None: return user_pdef + # 0.5 Exact Hermes provider IDs must win over alias collapsing. + # Example: kimi-coding-cn should stay distinct from kimi-coding instead of + # normalizing through the shared models.dev alias "kimi-for-coding". + try: + from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY + _pcfg = _AUTH_PROVIDER_REGISTRY.get(raw) + if _pcfg is not None: + 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: diff --git a/tests/hermes_cli/test_kimi_cn_provider_listing.py b/tests/hermes_cli/test_kimi_cn_provider_listing.py index 5d7f473e3249..1a49b0d8fd1a 100644 --- a/tests/hermes_cli/test_kimi_cn_provider_listing.py +++ b/tests/hermes_cli/test_kimi_cn_provider_listing.py @@ -14,7 +14,12 @@ through. import os from unittest.mock import patch -from hermes_cli.model_switch import list_authenticated_providers +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 ------------------------------------------------ @@ -117,3 +122,55 @@ def test_kimi_aliases_not_listed_separately(): 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" From 456f18b19c4208115acbf0c6b226af49916b5480 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 09:48:04 -0700 Subject: [PATCH 21/72] fix(picker): scope exact-ID resolution to lossy alias collapses only MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The cherry-picked resolve_provider_full 0.5 step returned a generic openai_chat ProviderDef for ANY registry ID, hijacking single-entry alias rewrites like copilot -> github-copilot away from their overlay transports (test_explicit_copilot_switch_uses_selected_model_api_mode regression). Restrict the early return to names where MULTIPLE registry providers collapse to one canonical (kimi-coding + kimi-coding-cn + kimi + moonshot -> kimi-for-coding) — the only case where alias resolution actually loses information. Also maps Almurat123's contributor email. --- .../emails/almurat@Almurats-MacBook-Pro.local | 1 + hermes_cli/providers.py | 42 ++++++++++++------- 2 files changed, 28 insertions(+), 15 deletions(-) create mode 100644 contributors/emails/almurat@Almurats-MacBook-Pro.local diff --git a/contributors/emails/almurat@Almurats-MacBook-Pro.local b/contributors/emails/almurat@Almurats-MacBook-Pro.local new file mode 100644 index 000000000000..fbd1de9e41e6 --- /dev/null +++ b/contributors/emails/almurat@Almurats-MacBook-Pro.local @@ -0,0 +1 @@ +Almurat123 diff --git a/hermes_cli/providers.py b/hermes_cli/providers.py index 128649cc17c9..2303e2141158 100644 --- a/hermes_cli/providers.py +++ b/hermes_cli/providers.py @@ -765,23 +765,35 @@ def resolve_provider_full( if user_pdef is not None: return user_pdef - # 0.5 Exact Hermes provider IDs must win over alias collapsing. + # 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". - try: - from hermes_cli.auth import PROVIDER_REGISTRY as _AUTH_PROVIDER_REGISTRY - _pcfg = _AUTH_PROVIDER_REGISTRY.get(raw) - if _pcfg is not None: - 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 + # 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) From 6ddbe8e5a40ee90ebd6ad2a0f5164dff95b37300 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 13:19:37 -0400 Subject: [PATCH 22/72] revert(installer): revert managed uv changes for now windows e2e ain't ready yet --- hermes_cli/managed_uv.py | 76 ++++++---------------------------------- 1 file changed, 10 insertions(+), 66 deletions(-) diff --git a/hermes_cli/managed_uv.py b/hermes_cli/managed_uv.py index 90ecbaac5671..78c8f469003a 100644 --- a/hermes_cli/managed_uv.py +++ b/hermes_cli/managed_uv.py @@ -16,8 +16,6 @@ import platform import shutil import subprocess import tempfile -import urllib.request -import zipfile from pathlib import Path from typing import Optional @@ -241,70 +239,16 @@ def _install_uv_posix(env: dict[str, str]) -> None: def _install_uv_windows(env: dict[str, str]) -> None: - """Download the uv binary zip directly from GitHub releases. - - We intentionally do NOT run the astral installer script - (``irm https://astral.sh/uv/install.ps1 | iex``) anymore. That script - calls ``Get-ExecutionPolicy`` internally (from the - ``Microsoft.PowerShell.Security`` module), and on some Windows installs - that module fails to load -- killing the installer before it can download - anything. Downloading the zip ourselves with stdlib avoids spawning any - PowerShell child process at all, sidestepping the broken module entirely. - """ - # Detect the real OS architecture. platform.machine() reports the - # emulated view (AMD64 on ARM under Prism), so prefer the env vars that - # reflect the actual hardware. Mirrors Get-WindowsArch in install.ps1. - proc_arch = ( - os.environ.get("PROCESSOR_ARCHITEW6432") - or os.environ.get("PROCESSOR_ARCHITECTURE", "") - ).upper() - if proc_arch in ("ARM64",): - target_triple = "aarch64-pc-windows-msvc" - elif proc_arch in ("AMD64", "X64"): - target_triple = "x86_64-pc-windows-msvc" - elif proc_arch in ("X86",): - target_triple = "i686-pc-windows-msvc" - else: - # Fallback: platform.machine(). On native x64 this is "AMD64". - machine = platform.machine().upper() - if machine in ("ARM64", "AARCH64"): - target_triple = "aarch64-pc-windows-msvc" - elif machine in ("AMD64", "X64"): - target_triple = "x86_64-pc-windows-msvc" - else: - target_triple = "i686-pc-windows-msvc" - - zip_name = f"uv-{target_triple}.zip" - urls = [ - f"https://github.com/astral-sh/uv/releases/latest/download/{zip_name}", - f"https://releases.astral.sh/github/uv/releases/latest/download/{zip_name}", - ] - - with tempfile.TemporaryDirectory() as tmp: - zip_path = Path(tmp) / zip_name - last_err: Exception | None = None - for url in urls: - try: - logging.debug("Downloading uv from %s", url) - urllib.request.urlretrieve(url, zip_path) - break - except Exception as exc: - last_err = exc - logging.debug("Download failed from %s: %s", url, exc) - else: - raise RuntimeError( - f"Failed to download uv from all mirrors: {last_err}" - ) from last_err - - with zipfile.ZipFile(zip_path) as zf: - zf.extractall(tmp) - - # Move every .exe from the archive into the target's parent (the - # managed bin dir). The zip layout is flat (uv.exe, uvx.exe) but - # handle nested just in case. - bin_dir = env.get("UV_INSTALL_DIR") or str(Path(env.get("UV_UNMANAGED_INSTALL", "")).parent) - for exe in Path(tmp).rglob("*.exe"): - shutil.copy2(exe, Path(bin_dir) / exe.name) + """Invoke the PowerShell installer.""" + cmd = ( + 'irm https://astral.sh/uv/install.ps1 | iex' + ) + subprocess.run( + ["powershell", "-ExecutionPolicy", "Bypass", "-c", cmd], + env=env, + check=True, + capture_output=True, + ) def rebuild_venv(uv_bin: str, venv_dir: Path, python_version: str = "3.11") -> bool: True # dont remove me. ask ethernet \ No newline at end of file From 9b513a3b8d9752432c69dadce998ff1ce2a70c9c Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 12:31:06 -0500 Subject: [PATCH 23/72] refactor(desktop): hoist shared ToggleRow into settings primitives System + Notifications each had an identical local ToggleRow; lift one haptic-baked version into primitives and reuse it. Net -12 lines. --- .../app/settings/notifications-settings.tsx | 29 +-------------- apps/desktop/src/app/settings/primitives.tsx | 35 +++++++++++++++++++ .../src/app/settings/system-settings.tsx | 22 +----------- 3 files changed, 37 insertions(+), 49 deletions(-) diff --git a/apps/desktop/src/app/settings/notifications-settings.tsx b/apps/desktop/src/app/settings/notifications-settings.tsx index efb0bf662f6e..b2810f691fed 100644 --- a/apps/desktop/src/app/settings/notifications-settings.tsx +++ b/apps/desktop/src/app/settings/notifications-settings.tsx @@ -3,7 +3,6 @@ import type { ReactNode } from 'react' import { Button } from '@/components/ui/button' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' -import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' import { COMPLETION_SOUND_VARIANTS, previewCompletionSound } from '@/lib/completion-sound' import { triggerHaptic } from '@/lib/haptics' @@ -20,7 +19,7 @@ import { import { notify } from '@/store/notifications' import { CONTROL_TEXT } from './constants' -import { ListRow, SectionHeading, SettingsContent } from './primitives' +import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' const CAPTION = 'text-[length:var(--conversation-caption-font-size)] text-(--ui-text-tertiary)' @@ -28,32 +27,6 @@ function Caption({ children, className }: { children: ReactNode; className?: str return

{children}

} -function ToggleRow(props: { - checked: boolean - description: string - disabled?: boolean - label: string - onChange: (on: boolean) => void -}) { - return ( - { - triggerHaptic('selection') - props.onChange(on) - }} - /> - } - description={props.description} - title={props.label} - /> - ) -} - export function NotificationsSettings() { const { t } = useI18n() const prefs = useStore($nativeNotifyPrefs) diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index 2d9d1906ef5f..fd53b2d32b6f 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -3,6 +3,8 @@ import type { ReactNode } from 'react' import { PageLoader } from '@/components/page-loader' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' +import { Switch } from '@/components/ui/switch' +import { triggerHaptic } from '@/lib/haptics' import type { IconComponent } from '@/lib/icons' import { cn } from '@/lib/utils' @@ -108,6 +110,39 @@ export function ListRow({ ) } +// A labelled on/off row — the canonical device-pref switch (haptic baked in). +export function ToggleRow({ + checked, + description, + disabled, + label, + onChange +}: { + checked: boolean + description?: string + disabled?: boolean + label: string + onChange: (on: boolean) => void +}) { + return ( + { + triggerHaptic('selection') + onChange(on) + }} + /> + } + description={description} + title={label} + /> + ) +} + export function LoadingState({ label }: { label: string }) { return } diff --git a/apps/desktop/src/app/settings/system-settings.tsx b/apps/desktop/src/app/settings/system-settings.tsx index 87510e952f55..d338f5962a60 100644 --- a/apps/desktop/src/app/settings/system-settings.tsx +++ b/apps/desktop/src/app/settings/system-settings.tsx @@ -1,7 +1,6 @@ import { useStore } from '@nanostores/react' import { SegmentedControl } from '@/components/ui/segmented-control' -import { Switch } from '@/components/ui/switch' import { useI18n } from '@/i18n' import { triggerHaptic } from '@/lib/haptics' import { Cpu } from '@/lib/icons' @@ -10,7 +9,7 @@ import { $keepAwake, setKeepAwake } from '@/store/keep-awake' import { $translucency, setTranslucency } from '@/store/translucency' import { $zoomPercent, setZoomPercent } from '@/store/zoom' -import { ListRow, SectionHeading, SettingsContent } from './primitives' +import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' // UI scale presets as zoom percentages (100 = browser default); the ids double // as the percent sent to main. A Cmd/Ctrl +/- step between presets highlights @@ -19,25 +18,6 @@ const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] -function ToggleRow(props: { checked: boolean; description: string; label: string; onChange: (on: boolean) => void }) { - return ( - { - triggerHaptic('selection') - props.onChange(on) - }} - /> - } - description={props.description} - title={props.label} - /> - ) -} - export function SystemSettings() { const { t } = useI18n() const s = t.settings.system From a632e68a0173e4cfb92039580a836397ac3e08ef Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 17 Jul 2026 14:49:14 -0400 Subject: [PATCH 24/72] =?UTF-8?q?fix(lsp):=20never=20report=20stale=20diag?= =?UTF-8?q?nostics=20=E2=80=94=20wait=20for=20fresh=20post-edit=20data?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Slow language servers (tsserver on large projects especially) publish diagnostics long after an edit. The client's wait/report path had three holes that together surfaced the PREVIOUS edit's errors as if they were current ("ghost diagnostics"), sending the agent chasing errors it had already fixed: 1. open_file only cleared the diagnostic stores on first open — on the didChange path (every subsequent edit) stale push/pull entries survived. 2. wait_for_diagnostics' predicates were satisfiable by that leftover state (`path in _published`, `path in _pull_diagnostics`), so the "wait" often returned instantly with old data. 3. diagnostics_for merged the stale push store unconditionally, so even a fresh clean pull got the old error merged back in. Fix: anchor freshness on a per-file didChange timestamp. - Pull results record their request send-time and are dropped when a didChange raced past them; the pull store is invalidated on every change, not just first open. - wait_for_diagnostics now returns bool (fresh data vs timeout), only counts pushes published at/after the change (and version >= ours when the server echoes versions), and accepts an explicit timeout — the user's lsp.wait_timeout config now actually controls the inner wait budget instead of only the outer thread-join. - diagnostics_for(fresh_only=True) excludes stores that predate the latest change; all manager report paths use it. - On timeout the manager returns [] ("no data") instead of stale state, logs a WARNING via eventlog, and does NOT mark the server broken — slow is not dead. - seed-on-first-push no longer marks the file published, so the TS seed push can't satisfy a waiter. Tests: new "stale" and "slow_push" mock-server scripts model the slow tsserver, plus client- and service-level regression tests (tests/agent/lsp/test_stale_diagnostics.py). --- agent/lsp/client.py | 144 ++++++++++--- agent/lsp/manager.py | 51 ++++- tests/agent/lsp/_mock_lsp_server.py | 70 ++++-- tests/agent/lsp/test_stale_diagnostics.py | 249 ++++++++++++++++++++++ website/docs/user-guide/features/lsp.md | 13 ++ 5 files changed, 476 insertions(+), 51 deletions(-) create mode 100644 tests/agent/lsp/test_stale_diagnostics.py diff --git a/agent/lsp/client.py b/agent/lsp/client.py index 2aab98c2b76f..e1f615596eee 100644 --- a/agent/lsp/client.py +++ b/agent/lsp/client.py @@ -198,6 +198,17 @@ class LSPClient: 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-path loop time of the most recent didOpen/didChange we + # sent. Freshness anchor: pushes/pulls that predate this are + # results for OLD content and must never satisfy a waiter or + # be reported to the agent (the "ghost diagnostics" bug — + # slow servers like tsserver publish long after the edit, so + # leftovers from the previous edit look like current errors). + self._changed_at: Dict[str, float] = {} + # Per-path loop time at which the most recent *stored* pull + # request was sent. Send-time (not receipt) so a didChange + # racing a pull in flight correctly invalidates the result. + self._pulled_at: Dict[str, float] = {} # Capability registrations — only diagnostic ones are tracked. self._diagnostic_registrations: Dict[str, Dict[str, Any]] = {} @@ -650,15 +661,13 @@ class LSPClient: 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). + # First push: seed the store without marking the file as + # "published" and without firing the event. The very first + # push arrives before the user-triggered didChange could've + # produced fresh diagnostics, so it must never satisfy a + # waiter — it's baseline data only. 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 return self._push_diagnostics[path] = diagnostics @@ -725,6 +734,12 @@ class LSPClient: }, ) self._files[abs_path] = {"version": new_version, "text": text} + # Anchor freshness at this change: any pull result stored + # before now describes the PREVIOUS content and must not be + # reported (it would resurrect just-fixed errors as ghosts). + self._changed_at[abs_path] = asyncio.get_event_loop().time() + self._pull_diagnostics.pop(abs_path, None) + self._pulled_at.pop(abs_path, None) return new_version # First open: didChangeWatchedFiles CREATED + didOpen. @@ -738,6 +753,8 @@ class LSPClient: self._pull_diagnostics.pop(abs_path, None) self._published.pop(abs_path, None) self._published_version.pop(abs_path, None) + self._pulled_at.pop(abs_path, None) + self._changed_at[abs_path] = asyncio.get_event_loop().time() await self._send_notification( "textDocument/didOpen", { @@ -771,10 +788,17 @@ class LSPClient: Stores results into :attr:`_pull_diagnostics`. Silently no-ops on errors (server may not support the pull endpoint). + + The request's *send time* is recorded in :attr:`_pulled_at` + so freshness checks can compare against :attr:`_changed_at`. + A result whose request predates the latest didChange is + dropped — it describes content that no longer exists. """ + abs_path = os.path.abspath(path) + sent_at = asyncio.get_event_loop().time() 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", @@ -786,9 +810,18 @@ class LSPClient: return if not isinstance(result, dict): return + if sent_at < self._changed_at.get(abs_path, 0.0): + # The document changed while this pull was in flight — the + # server answered for the OLD text. Storing it would + # resurrect ghost diagnostics. + logger.debug( + "[%s] dropping stale pull result for %s", self.server_id, abs_path + ) + return items = result.get("items") if isinstance(items, list): - self._pull_diagnostics[os.path.abspath(path)] = items + self._pull_diagnostics[abs_path] = items + self._pulled_at[abs_path] = sent_at related = result.get("relatedDocuments") if isinstance(related, dict): for uri, sub in related.items(): @@ -796,7 +829,36 @@ class LSPClient: continue sub_items = sub.get("items") if isinstance(sub_items, list): - self._pull_diagnostics[uri_to_path(uri)] = sub_items + rel_path = uri_to_path(uri) + if sent_at < self._changed_at.get(rel_path, 0.0): + continue + self._pull_diagnostics[rel_path] = sub_items + self._pulled_at[rel_path] = sent_at + + def _has_fresh_push(self, path: str, version: int) -> bool: + """True iff a *fresh* publishDiagnostics has arrived for ``path``. + + Fresh means: published at/after the latest didOpen/didChange we + sent, AND (when the server echoes versions) for a version >= the + one we're waiting on. A stale push left over from the previous + edit cycle satisfies neither and must not end a wait early — + that's exactly the "ghost diagnostics" failure mode with slow + servers like tsserver. + """ + published_at = self._published.get(path) + if published_at is None: + return False + if published_at < self._changed_at.get(path, 0.0): + return False + current_v = self._published_version.get(path) + return current_v is None or current_v >= version + + def _has_fresh_pull(self, path: str) -> bool: + """True iff the stored pull result postdates the latest change.""" + pulled_at = self._pulled_at.get(path) + if pulled_at is None: + return path in self._pull_diagnostics + return pulled_at >= self._changed_at.get(path, 0.0) async def wait_for_diagnostics( self, @@ -804,22 +866,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 +914,22 @@ 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 + if self._has_fresh_push(abs_path, version): + return True - # Pull may have populated _pull_diagnostics — that's also - # success. - if abs_path in self._pull_diagnostics: - return + # Pull may have populated _pull_diagnostics with a + # post-change answer — that's also success. + if self._has_fresh_pull(abs_path): + 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): + if self._has_fresh_push(path, 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,14 +960,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 + data postdates the latest didOpen/didChange for the file — + 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) + if fresh_only: + changed_at = self._changed_at.get(abs_path, 0.0) + push_fresh = self._published.get(abs_path, -1.0) >= changed_at + pulled_at = self._pulled_at.get(abs_path) + pull_fresh = pulled_at is not None and pulled_at >= changed_at + push = (self._push_diagnostics.get(abs_path) or []) if push_fresh else [] + pull = (self._pull_diagnostics.get(abs_path) or []) if pull_fresh else [] + return _dedupe(push, pull) push = self._push_diagnostics.get(abs_path) or [] pull = self._pull_diagnostics.get(abs_path) or [] return _dedupe(push, pull) diff --git a/agent/lsp/manager.py b/agent/lsp/manager.py index aebb4881c96e..d3b4244790b1 100644 --- a/agent/lsp/manager.py +++ b/agent/lsp/manager.py @@ -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) diff --git a/tests/agent/lsp/_mock_lsp_server.py b/tests/agent/lsp/_mock_lsp_server.py index 619b8da233f1..316e914569aa 100644 --- a/tests/agent/lsp/_mock_lsp_server.py +++ b/tests/agent/lsp/_mock_lsp_server.py @@ -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( { diff --git a/tests/agent/lsp/test_stale_diagnostics.py b/tests/agent/lsp/test_stale_diagnostics.py new file mode 100644 index 000000000000..8090540945e8 --- /dev/null +++ b/tests/agent/lsp/test_stale_diagnostics.py @@ -0,0 +1,249 @@ +"""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 be stored after a + didChange raced past it (send-time 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) + assert client._has_fresh_pull(os.path.abspath(str(f))) + + # Simulate an edit racing in: the change invalidates the pull. + f.write_text("good code\n") + await client.open_file(str(f), language_id="python") + assert not client._has_fresh_pull(os.path.abspath(str(f))) + assert os.path.abspath(str(f)) not in client._pull_diagnostics + 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() diff --git a/website/docs/user-guide/features/lsp.md b/website/docs/user-guide/features/lsp.md index 50df342792bb..25dce057c0ff 100644 --- a/website/docs/user-guide/features/lsp.md +++ b/website/docs/user-guide/features/lsp.md @@ -151,6 +151,12 @@ lsp: # How long to wait for diagnostics after each write. wait_mode: document # "document" or "full" + # Max seconds to wait for the server to re-check the file after an + # edit. Only *fresh* diagnostics (produced for the post-edit + # content) are ever reported; if the server doesn't finish within + # this budget, the edit reports "no LSP data" rather than stale + # errors from before the edit. Raise this for slow servers on big + # projects (tsserver, rust-analyzer mid-indexing). wait_timeout: 5.0 # How to handle missing server binaries. @@ -209,6 +215,13 @@ budget is `wait_timeout` seconds — typically the server responds in tens of milliseconds for pyright/tsserver and a few seconds for rust-analyzer mid-indexing. +Diagnostics are **freshness-gated**: a result only counts when the +server produced it for the content of the current edit (a +`publishDiagnostics` push at/after the change, or a pull request +answered after it). Slow servers that haven't re-checked yet result +in "no data" for that edit — never in yesterday's errors being +re-reported as current. + Servers are kept alive for the life of the Hermes process. There's no idle-timeout reaper — the cost of restarting the server's index on every write would be far higher than holding the daemon. From eb09df6ec8db2dedd9151fc5b94e32b6fd3685ee Mon Sep 17 00:00:00 2001 From: ethernet Date: Fri, 17 Jul 2026 15:01:43 -0400 Subject: [PATCH 25/72] refactor(lsp): version-tagged _DocState replaces timestamp freshness tracking MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The staleness fix (f9b1fd799) bolted two wall-clock dicts (_changed_at, _pulled_at) onto a client that already scattered per-document state across six parallel dicts (_files, _push_diagnostics, _pull_diagnostics, _published, _published_version, _first_push_seen) — eight maps kept in sync by hand. Collapse all of it into one _DocState per path, and use the LSP document version as the freshness token instead of clocks: - didChange bumps doc.version; stored push/pull results carry the version they describe (push_version from the server's echoed version, or the current version at receipt for servers that don't echo one; pull_version captured at request send so an in-flight pull that a didChange races past is stale on arrival). - fresh == tag >= version. Invalidation is implicit in the bump — no store-clearing, no clock comparisons, no race windows. - _has_fresh_push/_has_fresh_pull helpers dissolve into two one-line _DocState methods; diagnostics_for(fresh_only=True) becomes a three-liner. Semantics are unchanged from f9b1fd799 (same tests pass, one test updated off private internals); net -15 lines. --- agent/lsp/client.py | 229 ++++++++++------------ tests/agent/lsp/test_stale_diagnostics.py | 14 +- 2 files changed, 114 insertions(+), 129 deletions(-) diff --git a/agent/lsp/client.py b/agent/lsp/client.py index e1f615596eee..9207cb75bdaa 100644 --- a/agent/lsp/client.py +++ b/agent/lsp/client.py @@ -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,29 +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-path loop time of the most recent didOpen/didChange we - # sent. Freshness anchor: pushes/pulls that predate this are - # results for OLD content and must never satisfy a waiter or - # be reported to the agent (the "ghost diagnostics" bug — - # slow servers like tsserver publish long after the edit, so - # leftovers from the previous edit look like current errors). - self._changed_at: Dict[str, float] = {} - # Per-path loop time at which the most recent *stored* pull - # request was sent. Send-time (not receipt) so a didChange - # racing a pull in flight correctly invalidates the result. - self._pulled_at: Dict[str, float] = {} + # 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]] = {} @@ -658,23 +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 the store without marking the file as - # "published" and without firing the event. The very first - # push arrives before the user-triggered didChange could've + 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. - self._first_push_seen.add(path) - self._push_diagnostics[path] = diagnostics + 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 @@ -703,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 = [ @@ -733,13 +757,11 @@ class LSPClient: "contentChanges": content_changes, }, ) - self._files[abs_path] = {"version": new_version, "text": text} - # Anchor freshness at this change: any pull result stored - # before now describes the PREVIOUS content and must not be - # reported (it would resurrect just-fixed errors as ghosts). - self._changed_at[abs_path] = asyncio.get_event_loop().time() - self._pull_diagnostics.pop(abs_path, None) - self._pulled_at.pop(abs_path, None) + # 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. @@ -747,14 +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) - self._pulled_at.pop(abs_path, None) - self._changed_at[abs_path] = asyncio.get_event_loop().time() + # 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", { @@ -766,7 +783,6 @@ class LSPClient: } }, ) - self._files[abs_path] = {"version": 0, "text": text} return 0 async def save_file(self, path: str) -> None: @@ -786,16 +802,16 @@ 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). - - The request's *send time* is recorded in :attr:`_pulled_at` - so freshness checks can compare against :attr:`_changed_at`. - A result whose request predates the latest didChange is - dropped — it describes content that no longer exists. + 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) - sent_at = asyncio.get_event_loop().time() + doc = self._docs.get(abs_path) + sent_version = doc.version if doc else -1 try: params: Dict[str, Any] = { "textDocument": {"uri": file_uri(abs_path)} @@ -810,18 +826,11 @@ class LSPClient: return if not isinstance(result, dict): return - if sent_at < self._changed_at.get(abs_path, 0.0): - # The document changed while this pull was in flight — the - # server answered for the OLD text. Storing it would - # resurrect ghost diagnostics. - logger.debug( - "[%s] dropping stale pull result for %s", self.server_id, abs_path - ) - return items = result.get("items") if isinstance(items, list): - self._pull_diagnostics[abs_path] = items - self._pulled_at[abs_path] = sent_at + 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(): @@ -829,36 +838,11 @@ class LSPClient: continue sub_items = sub.get("items") if isinstance(sub_items, list): - rel_path = uri_to_path(uri) - if sent_at < self._changed_at.get(rel_path, 0.0): - continue - self._pull_diagnostics[rel_path] = sub_items - self._pulled_at[rel_path] = sent_at - - def _has_fresh_push(self, path: str, version: int) -> bool: - """True iff a *fresh* publishDiagnostics has arrived for ``path``. - - Fresh means: published at/after the latest didOpen/didChange we - sent, AND (when the server echoes versions) for a version >= the - one we're waiting on. A stale push left over from the previous - edit cycle satisfies neither and must not end a wait early — - that's exactly the "ghost diagnostics" failure mode with slow - servers like tsserver. - """ - published_at = self._published.get(path) - if published_at is None: - return False - if published_at < self._changed_at.get(path, 0.0): - return False - current_v = self._published_version.get(path) - return current_v is None or current_v >= version - - def _has_fresh_pull(self, path: str) -> bool: - """True iff the stored pull result postdates the latest change.""" - pulled_at = self._pulled_at.get(path) - if pulled_at is None: - return path in self._pull_diagnostics - return pulled_at >= self._changed_at.get(path, 0.0) + 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, @@ -914,12 +898,13 @@ class LSPClient: pass # If we got a fresh push for our version, we're done. - if self._has_fresh_push(abs_path, version): + doc = self._docs.get(abs_path) + if doc and doc.fresh_push(version): return True - # Pull may have populated _pull_diagnostics with a - # post-change answer — that's also success. - if self._has_fresh_pull(abs_path): + # 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. @@ -929,7 +914,8 @@ class LSPClient: deadline = asyncio.get_event_loop().time() + timeout baseline = self._push_counter while True: - if self._has_fresh_push(path, 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 @@ -968,23 +954,20 @@ class LSPClient: key. Empty list if the server hasn't published anything. With ``fresh_only=True``, a store only contributes when its - data postdates the latest didOpen/didChange for the file — + 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) + doc = self._docs.get(os.path.abspath(path)) + if doc is None: + return [] if fresh_only: - changed_at = self._changed_at.get(abs_path, 0.0) - push_fresh = self._published.get(abs_path, -1.0) >= changed_at - pulled_at = self._pulled_at.get(abs_path) - pull_fresh = pulled_at is not None and pulled_at >= changed_at - push = (self._push_diagnostics.get(abs_path) or []) if push_fresh else [] - pull = (self._pull_diagnostics.get(abs_path) or []) if pull_fresh else [] - return _dedupe(push, pull) - push = self._push_diagnostics.get(abs_path) or [] - pull = self._pull_diagnostics.get(abs_path) or [] - return _dedupe(push, pull) + 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]]: diff --git a/tests/agent/lsp/test_stale_diagnostics.py b/tests/agent/lsp/test_stale_diagnostics.py index 8090540945e8..1f0aa13cc37f 100644 --- a/tests/agent/lsp/test_stale_diagnostics.py +++ b/tests/agent/lsp/test_stale_diagnostics.py @@ -146,8 +146,8 @@ async def test_wait_timeout_param_overrides_mode_budget(tmp_path: Path): @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 be stored after a - didChange raced past it (send-time anchoring).""" + """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") @@ -156,13 +156,15 @@ async def test_stale_pull_result_dropped_when_change_races(tmp_path: Path): try: v0 = await client.open_file(str(f), language_id="python") await client.wait_for_diagnostics(str(f), v0, mode="document", timeout=2.0) - assert client._has_fresh_pull(os.path.abspath(str(f))) + doc = client._docs[os.path.abspath(str(f))] + assert doc.fresh_pull() - # Simulate an edit racing in: the change invalidates the 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 client._has_fresh_pull(os.path.abspath(str(f))) - assert os.path.abspath(str(f)) not in client._pull_diagnostics + assert not doc.fresh_pull() + assert client.diagnostics_for(str(f), fresh_only=True) == [] finally: await client.shutdown() From acc1b6e76a1c089074674e2f78223cb577cd03aa Mon Sep 17 00:00:00 2001 From: PRATHAMESH75 Date: Sat, 18 Jul 2026 15:15:02 +0530 Subject: [PATCH 26/72] fix(agent): inject empty required array on Moonshot object schemas Moonshot/Kimi's tool-parameter validator rejects object schemas that omit the required key with HTTP 400 ("required must be an array"), even though standard JSON Schema allows omitting it. Any Hermes tool with zero required parameters (browser_back, delegate_task, project_list, several MCP list_* tools, etc.) tripped this when routed to a Moonshot endpoint. Add a Rule 4 to the sanitizer: every object schema gets a required array, defaulting to []. Existing lists are preserved but pruned to names that actually appear in properties (dangling entries are also rejected upstream). Applied recursively to nested object schemas and to the coerced/empty top-level fallback. Fixes #66835 --- agent/moonshot_schema.py | 31 +++++++++++- tests/agent/test_moonshot_schema.py | 73 +++++++++++++++++++++++++++-- 2 files changed, 97 insertions(+), 7 deletions(-) diff --git a/agent/moonshot_schema.py b/agent/moonshot_schema.py index a7629c3f5dcb..3f1708ceb0ae 100644 --- a/agent/moonshot_schema.py +++ b/agent/moonshot_schema.py @@ -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 diff --git a/tests/agent/test_moonshot_schema.py b/tests/agent/test_moonshot_schema.py index 339e6ab529ee..6dc7944292bc 100644 --- a/tests/agent/test_moonshot_schema.py +++ b/tests/agent/test_moonshot_schema.py @@ -188,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"} @@ -212,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.""" @@ -239,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([]) == [] From a5b9803a51d33ecaa3a0f638b302593ae0446c93 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:25:00 -0700 Subject: [PATCH 27/72] test(transports): assert Moonshot wire schema carries required:[] MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Transport-level regression for #66835 — verifies the outgoing tool schema at the chat_completions build_kwargs boundary, not just the sanitizer unit. --- .../agent/transports/test_chat_completions.py | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/tests/agent/transports/test_chat_completions.py b/tests/agent/transports/test_chat_completions.py index cf245a2f3266..fd549e114114 100644 --- a/tests/agent/transports/test_chat_completions.py +++ b/tests/agent/transports/test_chat_completions.py @@ -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 = { From 08626c18be56d9f617c735fcd5fbed461a392c36 Mon Sep 17 00:00:00 2001 From: Ryan Kelln Date: Tue, 9 Jun 2026 16:02:48 -0400 Subject: [PATCH 28/72] fix(prompt_builder): improve Matrix PLATFORM_HINTS with tested formatting rules - Replace outdated brief hint with comprehensive, tested rules - Document exactly what renders on Matrix and what does not - Add critical linebreak semantics (two trailing spaces = soft break) - Add link formatting guidance (descriptive text, never bare URLs) --- agent/prompt_builder.py | 22 ++++++++++++++++++++-- 1 file changed, 20 insertions(+), 2 deletions(-) diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 25d900341f49..765743c60f4d 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -805,8 +805,26 @@ 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.\n\n" + "CRITICAL FORMATTING RULES (Matrix renders Markdown inconsistently):\n\n" + "Supported and safe: **bold**, *italic*, `inline code`, ``` code blocks, " + "- bullet lists, [links](url), > blockquote, labeled **Label:** value pairs, " + "and key: value pairs.\n\n" + "Do NOT use (they render incorrectly or not at all):\n" + "- Tables — become one continuous line; use labeled **Label:** value pairs\n" + "- Numbered lists — collapse to bullets; use - bullets\n" + "- Horizontal rules (---) — invisible\n" + "- ||spoiler|| tags — no visual effect\n" + "- ~strikethrough~ — no visual effect\n" + "- Headings (# ## ###) — no visual effect\n" + "- Checkboxes (- [ ] / - [x]) — render as plain ASCII\n\n" + "LINEBREAKS ARE CRITICAL: A single newline does NOT create a new line — " + "adjacent lines merge together. Use TWO trailing spaces at the end of a " + "line for a soft linebreak. Use a blank line between paragraphs. " + "Without two trailing spaces or a blank line, everything runs together.\n\n" + "LINKS: Always use [descriptive link text](url) — never show bare URLs. " + "When referencing something with an associated URL (event names, venues, " + "people, sources), 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, " From 35e0f56fbf1b1f9331b675fd1e83fa58f3f193c7 Mon Sep 17 00:00:00 2001 From: nankingjing <1079826437@qq.com> Date: Fri, 26 Jun 2026 21:34:51 +0800 Subject: [PATCH 29/72] fix(matrix): make outbound message length configurable (#53026) Raise the Matrix adapter default chunk size from 4,000 to 16,000 characters and allow overrides via config.yaml or MATRIX_MAX_MESSAGE_LENGTH. Fixes #53026 --- plugins/platforms/matrix/adapter.py | 58 +++++++++++--- tests/gateway/test_matrix_message_length.py | 85 +++++++++++++++++++++ 2 files changed, 131 insertions(+), 12 deletions(-) create mode 100644 tests/gateway/test_matrix_message_length.py diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index 3835d8e31efa..f86a45366f7c 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -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,22 @@ 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 - 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 +1644,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 +3639,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 +4722,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 +4769,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, ) diff --git a/tests/gateway/test_matrix_message_length.py b/tests/gateway/test_matrix_message_length.py new file mode 100644 index 000000000000..095993b46bd0 --- /dev/null +++ b/tests/gateway/test_matrix_message_length.py @@ -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()) From 086a56a028d89c3eda92e517cf19a2634b6de78c Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:43:46 -0700 Subject: [PATCH 30/72] fix(matrix): correct platform hint over-claims, add regression tests + docs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Follow-up to the salvaged #52552 and #53083 commits: - Rework the Matrix PLATFORM_HINTS entry around what the adapter actually emits: headings, numbered lists, blockquotes, strikethrough-free markdown all render (the adapter converts them to sanctioned HTML). Keep the genuinely valuable guidance: no Markdown tables (Element X / Beeper / mobile clients don't render HTML tables — cells collapse into one line), no spoilers/checkboxes/~~strikethrough~~ (not converted by python-markdown), prefer descriptive link text. - Regression test: hint must steer models away from tables. - Fix test_long_response_split_preserves_thread_context to derive its payload size from the adapter's configurable limit instead of assuming the old hardcoded 4000. - Document matrix.max_message_length in the Matrix docs page. - Contributor mapping for RKelln. --- agent/prompt_builder.py | 33 ++++++++------------- contributors/emails/ryan.kelln@gmail.com | 1 + tests/agent/test_prompt_builder.py | 5 ++++ tests/gateway/test_matrix.py | 5 +++- website/docs/user-guide/messaging/matrix.md | 1 + 5 files changed, 24 insertions(+), 21 deletions(-) create mode 100644 contributors/emails/ryan.kelln@gmail.com diff --git a/agent/prompt_builder.py b/agent/prompt_builder.py index 765743c60f4d..4ae71cbb3a37 100644 --- a/agent/prompt_builder.py +++ b/agent/prompt_builder.py @@ -805,26 +805,19 @@ PLATFORM_HINTS = { ), "matrix": ( "You are in a Matrix room communicating with your user. " - "The adapter converts your Markdown to HTML for rich display.\n\n" - "CRITICAL FORMATTING RULES (Matrix renders Markdown inconsistently):\n\n" - "Supported and safe: **bold**, *italic*, `inline code`, ``` code blocks, " - "- bullet lists, [links](url), > blockquote, labeled **Label:** value pairs, " - "and key: value pairs.\n\n" - "Do NOT use (they render incorrectly or not at all):\n" - "- Tables — become one continuous line; use labeled **Label:** value pairs\n" - "- Numbered lists — collapse to bullets; use - bullets\n" - "- Horizontal rules (---) — invisible\n" - "- ||spoiler|| tags — no visual effect\n" - "- ~strikethrough~ — no visual effect\n" - "- Headings (# ## ###) — no visual effect\n" - "- Checkboxes (- [ ] / - [x]) — render as plain ASCII\n\n" - "LINEBREAKS ARE CRITICAL: A single newline does NOT create a new line — " - "adjacent lines merge together. Use TWO trailing spaces at the end of a " - "line for a soft linebreak. Use a blank line between paragraphs. " - "Without two trailing spaces or a blank line, everything runs together.\n\n" - "LINKS: Always use [descriptive link text](url) — never show bare URLs. " - "When referencing something with an associated URL (event names, venues, " - "people, sources), make the name a clickable link.\n\n" + "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, " diff --git a/contributors/emails/ryan.kelln@gmail.com b/contributors/emails/ryan.kelln@gmail.com new file mode 100644 index 000000000000..e0d10922e2b1 --- /dev/null +++ b/contributors/emails/ryan.kelln@gmail.com @@ -0,0 +1 @@ +RKelln diff --git a/tests/agent/test_prompt_builder.py b/tests/agent/test_prompt_builder.py index a0acdea0433b..633fde7c02d9 100644 --- a/tests/agent/test_prompt_builder.py +++ b/tests/agent/test_prompt_builder.py @@ -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"] diff --git a/tests/gateway/test_matrix.py b/tests/gateway/test_matrix.py index d239728b7941..75bb826332e3 100644 --- a/tests/gateway/test_matrix.py +++ b/tests/gateway/test_matrix.py @@ -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", diff --git a/website/docs/user-guide/messaging/matrix.md b/website/docs/user-guide/messaging/matrix.md index 75babf5cbab9..8dc73b1643ca 100644 --- a/website/docs/user-guide/messaging/matrix.md +++ b/website/docs/user-guide/messaging/matrix.md @@ -97,6 +97,7 @@ matrix: session_scope: room # auto|room|thread; room is recommended for project rooms auto_thread: true # Auto-create threads for responses (default: true) dm_mention_threads: false # Create thread when @mentioned in DM (default: false) + max_message_length: 16000 # Outbound chunk size in chars (default: 16000, max: 65535) ``` Or via environment variables: From 0e281b58e6ec0ee7c3a314984ee0d6b3c2fd48f0 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 10:58:17 -0700 Subject: [PATCH 31/72] fix(matrix): class-level split-threshold defaults for partially-constructed adapters Text-batching tests (and any tooling) build MatrixAdapter via object.__new__ without running __init__; moving _split_threshold from a class constant to an instance attribute made _flush_text_batch die with AttributeError, silently dropping the flush. Restore class-level defaults (max_message_length, _split_threshold) that __init__ overrides, and derive the near-limit test payload from adapter._split_threshold instead of the old hardcoded 3950. --- plugins/platforms/matrix/adapter.py | 6 ++++++ tests/gateway/test_text_batching.py | 4 ++-- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/plugins/platforms/matrix/adapter.py b/plugins/platforms/matrix/adapter.py index f86a45366f7c..a49a99bdd0e1 100644 --- a/plugins/platforms/matrix/adapter.py +++ b/plugins/platforms/matrix/adapter.py @@ -837,6 +837,12 @@ class MatrixAdapter(BasePlatformAdapter): # (see _normalize_matrix_bang_command), so instruction text shows "!". typed_command_prefix = "!" + # 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) diff --git a/tests/gateway/test_text_batching.py b/tests/gateway/test_text_batching.py index d72cb439d474..6c0cdaa6d9ff 100644 --- a/tests/gateway/test_text_batching.py +++ b/tests/gateway/test_text_batching.py @@ -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) From 33c154e41f589a9eab2910777c5dcd1fcc2cf132 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 14:32:13 -0400 Subject: [PATCH 32/72] revert(desktop): restore Preparing error state in onboarding MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reverts the Preparing component changes from b2857110b so the progress bar turns red (bg-destructive) and the error text shows below it when boot.error is set, instead of bailing out with an early return null. The corresponding e2e guard in waitForBootFailure (e2e/fixtures.ts) that rejected any progress bar in the DOM is dropped — it now waits for the failure dialog (Retry/Repair/Use local gateway/Connection settings) or the "Desktop boot failed" toast. The boot-failure.spec.ts header comment is updated to match. Verified: tsc clean, vitest boot-failure-reauth (21/21) + boot-failure-overlay (3/3) pass, npm run build clean, playwright e2e/boot-failure.spec.ts 2/2 pass. --- apps/desktop/e2e/boot-failure.spec.ts | 3 +-- apps/desktop/e2e/fixtures.ts | 13 +++---------- apps/desktop/src/components/onboarding/index.tsx | 14 ++++++-------- 3 files changed, 10 insertions(+), 20 deletions(-) diff --git a/apps/desktop/e2e/boot-failure.spec.ts b/apps/desktop/e2e/boot-failure.spec.ts index 4f316d356c6f..a94373ce84a9 100644 --- a/apps/desktop/e2e/boot-failure.spec.ts +++ b/apps/desktop/e2e/boot-failure.spec.ts @@ -4,8 +4,7 @@ * * 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 — no progress bar should be - * visible. + * BootFailureOverlay with retry/repair actions. * * Prerequisite: `npm run build` must have been run so dist/ exists. */ diff --git a/apps/desktop/e2e/fixtures.ts b/apps/desktop/e2e/fixtures.ts index aa8dd94d2518..942e89dbe5b0 100644 --- a/apps/desktop/e2e/fixtures.ts +++ b/apps/desktop/e2e/fixtures.ts @@ -651,16 +651,9 @@ export async function waitForBootFailure(page: Page, timeoutMs = 60_000): Promis // 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. - // Either way, no progress bar should be visible and an error must - // have surfaced (toast, boot-failure banner, or the overlay itself). - const hasProgressBar = Boolean( - document.querySelector('[role="progressbar"], .h-2.rounded-full.bg-muted') - ) - - if (hasProgressBar) { - return false - } - + // 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. diff --git a/apps/desktop/src/components/onboarding/index.tsx b/apps/desktop/src/components/onboarding/index.tsx index 900d3f0c8739..87cc31648474 100644 --- a/apps/desktop/src/components/onboarding/index.tsx +++ b/apps/desktop/src/components/onboarding/index.tsx @@ -355,15 +355,9 @@ function ReasonNotice({ reason }: { reason: string }) { function Preparing({ boot }: { boot: DesktopBootState }) { const { t } = useI18n() const progress = Math.max(2, Math.min(100, Math.round(boot.progress))) + const hasError = Boolean(boot.error) const installing = boot.phase.startsWith('runtime.') - // When boot fails, BootFailureOverlay (z-1400) owns the screen. Bail out - // here so we never paint a progress bar alongside the error overlay — - // E2E screenshots can catch the two mid-transition. - if (boot.error) { - return null - } - return (

@@ -371,7 +365,10 @@ function Preparing({ boot }: { boot: DesktopBootState }) {

@@ -379,6 +376,7 @@ function Preparing({ boot }: { boot: DesktopBootState }) { {boot.message} {progress}%
+ {hasError ?

{boot.error}

: null}
) } From 3ef6bbd201263d354fd83ec55b3c306ded2eb72a Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:35:21 -0700 Subject: [PATCH 33/72] chore: release v0.19.0 (2026.7.20) (#68175) --- acp_registry/agent.json | 4 ++-- contributors/emails/agent@agents-Mac-mini.local | 1 + contributors/emails/ajzrva@gmail.com | 1 + contributors/emails/geoffreybutler94@gmail.com | 1 + contributors/emails/gigakun@agentmail.to | 1 + contributors/emails/git@hode.co.uk | 1 + contributors/emails/hermesagent424@gmail.com | 1 + contributors/emails/jakub.wolniewicz@gmail.com | 1 + contributors/emails/jfduarte09@gmail.com | 1 + contributors/emails/kuangmi@deeparchi.com | 1 + contributors/emails/marcolivier@gmail.com | 1 + contributors/emails/markvlcek@gmail.com | 1 + contributors/emails/siage@139.com | 1 + contributors/emails/turgut.kural@gmail.com | 1 + hermes_cli/__init__.py | 4 ++-- pyproject.toml | 2 +- scripts/contributor_audit.py | 2 ++ uv.lock | 2 +- 18 files changed, 21 insertions(+), 6 deletions(-) create mode 100644 contributors/emails/agent@agents-Mac-mini.local create mode 100644 contributors/emails/ajzrva@gmail.com create mode 100644 contributors/emails/geoffreybutler94@gmail.com create mode 100644 contributors/emails/gigakun@agentmail.to create mode 100644 contributors/emails/git@hode.co.uk create mode 100644 contributors/emails/hermesagent424@gmail.com create mode 100644 contributors/emails/jakub.wolniewicz@gmail.com create mode 100644 contributors/emails/jfduarte09@gmail.com create mode 100644 contributors/emails/kuangmi@deeparchi.com create mode 100644 contributors/emails/marcolivier@gmail.com create mode 100644 contributors/emails/markvlcek@gmail.com create mode 100644 contributors/emails/siage@139.com create mode 100644 contributors/emails/turgut.kural@gmail.com diff --git a/acp_registry/agent.json b/acp_registry/agent.json index 09319a1d02a6..1d3752bf15a1 100644 --- a/acp_registry/agent.json +++ b/acp_registry/agent.json @@ -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"] } } diff --git a/contributors/emails/agent@agents-Mac-mini.local b/contributors/emails/agent@agents-Mac-mini.local new file mode 100644 index 000000000000..850e0a4eb079 --- /dev/null +++ b/contributors/emails/agent@agents-Mac-mini.local @@ -0,0 +1 @@ +momomojo diff --git a/contributors/emails/ajzrva@gmail.com b/contributors/emails/ajzrva@gmail.com new file mode 100644 index 000000000000..2c2097b84dd5 --- /dev/null +++ b/contributors/emails/ajzrva@gmail.com @@ -0,0 +1 @@ +ajzrva-sys diff --git a/contributors/emails/geoffreybutler94@gmail.com b/contributors/emails/geoffreybutler94@gmail.com new file mode 100644 index 000000000000..2a3d8a47a618 --- /dev/null +++ b/contributors/emails/geoffreybutler94@gmail.com @@ -0,0 +1 @@ +geoffreybutler94 diff --git a/contributors/emails/gigakun@agentmail.to b/contributors/emails/gigakun@agentmail.to new file mode 100644 index 000000000000..a82f4d1faa32 --- /dev/null +++ b/contributors/emails/gigakun@agentmail.to @@ -0,0 +1 @@ +gigakun3030 diff --git a/contributors/emails/git@hode.co.uk b/contributors/emails/git@hode.co.uk new file mode 100644 index 000000000000..52ce26c3c88d --- /dev/null +++ b/contributors/emails/git@hode.co.uk @@ -0,0 +1 @@ +okisdev diff --git a/contributors/emails/hermesagent424@gmail.com b/contributors/emails/hermesagent424@gmail.com new file mode 100644 index 000000000000..c76c38becfcf --- /dev/null +++ b/contributors/emails/hermesagent424@gmail.com @@ -0,0 +1 @@ +DatTheMaster diff --git a/contributors/emails/jakub.wolniewicz@gmail.com b/contributors/emails/jakub.wolniewicz@gmail.com new file mode 100644 index 000000000000..88e457102f73 --- /dev/null +++ b/contributors/emails/jakub.wolniewicz@gmail.com @@ -0,0 +1 @@ +frizikk diff --git a/contributors/emails/jfduarte09@gmail.com b/contributors/emails/jfduarte09@gmail.com new file mode 100644 index 000000000000..38ef1ae32831 --- /dev/null +++ b/contributors/emails/jfduarte09@gmail.com @@ -0,0 +1 @@ +F4TB0Yz diff --git a/contributors/emails/kuangmi@deeparchi.com b/contributors/emails/kuangmi@deeparchi.com new file mode 100644 index 000000000000..7f3575558dd2 --- /dev/null +++ b/contributors/emails/kuangmi@deeparchi.com @@ -0,0 +1 @@ +kuangmi-bit diff --git a/contributors/emails/marcolivier@gmail.com b/contributors/emails/marcolivier@gmail.com new file mode 100644 index 000000000000..65046935be60 --- /dev/null +++ b/contributors/emails/marcolivier@gmail.com @@ -0,0 +1 @@ +marcolivierlavoie diff --git a/contributors/emails/markvlcek@gmail.com b/contributors/emails/markvlcek@gmail.com new file mode 100644 index 000000000000..71874600fd3c --- /dev/null +++ b/contributors/emails/markvlcek@gmail.com @@ -0,0 +1 @@ +MarkVLK diff --git a/contributors/emails/siage@139.com b/contributors/emails/siage@139.com new file mode 100644 index 000000000000..f269b94ab5e1 --- /dev/null +++ b/contributors/emails/siage@139.com @@ -0,0 +1 @@ +sjiangtao2024 diff --git a/contributors/emails/turgut.kural@gmail.com b/contributors/emails/turgut.kural@gmail.com new file mode 100644 index 000000000000..7ad451ee5b05 --- /dev/null +++ b/contributors/emails/turgut.kural@gmail.com @@ -0,0 +1 @@ +TurgutKural diff --git a/hermes_cli/__init__.py b/hermes_cli/__init__.py index 3a6233f03249..a10935a68f05 100644 --- a/hermes_cli/__init__.py +++ b/hermes_cli/__init__.py @@ -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(): diff --git a/pyproject.toml b/pyproject.toml index faf5b6efcf4e..c630b3cf7bbf 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 diff --git a/scripts/contributor_audit.py b/scripts/contributor_audit.py index 9c371e4ae162..460bb1ca8970 100644 --- a/scripts/contributor_audit.py +++ b/scripts/contributor_audit.py @@ -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", diff --git a/uv.lock b/uv.lock index 59f9d8e2628b..257f7d69645c 100644 --- a/uv.lock +++ b/uv.lock @@ -1516,7 +1516,7 @@ wheels = [ [[package]] name = "hermes-agent" -version = "0.18.2" +version = "0.19.0" source = { editable = "." } dependencies = [ { name = "certifi" }, From 0b40ba10cdab38f8c2bda2048e6f07df27971cbc Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 14:35:22 -0400 Subject: [PATCH 34/72] revert(installer): drop install.ps1 rewrite from E2E branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pulls commit 3dab86a95 out of this branch per review — the install.ps1 rewrite (swapping the astral install-script for a direct GitHub-zip download) is a real Windows-installer behavior change that belongs in its own installer PR, not riding along in a Desktop E2E PR. e2e-windows.yml is `if: false` on both jobs and can't run on Linux CI, so the rewrite lands here with no coverage. The deleted install tests (test_install_ps1_native_stderr_eap.py, test_install_ps1_uv_powershell_host.py) are restored — they'll be dropped alongside the installer change in its own PR. Original commit 3dab86a95 will be cherry-picked onto a dedicated installer PR. --- scripts/install.ps1 | 114 ++++++++----------- tests/test_install_ps1_native_stderr_eap.py | 94 +++++++++++++++ tests/test_install_ps1_uv_powershell_host.py | 77 +++++++++++++ 3 files changed, 216 insertions(+), 69 deletions(-) create mode 100644 tests/test_install_ps1_native_stderr_eap.py create mode 100644 tests/test_install_ps1_uv_powershell_host.py diff --git a/scripts/install.ps1 b/scripts/install.ps1 index 352cc647b9a4..0a98ad6e457b 100644 --- a/scripts/install.ps1 +++ b/scripts/install.ps1 @@ -412,6 +412,36 @@ function Install-AgentBrowser { # Dependency checks # ============================================================================ +# Resolve the PowerShell host executable used to spawn child PowerShell +# processes (the astral uv installer below). We must NOT hardcode the bare +# name `powershell`: it names *Windows PowerShell* and only resolves when its +# System32 directory is on PATH. When install.ps1 is run under PowerShell 7+ +# (`pwsh`) -- or any session where `powershell` isn't on PATH -- a bare +# `powershell` spawn dies with "The term 'powershell' is not recognized", +# aborting uv installation (field report: Windows install stuck, uv install +# failed with exactly that message). Prefer the absolute path of the host we +# are already running in (PATH-independent), then fall back to whichever of +# powershell/pwsh is resolvable, and only then to the bare name. +function Get-PowerShellHostExe { + try { + $hostExe = (Get-Process -Id $PID).Path + if ($hostExe -and (Test-Path $hostExe)) { + $leaf = Split-Path $hostExe -Leaf + # Only trust the current host when it is a real PowerShell CLI + # (not e.g. powershell_ise.exe or an embedded host that can't take + # `-ExecutionPolicy`/`-Command`). + if ($leaf -match '^(?i:powershell|pwsh)\.exe$') { return $hostExe } + } + } catch { } + foreach ($candidate in @("powershell", "pwsh")) { + $cmd = Get-Command $candidate -CommandType Application -ErrorAction SilentlyContinue | + Select-Object -First 1 + if ($cmd -and $cmd.Source) { return $cmd.Source } + } + # Last-ditch: hand back the bare name so the spawn surfaces its own error. + return "powershell" +} + function Install-Uv { # Hermes owns its own uv at $HermesHome\bin\uv.exe. Always install there -- # no PATH probing, no conda guards, no multi-location resolution chains. @@ -427,72 +457,20 @@ function Install-Uv { } Write-Info "Installing managed uv into $HermesHome\bin ..." - $binDir = Join-Path $HermesHome "bin" - New-Item -ItemType Directory -Path $binDir -Force | Out-Null - - # Download the uv binary zip directly from GitHub releases instead of - # running the astral installer script (`irm https://astral.sh/uv/install.ps1 | iex`). - # The astral installer calls Get-ExecutionPolicy internally (from the - # Microsoft.PowerShell.Security module), and on some Windows installs that - # module fails to load -- killing the installer before it can download - # anything (field report: "The 'Get-ExecutionPolicy' command was found in - # the module 'Microsoft.PowerShell.Security', but the module could not be - # loaded"). Downloading the zip ourselves sidesteps the broken module - # entirely: no child powershell spawn, no execution-policy check, no - # script parsing. The astral installer is just a wrapper around this zip - # anyway. - $arch = Get-WindowsArch - $targetTriple = switch ($arch) { - "x64" { "x86_64-pc-windows-msvc" } - "arm64" { "aarch64-pc-windows-msvc" } - "x86" { "i686-pc-windows-msvc" } - default { throw "Unsupported Windows architecture for uv: $arch" } - } - $zipName = "uv-$targetTriple.zip" - # The /latest/download/ URL always serves the most recent release zip. - $downloadUrls = @( - "https://github.com/astral-sh/uv/releases/latest/download/$zipName", - "https://releases.astral.sh/github/uv/releases/latest/download/$zipName" - ) - - $tempZip = [System.IO.Path]::GetTempFileName() + ".zip" - $tempExtract = Join-Path ([System.IO.Path]::GetTempPath()) ([System.IO.Path]::GetRandomFileName()) - $downloaded = $false - - foreach ($url in $downloadUrls) { - try { - Write-Info "Downloading uv from $url ..." - Invoke-WebRequest -Uri $url -OutFile $tempZip -UseBasicParsing -ErrorAction Stop - $downloaded = $true - break - } catch { - Write-Warn "Download failed from $url : $_" - } - } - - if (-not $downloaded) { - Write-Err "Failed to download uv from all mirrors" - Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" - Remove-Item $tempZip -Force -ErrorAction SilentlyContinue - return $false - } + New-Item -ItemType Directory -Path (Join-Path $HermesHome "bin") -Force | Out-Null + # UV_INSTALL_DIR tells the astral installer to place the binary + # directly into $HermesHome\bin instead of ~/.local/bin. + $prevEAP = $ErrorActionPreference try { - New-Item -ItemType Directory -Path $tempExtract -Force | Out-Null - Expand-Archive -Path $tempZip -DestinationPath $tempExtract -Force -ErrorAction Stop - - # The zip contains uv.exe (and uvx.exe) either at the root or inside - # a subdirectory. Find and move them to the managed bin dir. - $executables = Get-ChildItem -Path $tempExtract -Recurse -Filter "*.exe" -ErrorAction SilentlyContinue - if (-not $executables) { - Write-Err "uv zip did not contain any executables" - Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" - return $false - } - - foreach ($exe in $executables) { - Copy-Item -Path $exe.FullName -Destination $binDir -Force -ErrorAction Stop - } + $ErrorActionPreference = "Continue" + $env:UV_INSTALL_DIR = Join-Path $HermesHome "bin" + # Spawn via the resolved host exe (see Get-PowerShellHostExe) rather + # than a bare `powershell`, which isn't guaranteed to be on PATH under + # PowerShell 7 / pwsh-only setups. + $psHostExe = Get-PowerShellHostExe + & $psHostExe -ExecutionPolicy ByPass -c "irm https://astral.sh/uv/install.ps1 | iex" 2>&1 | Out-Null + $ErrorActionPreference = $prevEAP if (Test-Path $managedUv) { $script:UvCmd = $managedUv @@ -501,16 +479,14 @@ function Install-Uv { return $true } - Write-Err "uv.exe not found at $managedUv after extraction" + Write-Err "uv installed but not found at $managedUv" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false } catch { - Write-Err "Failed to extract uv: $_" + if ($prevEAP) { $ErrorActionPreference = $prevEAP } + Write-Err "Failed to install uv: $_" Write-Info "Install manually: https://docs.astral.sh/uv/getting-started/installation/" return $false - } finally { - Remove-Item $tempZip -Force -ErrorAction SilentlyContinue - Remove-Item $tempExtract -Recurse -Force -ErrorAction SilentlyContinue } } diff --git a/tests/test_install_ps1_native_stderr_eap.py b/tests/test_install_ps1_native_stderr_eap.py new file mode 100644 index 000000000000..de99bf229004 --- /dev/null +++ b/tests/test_install_ps1_native_stderr_eap.py @@ -0,0 +1,94 @@ +"""Regression tests for #48352: Windows PowerShell 5.1 native stderr. + +PowerShell 5.1 turns stderr from native commands into ``NativeCommandError`` +records when ``$ErrorActionPreference = "Stop"``. ``scripts/install.ps1`` has a +few git/uv calls where stderr can be normal progress output, so those calls must +run with EAP temporarily relaxed and then inspect ``$LASTEXITCODE``. +""" + +from __future__ import annotations + +import re +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1" + + +def _install_ps1() -> str: + return INSTALL_PS1.read_text(encoding="utf-8") + + +def _assert_relaxed_call(text: str, command_pattern: str) -> None: + helper_block_pattern = ( + r"Invoke-NativeWithRelaxedErrorAction\s*\{[^}]*" + + command_pattern + + r"[^}]*\}" + ) + inline_pattern = ( + r"\$ErrorActionPreference\s*=\s*\"Continue\"[\s\S]{0,900}?" + + command_pattern + ) + assert re.search(helper_block_pattern, text) or re.search(inline_pattern, text), ( + f"install.ps1 must relax ErrorActionPreference around {command_pattern}" + ) + + +def test_repository_stage_relieves_eap_for_ssh_and_https_git_clone() -> None: + text = _install_ps1() + assert "function Invoke-NativeWithRelaxedErrorAction" in text + _assert_relaxed_call( + text, + r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlSsh \$InstallDir", + ) + _assert_relaxed_call( + text, + r"git -c windows\.appendAtomically=false clone --depth 1 --branch \$Branch \$RepoUrlHttps \$InstallDir", + ) + + +def test_uv_venv_and_dependency_installs_relax_eap() -> None: + text = _install_ps1() + _assert_relaxed_call(text, r"& \$UvCmd venv venv --python \$PythonVersion") + _assert_relaxed_call(text, r"& \$UvCmd sync --extra all --locked") + _assert_relaxed_call(text, r"& \$UvCmd pip install -e \$tier\.Spec") + + +def test_uv_venv_failure_is_not_swallowed_after_eap_relax() -> None: + """Relaxing EAP must not let a genuine `uv venv` failure pass as success. + + Once EAP is relaxed, a real non-zero `uv venv` exit no longer aborts on its + own, so install.ps1 must capture $LASTEXITCODE right after the call and fail + fast — otherwise the `venv` stage falsely reports success (Invoke-Stage emits + ok=true) when no venv was created. Regression guard for the gap caught while + reviewing #48372 (the explicit check originally proposed in #48463). + """ + text = _install_ps1() + # The uv-venv invocation, then an exit-code capture, then a throw — all + # within a small window after the relaxed call. + guard = re.search( + r"& \$UvCmd venv venv --python \$PythonVersion[\s\S]{0,400}?" + r"\$LASTEXITCODE[\s\S]{0,200}?" + r"-ne 0[\s\S]{0,200}?throw", + text, + ) + assert guard is not None, ( + "install.ps1 must capture uv venv's exit code and throw on failure after " + "relaxing ErrorActionPreference, so a genuine venv-creation failure isn't " + "reported as a successful stage" + ) + + +def test_native_eap_helper_always_restores_previous_preference() -> None: + text = _install_ps1() + m = re.search( + r"function Invoke-NativeWithRelaxedErrorAction \{(?P[\s\S]*?)^\}", + text, + re.MULTILINE, + ) + assert m is not None, "expected a shared helper for NativeCommandError-safe calls" + body = m.group("body") + assert "$prevEAP = $ErrorActionPreference" in body + assert '$ErrorActionPreference = "Continue"' in body + assert "finally" in body + assert "$ErrorActionPreference = $prevEAP" in body diff --git a/tests/test_install_ps1_uv_powershell_host.py b/tests/test_install_ps1_uv_powershell_host.py new file mode 100644 index 000000000000..ea442ce484a8 --- /dev/null +++ b/tests/test_install_ps1_uv_powershell_host.py @@ -0,0 +1,77 @@ +"""Regression: the Windows installer must not spawn a bare ``powershell``. + +A user on Windows reported the installer getting stuck; running +``irm https://hermes-agent.nousresearch.com/install.ps1 | iex`` failed at the +uv step with:: + + [X] Failed to install uv: The term 'powershell' is not recognized as the + name of a cmdlet, function, script file, or operable program. + +Root cause: ``Install-Uv`` spawned the astral uv installer via a hardcoded +bare ``powershell`` command. That name resolves only to *Windows PowerShell* +and only when its System32 directory is on ``PATH``. Under PowerShell 7+ +(``pwsh``) -- or any session where ``powershell`` isn't on ``PATH`` -- the +spawn dies and uv installation aborts. + +The fix resolves the PowerShell host executable (preferring the absolute path +of the running host, then ``powershell``/``pwsh`` via ``Get-Command``) and +invokes *that* instead of a bare name. These tests lock that contract at the +source level (the script only runs on Windows, so there's no runner to +execute it on Linux CI). +""" + +from pathlib import Path + +import pytest + +_INSTALL_PS1 = Path(__file__).resolve().parents[1] / "scripts" / "install.ps1" + + +@pytest.fixture(scope="module") +def source() -> str: + return _INSTALL_PS1.read_text(encoding="utf-8") + + +def test_astral_uv_installer_not_spawned_via_bare_powershell(source: str): + """The exact failing literal must be gone.""" + forbidden = 'powershell -ExecutionPolicy ByPass -c "irm https://astral.sh/uv' + assert forbidden not in source, ( + "Install-Uv still spawns the astral uv installer via a bare " + "`powershell` — it must use the resolved PowerShell host exe so it " + "works under pwsh / when powershell isn't on PATH." + ) + + +def test_astral_uv_installer_invoked_via_resolved_host_variable(source: str): + """The astral uv installer line must use the call operator on a variable. + + i.e. ``& $psHostExe -ExecutionPolicy ... irm https://astral.sh/uv...`` + rather than naming a fixed executable. + """ + lines = [ln for ln in source.splitlines() if "astral.sh/uv/install.ps1 | iex" in ln] + # Exactly one invocation line carries the astral installer. + invocation = [ln for ln in lines if "irm https://astral.sh/uv/install.ps1 | iex" in ln] + assert invocation, "astral uv install invocation line not found" + for ln in invocation: + stripped = ln.strip() + assert stripped.startswith("& $"), ( + f"astral uv installer must be invoked via the call operator on a " + f"resolved host variable (`& $...`), got: {stripped!r}" + ) + + +def test_powershell_host_resolver_is_defined_and_portable(source: str): + """A host-resolver helper must exist and be PATH-independent + pwsh-aware.""" + assert "function Get-PowerShellHostExe" in source, ( + "expected a Get-PowerShellHostExe helper that resolves the host exe" + ) + # PATH-independent: derive the absolute path of the running host. + assert "Get-Process -Id $PID" in source, ( + "resolver must derive the current host's absolute path " + "(Get-Process -Id $PID), which is independent of PATH" + ) + # pwsh-aware fallback: PowerShell 7's executable is `pwsh`, not `powershell`. + assert "pwsh" in source, ( + "resolver must fall back to pwsh (PowerShell 7) when powershell is " + "unavailable" + ) From 2e10d7b94225ec8cc554722d4a4d8edb9bc3799f Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 14:37:39 -0400 Subject: [PATCH 35/72] =?UTF-8?q?fix(desktop):=20address=20review=20?= =?UTF-8?q?=E2=80=94=20overlay=20a11y,=20e2e=20typecheck,=20nits?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blocking #1 — gateway-connecting-overlay.tsx reduced-motion regression: the top `if (reduce) setPhase('gone')` fired unconditionally on mount whenever reduce-motion was on, so every OS reduced-motion user lost the CONNECTING overlay during cold boot entirely (jumped to 'gone' before the gateway was even open). The intent was to skip the exit *choreography*, not to skip showing the overlay. Removed the unconditional top block and the redundant nested preview block; kept only the third branch (`gatewayState === 'open' && shownRef.current` → `reduce ? 'gone' : 'text-out'`) which correctly gates the short-circuit on connect. Also fixed `if(reduce)` missing-space, 6-space misindent, and the same 3-line comment pasted three times. Nit #1 — tsconfig excludes e2e, so specs were never typechecked in CI. Added tsconfig.e2e.json (extends base, includes e2e/ + playwright.config.ts, adds @playwright/test types) and wired it into the typecheck script. This surfaced three latent type errors that are fixed in the same commit: - fix-electron-tracing.ts: `app._context` and `electron._playwright` are private APIs — added `as any` on the access before the existing cast. - playwright.config.ts: `reducedMotion: 'reduce'` directly under `use:` is not a valid UseOptions property in playwright 1.58; it's a BrowserContextOption accessed via `contextOptions: { reducedMotion: 'reduce' }`. The old form was silently ignored at runtime, so reduced-motion emulation wasn't actually active — screenshots could catch overlays mid-fade (exactly what the comment warned about). Nit #2 — fix-electron-tracing.ts reaches into Playwright internals (_playwright, _allContexts, _context) with no public contract. Added a header comment calling out the `@playwright/test` exact pin (=1.58.2) so a future bump knows to re-verify the private symbols still exist. Nit #3 — main.ts TEST_WORKER_INDEX block had stray 6-space indentation. Verified: tsc -p . && tsconfig.electron && tsconfig.e2e → 0 errors; vitest boot-failure-overlay (3/3) + boot-failure-reauth (21/21) pass; npm run build clean; playwright e2e/boot-failure.spec.ts 2/2 pass. --- apps/desktop/e2e/fix-electron-tracing.ts | 10 +++++++-- apps/desktop/electron/main.ts | 6 ++--- apps/desktop/package.json | 2 +- apps/desktop/playwright.config.ts | 4 +++- .../components/gateway-connecting-overlay.tsx | 22 +++++-------------- apps/desktop/tsconfig.e2e.json | 9 ++++++++ 6 files changed, 29 insertions(+), 24 deletions(-) create mode 100644 apps/desktop/tsconfig.e2e.json diff --git a/apps/desktop/e2e/fix-electron-tracing.ts b/apps/desktop/e2e/fix-electron-tracing.ts index a59825e98444..1c2a9825dad5 100644 --- a/apps/desktop/e2e/fix-electron-tracing.ts +++ b/apps/desktop/e2e/fix-electron-tracing.ts @@ -21,6 +21,12 @@ * 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' @@ -31,13 +37,13 @@ const originalLaunch = electron.launch.bind(electron) electron.launch = async (options: any) => { const app = await originalLaunch(options) - const ctx = app._context as BrowserContext + 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._playwright as any + const pw = (electron as any)._playwright as any if (pw && !pw.__electronTracingPatched) { pw.__electronTracingPatched = true const original = pw._allContexts.bind(pw) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index fb60adbf1bf9..b24decc00815 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -7622,9 +7622,9 @@ 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() - } + if (mainWindow && !mainWindow.isDestroyed() && !mainWindow.isVisible()) { + mainWindow.show() + } } mainWindow.on('will-enter-full-screen', () => sendWindowStateChanged(true)) diff --git a/apps/desktop/package.json b/apps/desktop/package.json index b6fc344cd80e..743269c10476 100644 --- a/apps/desktop/package.json +++ b/apps/desktop/package.json @@ -41,7 +41,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", + "typecheck": "tsc -p . --noEmit && tsc -p tsconfig.electron.json --noEmit && tsc -p tsconfig.e2e.json --noEmit", "lint": "eslint src/ electron/", "lint:fix": "eslint src/ electron/ --fix", "fmt": "prettier --write 'src/**/*.{ts,tsx}' 'electron/**/*.ts' 'vite.config.ts'", diff --git a/apps/desktop/playwright.config.ts b/apps/desktop/playwright.config.ts index 9a661e5fadd0..dc06c59fe786 100644 --- a/apps/desktop/playwright.config.ts +++ b/apps/desktop/playwright.config.ts @@ -45,7 +45,9 @@ export default defineConfig({ // 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. - reducedMotion: 'reduce', + contextOptions: { + reducedMotion: 'reduce', + }, }, expect: { toHaveScreenshot: { diff --git a/apps/desktop/src/components/gateway-connecting-overlay.tsx b/apps/desktop/src/components/gateway-connecting-overlay.tsx index d2050f11fed4..0268755d5632 100644 --- a/apps/desktop/src/components/gateway-connecting-overlay.tsx +++ b/apps/desktop/src/components/gateway-connecting-overlay.tsx @@ -83,29 +83,17 @@ export function GatewayConnectingOverlay() { return } - if(reduce) { - // Under reduced motion, skip straight to gone — no text-out, no hold, - // no overlay fade. The overlay unmounts immediately. - setPhase('gone') - } - - if (previewing) { - // Under reduced motion, skip straight to gone — no text-out, no hold, - // no overlay fade. The overlay unmounts immediately. - if(reduce) { - setPhase('gone') - - return - } - + if (previewing) { const id = window.setTimeout(() => setPhase('text-out'), PREVIEW_CONNECT_MS) return () => window.clearTimeout(id) } if (gatewayState === 'open' && shownRef.current) { - // Under reduced motion, skip straight to gone — no text-out, no hold, - // no overlay fade. The overlay unmounts immediately. + // 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') } }, [phase, previewing, gatewayState, reduce]) diff --git a/apps/desktop/tsconfig.e2e.json b/apps/desktop/tsconfig.e2e.json new file mode 100644 index 000000000000..e0beb8961941 --- /dev/null +++ b/apps/desktop/tsconfig.e2e.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "types": ["node", "@playwright/test"], + "composite": true + }, + "include": ["e2e", "playwright.config.ts"], + "exclude": ["src", "electron"] +} From ac9a1014a69cb55e142f53c9ce51caa370c0d6e1 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 13:40:16 -0500 Subject: [PATCH 36/72] =?UTF-8?q?refactor(desktop):=20drop=20System=20sett?= =?UTF-8?q?ings=20section;=20keep-awake=20=E2=86=92=20Advanced?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Revert the dedicated System section: Window Translucency + UI Scale move back to Appearance, and Haptics returns to its titlebar-only home. Keep computer awake now lives as a device-local toggle at the top of Advanced (a ConfigSettings section-specific extra, like the Model block), keeping the statusbar quick-toggle. Relocated i18n back to settings.appearance / settings.config across all four locales. --- .../src/app/settings/appearance-settings.tsx | 61 +++++++++++++ .../src/app/settings/config-settings.tsx | 10 ++- apps/desktop/src/app/settings/index.tsx | 12 --- .../src/app/settings/system-settings.tsx | 90 ------------------- apps/desktop/src/app/settings/types.ts | 1 - apps/desktop/src/i18n/en.ts | 25 ++---- apps/desktop/src/i18n/ja.ts | 24 ++--- apps/desktop/src/i18n/types.ts | 19 ++-- apps/desktop/src/i18n/zh-hant.ts | 24 ++--- apps/desktop/src/i18n/zh.ts | 24 ++--- 10 files changed, 112 insertions(+), 178 deletions(-) delete mode 100644 apps/desktop/src/app/settings/system-settings.tsx diff --git a/apps/desktop/src/app/settings/appearance-settings.tsx b/apps/desktop/src/app/settings/appearance-settings.tsx index 7b37f6844dd5..32deb34d2d47 100644 --- a/apps/desktop/src/app/settings/appearance-settings.tsx +++ b/apps/desktop/src/app/settings/appearance-settings.tsx @@ -16,6 +16,8 @@ import { $backdrop, setBackdrop } from '@/store/backdrop' import { $embedAllowed, $embedMode, clearEmbedAllowed, type EmbedMode, setEmbedMode } from '@/store/embed-consent' import { $activeGatewayProfile, $profiles, normalizeProfileKey } from '@/store/profile' import { $toolViewMode, setToolViewMode } from '@/store/tool-view' +import { $translucency, setTranslucency } from '@/store/translucency' +import { $zoomPercent, setZoomPercent } from '@/store/zoom' import { getBaseColors, useTheme } from '@/themes/context' import { installVscodeThemeFromMarketplace } from '@/themes/install' import type { DesktopTheme } from '@/themes/types' @@ -62,6 +64,18 @@ function ThemePreview({ name, mode }: { name: string; mode: 'light' | 'dark' }) ) } +// UI scale presets, as zoom percentages. 100 is the browser-default size; +// the ids double as the percent values sent to the main process. A Cmd/Ctrl +// +/- step landing between presets highlights nothing, and the row +// description keeps showing the exact current percent. +const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const + +type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] + +function matchUiScalePreset(percent: number): UiScalePreset | null { + return UI_SCALE_PRESETS.find(preset => Number(preset) === percent) ?? null +} + function useDebounced(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value) @@ -231,8 +245,10 @@ export function AppearanceSettings() { const { t, isSavingLocale } = useI18n() const { themeName, mode, resolvedMode, availableThemes, setTheme, setMode } = useTheme() const toolViewMode = useStore($toolViewMode) + const zoomPercent = useStore($zoomPercent) const embedMode = useStore($embedMode) const embedAllowed = useStore($embedAllowed) + const translucency = useStore($translucency) const backdrop = useStore($backdrop) const installs = useStore($marketplaceInstalls) const profiles = useStore($profiles) @@ -277,6 +293,10 @@ export function AppearanceSettings() { { id: 'off', label: a.embedsOff } ] as const satisfies readonly { id: EmbedMode; label: string }[] + const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) + + const matchedScalePreset = matchUiScalePreset(zoomPercent) + return (
@@ -392,6 +412,47 @@ export function AppearanceSettings() { wide /> + { + triggerHaptic('selection') + setZoomPercent(Number(id)) + }} + options={uiScaleOptions} + value={matchedScalePreset ?? ('' as UiScalePreset)} + /> + } + description={a.uiScaleDesc(zoomPercent)} + title={a.uiScaleTitle} + /> + + + { + triggerHaptic('selection') + setTranslucency(Number(event.target.value)) + }} + step={5} + style={{ accentColor: 'var(--dt-primary)' }} + type="range" + value={translucency} + /> + + {translucency}% + +
+ } + description={a.translucencyDesc} + title={a.translucencyTitle} + /> + )} + {/* Device-local desktop pref (not config.yaml) — lives here since keeping + the machine awake is a power-user knob. */} + {activeSectionId === 'advanced' && ( + + )} {visibleFields.length === 0 ? ( ) : ( diff --git a/apps/desktop/src/app/settings/index.tsx b/apps/desktop/src/app/settings/index.tsx index 0637eda06fcf..f51cca30f949 100644 --- a/apps/desktop/src/app/settings/index.tsx +++ b/apps/desktop/src/app/settings/index.tsx @@ -10,7 +10,6 @@ import { Archive, BarChart3, Bell, - Cpu, Download, Globe, Info, @@ -43,7 +42,6 @@ import { NotificationsSettings } from './notifications-settings' import { PluginsSettings } from './plugins-settings' import { PROVIDER_VIEWS, ProvidersSettings, type ProviderView } from './providers-settings' import { SessionsSettings } from './sessions-settings' -import { SystemSettings } from './system-settings' import type { SettingsPageProps, SettingsView as SettingsViewId } from './types' const SETTINGS_VIEWS: readonly SettingsViewId[] = [ @@ -56,7 +54,6 @@ const SETTINGS_VIEWS: readonly SettingsViewId[] = [ 'billing', 'plugins', 'sessions', - 'system', 'about' ] @@ -156,13 +153,6 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set label: t.settings.nav.notifications, onSelect: () => setActiveView('notifications') }, - { - active: activeView === 'system', - icon: Cpu, - id: 'system', - label: t.settings.nav.system, - onSelect: () => setActiveView('system') - }, { active: activeView === 'billing', icon: BarChart3, @@ -326,8 +316,6 @@ export function SettingsView({ onClose, onConfigSaved, onMainModelChanged }: Set ) : activeView === 'notifications' ? ( - ) : activeView === 'system' ? ( - ) : activeView === 'billing' ? ( ) : activeView === 'plugins' ? ( diff --git a/apps/desktop/src/app/settings/system-settings.tsx b/apps/desktop/src/app/settings/system-settings.tsx deleted file mode 100644 index d338f5962a60..000000000000 --- a/apps/desktop/src/app/settings/system-settings.tsx +++ /dev/null @@ -1,90 +0,0 @@ -import { useStore } from '@nanostores/react' - -import { SegmentedControl } from '@/components/ui/segmented-control' -import { useI18n } from '@/i18n' -import { triggerHaptic } from '@/lib/haptics' -import { Cpu } from '@/lib/icons' -import { $hapticsMuted, setHapticsMuted } from '@/store/haptics' -import { $keepAwake, setKeepAwake } from '@/store/keep-awake' -import { $translucency, setTranslucency } from '@/store/translucency' -import { $zoomPercent, setZoomPercent } from '@/store/zoom' - -import { ListRow, SectionHeading, SettingsContent, ToggleRow } from './primitives' - -// UI scale presets as zoom percentages (100 = browser default); the ids double -// as the percent sent to main. A Cmd/Ctrl +/- step between presets highlights -// nothing, and the row description keeps showing the exact current percent. -const UI_SCALE_PRESETS = ['90', '100', '110', '125', '150', '175'] as const - -type UiScalePreset = (typeof UI_SCALE_PRESETS)[number] - -export function SystemSettings() { - const { t } = useI18n() - const s = t.settings.system - const keepAwake = useStore($keepAwake) - const translucency = useStore($translucency) - const zoomPercent = useStore($zoomPercent) - const hapticsMuted = useStore($hapticsMuted) - - const uiScaleOptions = UI_SCALE_PRESETS.map(preset => ({ id: preset, label: `${preset}%` })) - const matchedScale = UI_SCALE_PRESETS.find(preset => Number(preset) === zoomPercent) ?? ('' as UiScalePreset) - - return ( - - -

- {s.intro} -

- - - - { - triggerHaptic('selection') - setZoomPercent(Number(id)) - }} - options={uiScaleOptions} - value={matchedScale} - /> - } - description={s.uiScaleDesc(zoomPercent)} - title={s.uiScaleTitle} - /> - - - { - triggerHaptic('selection') - setTranslucency(Number(event.target.value)) - }} - step={5} - style={{ accentColor: 'var(--dt-primary)' }} - type="range" - value={translucency} - /> - - {translucency}% - - - } - description={s.translucencyDesc} - title={s.translucencyTitle} - /> - - setHapticsMuted(!on)} - /> -
- ) -} diff --git a/apps/desktop/src/app/settings/types.ts b/apps/desktop/src/app/settings/types.ts index bc726d221bb3..2828609ef63f 100644 --- a/apps/desktop/src/app/settings/types.ts +++ b/apps/desktop/src/app/settings/types.ts @@ -14,7 +14,6 @@ export type SettingsView = | 'plugins' | 'providers' | 'sessions' - | 'system' | `config:${string}` export type EnvPatch = Partial> diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 94a5a71da82f..3202e977934b 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -325,8 +325,7 @@ export const en: Translations = { about: 'About', billing: 'Billing', notifications: 'Notifications', - plugins: 'Plugins', - system: 'System' + plugins: 'Plugins' }, plugins: { title: 'Desktop plugins', @@ -380,19 +379,6 @@ export const en: Translations = { completionSoundDesc: 'Plays when an agent turn finishes. Pick a preset and preview it here.', completionSoundPreview: 'Preview' }, - system: { - title: 'System', - intro: 'How Hermes behaves on this machine. These are device-local — each computer keeps its own settings.', - keepAwakeTitle: 'Keep computer awake', - keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.', - translucencyTitle: 'Window Translucency', - translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', - uiScaleTitle: 'UI Scale', - uiScaleDesc: (percent: number) => - `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, - hapticsTitle: 'Haptic Feedback', - hapticsDesc: 'Trackpad taps on actions and toggles. Supported hardware only (macOS).' - }, sections: { model: 'Model', chat: 'Chat', @@ -424,6 +410,11 @@ export const en: Translations = { colorModeDesc: 'Pick a fixed mode or let Hermes follow your system setting.', toolViewTitle: 'Tool Call Display', toolViewDesc: 'Product hides raw tool payloads; Technical shows full input/output.', + uiScaleTitle: 'UI Scale', + uiScaleDesc: (percent: number) => + `Scales text and controls across the whole app. Cmd/Ctrl with +, - and 0 also works. Current: ${percent}%.`, + translucencyTitle: 'Window Translucency', + translucencyDesc: 'See your desktop through the whole window. macOS and Windows only.', backdropTitle: 'Chat Backdrop', backdropDesc: 'The faint statue image behind the conversation.', embedsTitle: 'Inline Embeds', @@ -532,7 +523,9 @@ export const en: Translations = { failedLoad: 'Settings failed to load', autosaveFailed: 'Autosave failed', imported: 'Config imported', - invalidJson: 'Invalid config JSON' + invalidJson: 'Invalid config JSON', + keepAwakeTitle: 'Keep computer awake', + keepAwakeDesc: 'Stop this machine from sleeping so long or overnight runs keep going. The display can still dim.' }, credentials: { pasteKey: 'Paste key', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index d8f7473fb4fa..fabc1bc7b70d 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -225,8 +225,7 @@ export const ja = defineLocale({ archivedChats: 'アーカイブ済みチャット', about: '情報', billing: '請求', - notifications: '通知', - system: 'システム' + notifications: '通知' }, notifications: { title: '通知', @@ -266,18 +265,6 @@ export const ja = defineLocale({ completionSoundDesc: 'エージェントのターン終了時に再生されます。プリセットを選んでここで試聴できます。', completionSoundPreview: '試聴' }, - system: { - title: 'システム', - intro: 'この端末での Hermes の動作。設定は端末ごとに保存されます。', - keepAwakeTitle: 'コンピューターをスリープさせない', - keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。', - translucencyTitle: 'ウィンドウの透過', - translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', - uiScaleTitle: 'UI スケール', - uiScaleDesc: (percent: number) => `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, - hapticsTitle: '触覚フィードバック', - hapticsDesc: '操作やトグル時にトラックパッドの触覚を返します。対応ハードウェアのみ(macOS)。' - }, sections: { model: 'モデル', chat: 'チャット', @@ -309,6 +296,11 @@ export const ja = defineLocale({ colorModeDesc: '固定モードを選ぶか、Hermes をシステム設定に合わせます。', toolViewTitle: 'ツール呼び出しの表示', toolViewDesc: 'プロダクト表示は生のツールペイロードを隠し、テクニカル表示は入出力をすべて表示します。', + uiScaleTitle: 'UI スケール', + uiScaleDesc: (percent: number) => + `アプリ全体の文字と UI を拡大縮小します。Cmd/Ctrl と +、-、0 でも変更できます。現在: ${percent}%`, + translucencyTitle: 'ウィンドウの透過', + translucencyDesc: 'ウィンドウ全体を透過させてデスクトップを表示します。macOS と Windows のみ。', backdropTitle: 'チャット背景', backdropDesc: '会話の背後に表示される淡い彫像の画像。', embedsTitle: 'インライン埋め込み', @@ -629,7 +621,9 @@ export const ja = defineLocale({ failedLoad: '設定の読み込みに失敗しました', autosaveFailed: '自動保存に失敗しました', imported: '設定をインポートしました', - invalidJson: '設定 JSON が無効です' + invalidJson: '設定 JSON が無効です', + keepAwakeTitle: 'コンピューターをスリープさせない', + keepAwakeDesc: '本体のスリープを防ぎ、長時間や夜通しの実行を継続します。画面は暗転できます。' }, credentials: { pasteKey: 'キーを貼り付け', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index 646901b4f818..c479706e29d8 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -284,7 +284,6 @@ export interface Translations { billing: string notifications: string plugins: string - system: string } plugins: { title: string @@ -318,18 +317,6 @@ export interface Translations { completionSoundDesc: string completionSoundPreview: string } - system: { - title: string - intro: string - keepAwakeTitle: string - keepAwakeDesc: string - translucencyTitle: string - translucencyDesc: string - uiScaleTitle: string - uiScaleDesc: (percent: number) => string - hapticsTitle: string - hapticsDesc: string - } sections: Record searchPlaceholder: Record<'about' | 'config' | 'gateway' | 'keys' | 'mcp' | 'sessions', string> modeOptions: Record<'light' | 'dark' | 'system', ModeOptionCopy> @@ -340,6 +327,10 @@ export interface Translations { colorModeDesc: string toolViewTitle: string toolViewDesc: string + uiScaleTitle: string + uiScaleDesc: (percent: number) => string + translucencyTitle: string + translucencyDesc: string backdropTitle: string backdropDesc: string embedsTitle: string @@ -444,6 +435,8 @@ export interface Translations { autosaveFailed: string imported: string invalidJson: string + keepAwakeTitle: string + keepAwakeDesc: string } credentials: { pasteKey: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 345a7935ac7a..986bf3792351 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -219,8 +219,7 @@ export const zhHant = defineLocale({ archivedChats: '已封存聊天', about: '關於', billing: '帳單', - notifications: '通知', - system: '系統' + notifications: '通知' }, notifications: { title: '通知', @@ -259,18 +258,6 @@ export const zhHant = defineLocale({ completionSoundDesc: '代理回合結束時播放。可在此選擇預設並預覽。', completionSoundPreview: '預覽' }, - system: { - title: '系統', - intro: 'Hermes 在這台電腦上的行為。設定會依裝置保存,每台電腦各自獨立。', - keepAwakeTitle: '保持電腦喚醒', - keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。', - translucencyTitle: '視窗透明', - translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', - uiScaleTitle: '介面縮放', - uiScaleDesc: (percent: number) => `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, - hapticsTitle: '觸覺回饋', - hapticsDesc: '在操作與開關時觸發觸控板輕觸。僅支援對應硬體(macOS)。' - }, sections: { model: '模型', chat: '聊天', @@ -301,6 +288,11 @@ export const zhHant = defineLocale({ colorModeDesc: '選擇固定模式,或讓 Hermes 跟隨系統設定。', toolViewTitle: '工具呼叫顯示', toolViewDesc: '產品模式會隱藏原始工具 payload;技術模式會顯示完整輸入/輸出。', + uiScaleTitle: '介面縮放', + uiScaleDesc: (percent: number) => + `縮放整個應用程式的文字與介面。也可使用 Cmd/Ctrl 加 +、- 或 0 調整。目前:${percent}%`, + translucencyTitle: '視窗透明', + translucencyDesc: '讓整個視窗透出桌面。僅支援 macOS 與 Windows。', backdropTitle: '聊天背景', backdropDesc: '對話後方那張淡淡的雕像圖片。', embedsTitle: '內嵌預覽', @@ -617,7 +609,9 @@ export const zhHant = defineLocale({ failedLoad: '設定載入失敗', autosaveFailed: '自動儲存失敗', imported: '設定已匯入', - invalidJson: '設定 JSON 無效' + invalidJson: '設定 JSON 無效', + keepAwakeTitle: '保持電腦喚醒', + keepAwakeDesc: '阻止本機睡眠,讓長時間或整夜執行持續進行。螢幕仍可變暗。' }, credentials: { pasteKey: '貼上金鑰', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index a019bf3992dc..deb750478509 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -316,8 +316,7 @@ export const zh: Translations = { about: '关于', billing: '账单', notifications: '通知', - plugins: '插件', - system: '系统' + plugins: '插件' }, plugins: { title: '桌面插件', @@ -370,18 +369,6 @@ export const zh: Translations = { completionSoundDesc: '智能体回合结束时播放。可在此选择预设并预览。', completionSoundPreview: '预览' }, - system: { - title: '系统', - intro: 'Hermes 在这台电脑上的行为。设置按设备保存,每台电脑各自独立。', - keepAwakeTitle: '保持电脑唤醒', - keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。', - translucencyTitle: '窗口透明', - translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', - uiScaleTitle: '界面缩放', - uiScaleDesc: (percent: number) => `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, - hapticsTitle: '触感反馈', - hapticsDesc: '在操作和开关时触发触控板轻触。仅支持相应硬件(macOS)。' - }, sections: { model: '模型', chat: '对话', @@ -412,6 +399,11 @@ export const zh: Translations = { colorModeDesc: '选择固定模式,或让 Hermes 跟随系统设置。', toolViewTitle: '工具调用显示', toolViewDesc: '产品模式隐藏原始工具数据;技术模式显示完整输入/输出。', + uiScaleTitle: '界面缩放', + uiScaleDesc: (percent: number) => + `缩放整个应用的文字和界面。也可使用 Cmd/Ctrl 加 +、- 或 0 调整。当前:${percent}%`, + translucencyTitle: '窗口透明', + translucencyDesc: '让整个窗口透出桌面。仅支持 macOS 和 Windows。', backdropTitle: '聊天背景', backdropDesc: '对话后方那张淡淡的雕像图片。', embedsTitle: '内嵌预览', @@ -728,7 +720,9 @@ export const zh: Translations = { failedLoad: '设置加载失败', autosaveFailed: '自动保存失败', imported: '配置已导入', - invalidJson: '配置 JSON 无效' + invalidJson: '配置 JSON 无效', + keepAwakeTitle: '保持电脑唤醒', + keepAwakeDesc: '阻止本机休眠,让长时间或通宵运行继续进行。屏幕仍可变暗。' }, credentials: { pasteKey: '粘贴密钥', From 3640b8e66624f1472cac19610b16958cb9fb65ee Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 14:45:24 -0400 Subject: [PATCH 37/72] ci(windows): pull e2e-windows scaffolding out to its own branch The Windows installer E2E scaffolding (e2e-windows.yml + AutoHotkey helper + button screenshots) lands in its own draft PR (ethie/windows-e2e) targeting this branch, so it can be reviewed + iterated independently of the desktop E2E suite. Both jobs remain `if: false` until the installer E2E is ready to run. --- .github/workflows/e2e-windows.yml | 396 ------------------------- e2e/windows/install-button.png | Bin 1200 -> 0 bytes e2e/windows/install-hermes-desktop.ahk | 126 -------- e2e/windows/launch-button.png | Bin 1485 -> 0 bytes 4 files changed, 522 deletions(-) delete mode 100644 .github/workflows/e2e-windows.yml delete mode 100644 e2e/windows/install-button.png delete mode 100644 e2e/windows/install-hermes-desktop.ahk delete mode 100644 e2e/windows/launch-button.png diff --git a/.github/workflows/e2e-windows.yml b/.github/workflows/e2e-windows.yml deleted file mode 100644 index 852d39dc59a3..000000000000 --- a/.github/workflows/e2e-windows.yml +++ /dev/null @@ -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 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 diff --git a/e2e/windows/install-button.png b/e2e/windows/install-button.png deleted file mode 100644 index feed47bd98fb3ffe464025c6f52553205ed89751..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1200 zcmX9;Ycv!H6rNFQVwr43op!QisWz%piB^c1W3(lqj7@2#u)7|yN|b~R&4`s_WcAQP zsV0vZjgg@envmCE!X}TXd5}p{G45q&cklTg=bm%#x#xU8F3Z!y%}{@-K7l|mbf>v` zX+1|vt9f&@TAIPD)fzF%%WXTcr@`usw$Y2A`9%>3UoZTZL_*%>CE9@k&z(Nh$*Gwu zRp@yRmmWcaH}(wAxF(`qEN(rCc@L4Ah>MwM_A6S1qJJiie*{TC^)KN;6aMuOKB!@26li|{ zM+gsFvAP`|OJGt1j&a!ZHyG~6s&=fC!nH?GBt~Hi#@xis(fH~u49LMD4nKZ|kUZ>u z11m!@?ly#7#)=k{kHfap=#dKf)!-Zt={$Jc0R=U{$VOH%q}MF_aLtUBd(%! z2u>HGUj{z!(JsR9DD;j%N)h(GgM0NeyHc>de}?3X2K%*lXN0l2ECNC2mbqBro^8-rYqMXuvo?d}atT~~FV z&z{X4YpWL&@F_u?%FRQ{mN3ic|G{AB89%ZnbA)JeLiU0i>M-Fu>Ex~WM3(2%&!99I>XmJ$I2a?r}x9-m6nNn4ome4c4RL5 zr1^=mja6vy@s^Xn{7`koQl8mNT0H13|81Z*ajz9KjN_{zXLK>_qq1+l@~QWN=OUdP zsg5KzzMQg2K7Mdr#Av#aYS~j^XmzD=K$||Pja;!0LrFb_vK(=pNU;L$tjCcGE-1PbO7Q;JLf~#Fw>(v1c6!H+q!`M{ft3BwzrCVC>@-Y;#GA!%@pONb9xbfzG zedk-q{cp%sv_N^UO&K+u$}Di$SdnZIlDyOVyASOp$#ZxP9<%$q4?4aT2l49uN267m zX*ylK{H`*whkU^Fb<@~4Yi$l(IUy+PaH}|K6X;zTA(oH^q)h?7Cu#O{dm~-#*CDu5 LJzR^oA4>WMWp}7g diff --git a/e2e/windows/install-hermes-desktop.ahk b/e2e/windows/install-hermes-desktop.ahk deleted file mode 100644 index 6d54729a0ec1..000000000000 --- a/e2e/windows/install-hermes-desktop.ahk +++ /dev/null @@ -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) \ No newline at end of file diff --git a/e2e/windows/launch-button.png b/e2e/windows/launch-button.png deleted file mode 100644 index 6ab89a75ca32480987264e3ee359063cb11f6446..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1485 zcmWkuc{G#@9Q{-xm5TO7#jA*VQxr+Ba?sdHQkJLX*++UaDn=?Klp@|JDU((qktI~3 zv1A|XC}SBzgc(T^!}t5md-Lu&_ug~=y64RZ1a)Ow#)W*urR5B%fUWTV`ODX4Z zg&TGbNRLIMRRmgJ!zUcHkH#&&xXufAT|kR)T%GuQJaD7{bV(t+;a&TNx10@+TFx2V)9cjjE;j(B63<_LIP1)@T>$412LbA zQQ7$U3p5JIvR2Sx;Mgyy?I3?nz%&UvnRqk`Jrgi44|+wgnT|XB@oGA1{*ApNvak_W zxuelVX#p30jKkD4RJDo<5* z4E#PApMbVL(DjqPZGiX!=pP2*5U6{jS*WzRo2=^q|0kGSii0B%n+pwH&^-t#Wl+;j z7Sv;E8Azs}vJC>0AuJu)g)lM(lM=|N#1bAP6~THMHVeszMZkQDL*K#m4hAM;S~=K6 z%666(H)CZRc_$xkWZ|z#SvC33Ylz8#lPsV=z=08H7D8h;zO2CaO?W#WPO$J+4qQ%= zo_mCOb!cz_)jaW46|@LRb|DV`fH(CJl!E*|vTFcn_ki08nU&DdPi{SnjRLT}flb}y zg=ZL(3mV>7+6rc&m{TJ?&cfCn=~^0UoJPSQ&I?>OLJ%`;%uV*&1+c%hounQ)yjo%O zXD;`i$MdaAmhd;WY(L{|n#obK(=)zdd)MLK;qg0}3+xl2nFoh=`XSGw=qvE@OaAybf+m>9iCpe?z~FB-U5aQV$ZY0)I%;u}Ka_%!u| zgwd|=r_5>Yc|)n!t1$7&iZA_9kr0tEZ(u>mmQUNc6x9*>z6lL0?YeS*#n>`Ri;`~7 z{29HJXi8#}Jo}C#$1!m#Dpy0lt7KQ%a?hQeDqUX)udmInv5t&-Uar!-4!;I1ufV!5 zM%RaR$J89!O&3Ur(ig$fM}#mgsAJi_E3wNvUgIJ5zY=w0F@)Q!3wpKIZO+!z?#TO1 zOvQ{T&7j)torda1B&`~+ulN}0mWI#Ni(ncm z@3wr5UVre8OP#8|f_}JHR;{MUc|=bqk-`IEAqm2l*Y3d#YFO z*`i2KZOHXk*)%po-rgbhrAi)^$CP&&?LDvfO2pWs+I^nBn=0`hJ{-N_g}(agF7fO} zVahkOK}K!TDtgF-I}zC^nSyIe%we(5@#sIZ}Frpd1=*Iqvkxj4W(|uPZm-w z!)`sk7cb1|F>~=y?)-Yqv#vZj&nIa01^HT0`IVt%V;g;G1^K*MZcnf1*sK%k!4Mq)=rnhkV|2X<_Pn&Y8)$Fv{Z=$k)A563t&_XmW^%wh_x4v*HR=XCSv1^LA z!&B*VJpagR>xAGj_O^rO%ebOHVwQPZGICPO6xj975up! zEbWq9o zi|RJRl|2PUl|D-upFHF|&$Qig{bj72ewS0j+@3*+V;PKj)HENle|ObMH8UjO`TC8v h%6~e3;57(9p257kTi97NOLobGx#>ZZOyg6b{{c0JSjYeX From fc8e96b200ec38e95b31192ffa0fb9fae1f4c94e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 13:49:27 -0500 Subject: [PATCH 38/72] fix(desktop): vertically center settings panel loader The settings OverlayMain has a titlebar-height top pad (no bottom pad), so the full-panel LoadingState centered in the band beneath it and read low. Cancel the top pad on the loader so it centers in the whole card; the one inline (mid-panel) memory loader switches to a plain min-height PageLoader so it's unaffected. --- .../src/app/settings/memory/provider-config-panel.tsx | 5 +++-- apps/desktop/src/app/settings/primitives.tsx | 11 ++++++++++- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx index ab7b29da9884..aa0c5ffd8661 100644 --- a/apps/desktop/src/app/settings/memory/provider-config-panel.tsx +++ b/apps/desktop/src/app/settings/memory/provider-config-panel.tsx @@ -1,5 +1,6 @@ import { useCallback, useEffect, useState } from 'react' +import { PageLoader } from '@/components/page-loader' import { Button } from '@/components/ui/button' import { DisclosureCaret } from '@/components/ui/disclosure-caret' import { getMemoryProviderConfig, saveMemoryProviderConfig } from '@/hermes' @@ -7,7 +8,7 @@ import { SlidersHorizontal } from '@/lib/icons' import { notifyError } from '@/store/notifications' import type { MemoryProviderConfig, MemoryProviderField } from '@/types/hermes' -import { ListRow, LoadingState, Pill } from '../primitives' +import { ListRow, Pill } from '../primitives' import { FieldControl, FieldTitle } from './field-control' import { ProviderConfigModal } from './provider-config-modal' @@ -95,7 +96,7 @@ export function ProviderConfigPanel({ provider }: { provider: string }) { ) } - return + return } const inlineFields = config.fields.filter(field => field.inline) diff --git a/apps/desktop/src/app/settings/primitives.tsx b/apps/desktop/src/app/settings/primitives.tsx index fd53b2d32b6f..4e55a2f9f4b9 100644 --- a/apps/desktop/src/app/settings/primitives.tsx +++ b/apps/desktop/src/app/settings/primitives.tsx @@ -143,8 +143,17 @@ export function ToggleRow({ ) } +// The settings panels render this as the sole child of the top-padded +// OverlayMain (pt = titlebar + 1rem, no bottom pad — see settings/index.tsx). +// Cancel that top pad so the loader centers in the whole card, not just the +// band beneath it. Inline loaders (mid-panel) should use directly. export function LoadingState({ label }: { label: string }) { - return + return ( + + ) } // Canonical implementation lives in components/ui; re-exported so the many From 3ef5249558a47752e46c3c6a8f74c1280221af5e Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 13:57:41 -0500 Subject: [PATCH 39/72] refactor(desktop): drop keep-awake statusbar toggle; persist in main MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Keep-awake lives only in Settings → Advanced now. Remove the statusbar quick-toggle (+ its Sun icon, store toggle helper, and keepAwakeOn/Off strings across locales). Since the statusbar was what eagerly loaded the store at boot, move persistence to the main process (keep-awake.json, re-applied on app ready — same pattern as translucency), so a cold launch restores the blocker without the renderer opening Settings. --- apps/desktop/electron/main.ts | 27 ++++++++++++++++--- .../app/shell/hooks/use-statusbar-items.tsx | 13 +-------- apps/desktop/src/i18n/en.ts | 2 -- apps/desktop/src/i18n/ja.ts | 2 -- apps/desktop/src/i18n/types.ts | 2 -- apps/desktop/src/i18n/zh-hant.ts | 2 -- apps/desktop/src/i18n/zh.ts | 2 -- apps/desktop/src/store/keep-awake.test.ts | 9 +------ apps/desktop/src/store/keep-awake.ts | 15 +++++------ 9 files changed, 32 insertions(+), 42 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index cac3031dee6a..7f09220e148a 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -105,8 +105,8 @@ import { } from './hardening' import { createLinkTitleWindow, guardLinkTitleSession, readLinkTitleWindowTitle } from './link-title-window' import { ensureMainWindow } from './main-window-lifecycle' -import { createKeepAwake } from './power-save' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' +import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' import { buildSessionWindowUrl, @@ -8556,11 +8556,31 @@ ipcMain.on('hermes:translucency', (_event, payload) => { } }) -// Keep-awake: the renderer owns the preference; main holds the one blocker. +// Keep-awake: hold the machine awake for long/overnight runs. Main owns the one +// blocker and its persisted state so a cold launch restores it (applied on +// ready — powerSaveBlocker needs the app ready). The renderer toggles it from +// Settings → Advanced over IPC. See store/keep-awake. +const KEEP_AWAKE_CONFIG_PATH = path.join(app.getPath('userData'), 'keep-awake.json') const keepAwake = createKeepAwake(powerSaveBlocker) +function readPersistedKeepAwake() { + try { + return JSON.parse(fs.readFileSync(KEEP_AWAKE_CONFIG_PATH, 'utf8')).on === true + } catch { + return false + } +} + ipcMain.on('hermes:keep-awake', (_event, on) => { - keepAwake.set(Boolean(on)) + const enabled = Boolean(on) + keepAwake.set(enabled) + + try { + fs.mkdirSync(path.dirname(KEEP_AWAKE_CONFIG_PATH), { recursive: true }) + fs.writeFileSync(KEEP_AWAKE_CONFIG_PATH, JSON.stringify({ on: enabled }, null, 2), 'utf8') + } catch (error) { + rememberLog(`[keep-awake] write failed: ${error.message}`) + } }) ipcMain.handle('hermes:openExternal', (_event, url) => { @@ -9563,6 +9583,7 @@ app.whenReady().then(() => { ensureWslWindowsFonts() configureSpellChecker() registerPowerResumeListeners() + keepAwake.set(readPersistedKeepAwake()) createWindow() // Win/Linux cold start: the launching hermes:// URL is in our own argv. diff --git a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx index 94485b4fef45..7d73180217a7 100644 --- a/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx +++ b/apps/desktop/src/app/shell/hooks/use-statusbar-items.tsx @@ -9,12 +9,11 @@ import { GatewayMenuPanel } from '@/app/shell/gateway-menu-panel' import { Codicon } from '@/components/ui/codicon' import { GlyphSpinner } from '@/components/ui/glyph-spinner' import { useI18n } from '@/i18n' -import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Sun, Terminal } from '@/lib/icons' +import { Activity, AlertCircle, Clock, Command, FolderOpen, Hash, Loader2, Terminal } from '@/lib/icons' import type { RuntimeReadinessResult } from '@/lib/runtime-readiness' import { contextBarLabel, LiveDuration, usageContextLabel } from '@/lib/statusbar' import { cn } from '@/lib/utils' import { copyFilePath, revealFile } from '@/store/file-actions' -import { $keepAwake, toggleKeepAwake } from '@/store/keep-awake' import { revealFileInTree } from '@/store/layout' import { $activeGatewayProfile } from '@/store/profile' import { $projectTree, projectNameForCwd } from '@/store/projects' @@ -91,7 +90,6 @@ export function useStatusbarItems({ const primaryActiveSessionId = useStore($activeSessionId) const activeGatewayProfile = useStore($activeGatewayProfile) const terminalTakeover = useStore($terminalTakeover) - const keepAwake = useStore($keepAwake) const primaryBusy = useStore($busy) const currentCwd = useStore($currentCwd) // Derive the workspace's project name from the already-cached project tree @@ -453,14 +451,6 @@ export function useStatusbarItems({ title: terminalTakeover ? copy.hideTerminal : copy.showTerminal, variant: 'action' }, - { - className: `w-7 justify-center px-0${keepAwake ? ' bg-accent/55 text-foreground' : ''}`, - icon: , - id: 'keep-awake', - onSelect: () => toggleKeepAwake(), - title: keepAwake ? copy.keepAwakeOn : copy.keepAwakeOff, - variant: 'action' - }, clientVersionItem, ...(backendVersionItem ? [backendVersionItem] : []) ], @@ -475,7 +465,6 @@ export function useStatusbarItems({ contextUsage, copy, currentUsage, - keepAwake, requestGateway, sessionStartedAt, gatewayState, diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index 3202e977934b..a17ecbb3bd60 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -2176,8 +2176,6 @@ export const en: Translations = { openCommandCenter: 'Open Command Center', showTerminal: 'Show terminal', hideTerminal: 'Hide terminal', - keepAwakeOn: 'Keeping awake — click to allow sleep', - keepAwakeOff: 'Keep computer awake', gateway: 'Gateway', gatewayReady: 'ready', gatewayNeedsSetup: 'needs setup', diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts index fabc1bc7b70d..3ade882c6ab8 100644 --- a/apps/desktop/src/i18n/ja.ts +++ b/apps/desktop/src/i18n/ja.ts @@ -2102,8 +2102,6 @@ export const ja = defineLocale({ openCommandCenter: 'コマンドセンターを開く', showTerminal: 'ターミナルを表示', hideTerminal: 'ターミナルを非表示', - keepAwakeOn: 'スリープ抑止中 — クリックで解除', - keepAwakeOff: 'コンピューターをスリープさせない', gateway: 'ゲートウェイ', gatewayReady: '準備完了', gatewayNeedsSetup: '設定が必要', diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts index c479706e29d8..25a9f67e2198 100644 --- a/apps/desktop/src/i18n/types.ts +++ b/apps/desktop/src/i18n/types.ts @@ -1804,8 +1804,6 @@ export interface Translations { openCommandCenter: string showTerminal: string hideTerminal: string - keepAwakeOn: string - keepAwakeOff: string gateway: string gatewayReady: string gatewayNeedsSetup: string diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts index 986bf3792351..d061a6d57a79 100644 --- a/apps/desktop/src/i18n/zh-hant.ts +++ b/apps/desktop/src/i18n/zh-hant.ts @@ -2035,8 +2035,6 @@ export const zhHant = defineLocale({ openCommandCenter: '開啟命令中心', showTerminal: '顯示終端機', hideTerminal: '隱藏終端機', - keepAwakeOn: '保持喚醒中 — 點擊以允許睡眠', - keepAwakeOff: '保持電腦喚醒', gateway: '閘道', gatewayReady: '就緒', gatewayNeedsSetup: '需要設定', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index deb750478509..61d6ff602aab 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -2346,8 +2346,6 @@ export const zh: Translations = { openCommandCenter: '打开命令中心', showTerminal: '显示终端', hideTerminal: '隐藏终端', - keepAwakeOn: '保持唤醒中 — 点击以允许休眠', - keepAwakeOff: '保持电脑唤醒', gateway: '网关', gatewayReady: '就绪', gatewayNeedsSetup: '需要设置', diff --git a/apps/desktop/src/store/keep-awake.test.ts b/apps/desktop/src/store/keep-awake.test.ts index e6acffa9f93d..4807e436ae4c 100644 --- a/apps/desktop/src/store/keep-awake.test.ts +++ b/apps/desktop/src/store/keep-awake.test.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { storedBoolean } from '@/lib/storage' -import { $keepAwake, setKeepAwake, toggleKeepAwake } from './keep-awake' +import { $keepAwake, setKeepAwake } from './keep-awake' const KEY = 'hermes.desktop.keepAwake.v1' const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } @@ -30,11 +30,4 @@ describe('keep-awake store', () => { expect(storedBoolean(KEY, true)).toBe(false) expect(setKeepAwakeBridge).toHaveBeenLastCalledWith(false) }) - - it('toggles the current value', () => { - toggleKeepAwake() - expect($keepAwake.get()).toBe(true) - toggleKeepAwake() - expect($keepAwake.get()).toBe(false) - }) }) diff --git a/apps/desktop/src/store/keep-awake.ts b/apps/desktop/src/store/keep-awake.ts index b0db1cbfdb55..f2d2022391f3 100644 --- a/apps/desktop/src/store/keep-awake.ts +++ b/apps/desktop/src/store/keep-awake.ts @@ -1,11 +1,12 @@ /** * Keep-awake — stop the machine sleeping during long, unattended runs. * - * A device-local preference (each computer keeps its own), off by default. The - * renderer owns the value and persists it; the main process holds the actual - * power-save blocker (see electron/power-save.ts) and re-reads this on every - * window load via the subscribe below. Linux/web builds without the bridge just - * no-op. + * A device-local preference (each computer keeps its own), off by default. This + * atom backs the Settings → Advanced toggle and mirrors changes to the main + * process, which owns the real power-save blocker AND its own persisted copy — + * so a cold launch restores the blocker without the renderer visiting Settings + * (see electron/main.ts + electron/power-save.ts). Linux/web without the bridge + * just no-op. */ import { atom } from 'nanostores' @@ -20,10 +21,6 @@ export function setKeepAwake(on: boolean): void { $keepAwake.set(on) } -export function toggleKeepAwake(): void { - $keepAwake.set(!$keepAwake.get()) -} - if (typeof window !== 'undefined') { $keepAwake.subscribe(on => { persistBoolean(KEY, on) From e2fd8a37dca030189ee4cdecaef96eb89b9f49eb Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 16:01:28 -0400 Subject: [PATCH 40/72] fix(desktop): refresh repo status on session switch with unchanged cwd (#68208) fix(desktop): refresh repo status on session switch with unchanged cwd --- apps/desktop/src/store/coding-status.test.ts | 26 +++++++++++++++++++- apps/desktop/src/store/coding-status.ts | 9 ++++++- 2 files changed, 33 insertions(+), 2 deletions(-) diff --git a/apps/desktop/src/store/coding-status.test.ts b/apps/desktop/src/store/coding-status.test.ts index d83e31a4849e..7d1915095f07 100644 --- a/apps/desktop/src/store/coding-status.test.ts +++ b/apps/desktop/src/store/coding-status.test.ts @@ -3,7 +3,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { HermesRepoStatus } from '@/global' import { $repoStatus, $repoStatusLoading, refreshRepoStatus } from './coding-status' -import { $currentCwd } from './session' +import { $currentCwd, $selectedStoredSessionId } from './session' const sampleStatus: HermesRepoStatus = { branch: 'feature/login', @@ -30,6 +30,7 @@ describe('refreshRepoStatus', () => { vi.useFakeTimers() $repoStatus.set(null) $currentCwd.set('') + $selectedStoredSessionId.set(null) delete (window as unknown as { hermesDesktop?: unknown }).hermesDesktop }) @@ -117,4 +118,27 @@ describe('refreshRepoStatus', () => { expect($repoStatus.get()).toEqual(sampleStatus) expect($repoStatusLoading.get()).toBe(false) }) + + it('refreshes when the stored session id changes even if the cwd is unchanged', async () => { + const probe = vi.fn(async () => sampleStatus) + stubProbe(probe) + + $currentCwd.set('/repo') + $selectedStoredSessionId.set('session-a') + // The cwd subscription fires on the set above; drain the debounced refresh. + vi.advanceTimersByTime(200) + await vi.runAllTicks() + + probe.mockClear() + + // Switch to a different session in the SAME repo dir. The cwd atom value is + // identical, so its subscription would not re-fire — but the stored-session + // id did change, which must still trigger a probe so the branch label + // tracks the new session's checked-out branch. + $selectedStoredSessionId.set('session-b') + vi.advanceTimersByTime(200) + await vi.runAllTicks() + + expect(probe).toHaveBeenCalledWith('/repo') + }) }) diff --git a/apps/desktop/src/store/coding-status.ts b/apps/desktop/src/store/coding-status.ts index 84f9c9b2aea5..0ed7eed5626d 100644 --- a/apps/desktop/src/store/coding-status.ts +++ b/apps/desktop/src/store/coding-status.ts @@ -4,7 +4,7 @@ import type { HermesGitWorktree, HermesRepoStatus } from '@/global' import { desktopGit } from '@/lib/desktop-git' import { $worktreeRefreshToken } from './projects' -import { $busy, $currentCwd } from './session' +import { $busy, $currentCwd, $selectedStoredSessionId } from './session' import { $workspaceChangeTick } from './workspace-events' // Live working-tree status for the active session's cwd — the data backbone of @@ -172,6 +172,13 @@ function scheduleRepoStatusRefresh(cwd?: null | string): void { // The active session's cwd changed (session switch / new chat) → re-probe. $currentCwd.subscribe(cwd => scheduleRepoStatusRefresh(cwd)) +// Switching sessions can land on the same cwd but a different checked-out +// branch (the agent ran `git checkout` in another session's terminal). The cwd +// subscription above won't fire when the path is identical, so the branch label +// would stay stale until a window focus or turn-settle triggers a refresh. +// Treat the stored-session id as a structural edge in its own right. +$selectedStoredSessionId.subscribe(() => scheduleRepoStatusRefresh()) + // A worktree add/remove (desktop op, or the agent's out-of-band git in a settled // turn / a window refocus — both already bump this token) → re-probe. $worktreeRefreshToken.subscribe(() => scheduleRepoStatusRefresh()) From d7b36070ef807841699ad32c5b6af547fee3ff64 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:04:12 -0600 Subject: [PATCH 41/72] fix(checkpoints): honor gateway config and task cwd (#68195) * fix(gateway): wire checkpoint config into agents * fix(checkpoints): resolve gateway file paths by task cwd --- agent/tool_executor.py | 44 +++++++++++---- gateway/platforms/api_server.py | 2 + gateway/run.py | 41 +++++++++++++- gateway/slash_commands.py | 24 +++------ .../test_tool_executor_checkpoint_paths.py | 40 ++++++++++++++ tests/gateway/test_agent_cache.py | 26 +++++++++ tests/gateway/test_api_server.py | 16 +++++- tests/gateway/test_background_command.py | 14 +++++ tests/gateway/test_checkpoint_config.py | 53 +++++++++++++++++++ tests/gateway/test_run_cleanup_progress.py | 46 ++++++++++++++++ 10 files changed, 275 insertions(+), 31 deletions(-) create mode 100644 tests/agent/test_tool_executor_checkpoint_paths.py create mode 100644 tests/gateway/test_checkpoint_config.py diff --git a/agent/tool_executor.py b/agent/tool_executor.py index f740fedb79c6..d235de36c03d 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -53,6 +53,28 @@ from tools.budget_config import BudgetConfig, DEFAULT_BUDGET, budget_for_context logger = logging.getLogger(__name__) +def _ensure_file_checkpoint( + agent, + function_name: str, + function_args: dict, + effective_task_id: str, +) -> None: + """Checkpoint the same workspace path that the file tool will mutate.""" + file_path = function_args.get("path", "") + if not file_path: + return + + # File tools resolve relative paths against the task's live/session cwd, + # which can differ from the Hermes process cwd (notably in Docker). Resolve + # through that same path pipeline before asking the checkpoint manager to + # discover the project root. + from tools.file_tools import _resolve_path_for_task + + resolved_path = _resolve_path_for_task(file_path, effective_task_id or "default") + work_dir = agent._checkpoint_mgr.get_working_dir_for_path(str(resolved_path)) + agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}") + + def _budget_for_agent(agent) -> BudgetConfig: """Resolve a tool-result BudgetConfig scaled to the agent's context window. @@ -502,10 +524,12 @@ def execute_tool_calls_concurrent(agent, assistant_message, messages: list, effe # Checkpoint for file-mutating tools if function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled: try: - file_path = function_args.get("path", "") - if file_path: - work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path) - agent._checkpoint_mgr.ensure_checkpoint(work_dir, f"before {function_name}") + _ensure_file_checkpoint( + agent, + function_name, + function_args, + effective_task_id, + ) except Exception: pass @@ -1188,12 +1212,12 @@ def execute_tool_calls_sequential(agent, assistant_message, messages: list, effe # Checkpoint: snapshot working dir before file-mutating tools if not _execution_blocked and function_name in {"write_file", "patch"} and agent._checkpoint_mgr.enabled: try: - file_path = function_args.get("path", "") - if file_path: - work_dir = agent._checkpoint_mgr.get_working_dir_for_path(file_path) - agent._checkpoint_mgr.ensure_checkpoint( - work_dir, f"before {function_name}" - ) + _ensure_file_checkpoint( + agent, + function_name, + function_args, + effective_task_id, + ) except Exception: pass # never block tool execution diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 65d5b324cc45..973ba5507379 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -1777,6 +1777,7 @@ class APIServerAdapter(BasePlatformAdapter): """ from run_agent import AIAgent from gateway.run import ( + _checkpoint_agent_kwargs, _current_max_iterations, _resolve_runtime_agent_kwargs, _resolve_gateway_model, @@ -1858,6 +1859,7 @@ class APIServerAdapter(BasePlatformAdapter): agent = AIAgent( model=model, **runtime_kwargs, + **_checkpoint_agent_kwargs(user_config), max_iterations=max_iterations, quiet_mode=True, verbose_logging=False, diff --git a/gateway/run.py b/gateway/run.py index f59e006f6249..9184c22b4125 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -2592,6 +2592,35 @@ def _load_gateway_config() -> dict: return raw +def _checkpoint_agent_kwargs(config: dict | None) -> dict: + """Translate gateway checkpoint config into ``AIAgent`` constructor args. + + The gateway reads raw YAML instead of ``load_config()``, so checkpoint + defaults must be supplied here. Keep legacy ``checkpoints: true`` configs + working while giving every gateway-created agent the same limits. + """ + cp_cfg = config.get("checkpoints", {}) if isinstance(config, dict) else {} + if isinstance(cp_cfg, bool): + cp_cfg = {"enabled": cp_cfg} + elif not isinstance(cp_cfg, dict): + cp_cfg = {} + + from hermes_cli.config import DEFAULT_CONFIG + defaults = DEFAULT_CONFIG["checkpoints"] + return { + "checkpoints_enabled": cp_cfg.get("enabled", defaults["enabled"]), + "checkpoint_max_snapshots": cp_cfg.get( + "max_snapshots", defaults["max_snapshots"], + ), + "checkpoint_max_total_size_mb": cp_cfg.get( + "max_total_size_mb", defaults["max_total_size_mb"], + ), + "checkpoint_max_file_size_mb": cp_cfg.get( + "max_file_size_mb", defaults["max_file_size_mb"], + ), + } + + def _load_gateway_runtime_config() -> dict: """Load gateway config for runtime reads, expanding supported ``${VAR}`` refs. @@ -14777,6 +14806,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew agent = AIAgent( model=turn_route["model"], **turn_route["runtime"], + **_checkpoint_agent_kwargs(user_config), max_iterations=max_iterations, quiet_mode=True, verbose_logging=False, @@ -17273,6 +17303,10 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew ("compression", "protect_last_n"), ("agent", "disabled_toolsets"), ("memory", "provider"), + ("checkpoints", "enabled"), + ("checkpoints", "max_snapshots"), + ("checkpoints", "max_total_size_mb"), + ("checkpoints", "max_file_size_mb"), ) _HONCHO_CACHE_BUSTING_KEYS = ( @@ -17336,7 +17370,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew cfg = user_config if isinstance(user_config, dict) else {} for section, key in cls._CACHE_BUSTING_CONFIG_KEYS: section_val = cfg.get(section) - if isinstance(section_val, dict): + if section == "checkpoints" and isinstance(section_val, bool): + # Preserve legacy ``checkpoints: true`` behavior. A live + # toggle must still rebuild the cached agent. + out[f"{section}.{key}"] = section_val if key == "enabled" else None + elif isinstance(section_val, dict): out[f"{section}.{key}"] = section_val.get(key) else: out[f"{section}.{key}"] = None @@ -20303,6 +20341,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew agent = AIAgent( model=turn_route["model"], **turn_route["runtime"], + **_checkpoint_agent_kwargs(user_config), max_iterations=max_iterations, quiet_mode=True, verbose_logging=False, diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 4aa75fa7985a..a2da10556c8e 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -2668,31 +2668,19 @@ class GatewaySlashCommandsMixin: async def _handle_rollback_command(self, event: MessageEvent) -> str: """Handle /rollback command — list or restore filesystem checkpoints.""" - from gateway.run import _hermes_home + from gateway.run import _checkpoint_agent_kwargs, _load_gateway_config from tools.checkpoint_manager import CheckpointManager, format_checkpoint_list - # Read checkpoint config from config.yaml - cp_cfg = {} - try: - import yaml as _y - _cfg_path = _hermes_home / "config.yaml" - if _cfg_path.exists(): - with open(_cfg_path, encoding="utf-8") as _f: - _data = _y.safe_load(_f) or {} - cp_cfg = _data.get("checkpoints", {}) - if isinstance(cp_cfg, bool): - cp_cfg = {"enabled": cp_cfg} - except Exception: - pass + cp_kwargs = _checkpoint_agent_kwargs(_load_gateway_config()) - if not cp_cfg.get("enabled", False): + if not cp_kwargs["checkpoints_enabled"]: return t("gateway.rollback.not_enabled") mgr = CheckpointManager( enabled=True, - max_snapshots=cp_cfg.get("max_snapshots", 50), - max_total_size_mb=cp_cfg.get("max_total_size_mb", 500), - max_file_size_mb=cp_cfg.get("max_file_size_mb", 10), + max_snapshots=cp_kwargs["checkpoint_max_snapshots"], + max_total_size_mb=cp_kwargs["checkpoint_max_total_size_mb"], + max_file_size_mb=cp_kwargs["checkpoint_max_file_size_mb"], ) cwd = os.getenv("TERMINAL_CWD", str(Path.home())) diff --git a/tests/agent/test_tool_executor_checkpoint_paths.py b/tests/agent/test_tool_executor_checkpoint_paths.py new file mode 100644 index 000000000000..46c28fc37451 --- /dev/null +++ b/tests/agent/test_tool_executor_checkpoint_paths.py @@ -0,0 +1,40 @@ +"""Behavioral coverage for file-tool checkpoint path resolution.""" + +from types import SimpleNamespace + +from agent.tool_executor import _ensure_file_checkpoint +from tools.checkpoint_manager import CheckpointManager + + +def test_relative_file_checkpoint_uses_task_workspace(tmp_path, monkeypatch): + """Checkpoint lookup must use the same cwd as a relative file mutation.""" + process_cwd = tmp_path / "opt" / "hermes" + workspace_cwd = tmp_path / "opt" / "data" / "workspace" + process_cwd.mkdir(parents=True) + workspace_cwd.mkdir(parents=True) + + # Both directories contain content so checkpointing the wrong one would + # still succeed and remain observable as the regression did in Docker. + (process_cwd / "pyproject.toml").write_text("[project]\nname = 'hermes'\n") + (workspace_cwd / "pyproject.toml").write_text("[project]\nname = 'workspace'\n") + (workspace_cwd / "existing.txt").write_text("before\n") + + monkeypatch.chdir(process_cwd) + monkeypatch.setenv("TERMINAL_CWD", str(workspace_cwd)) + monkeypatch.setattr( + "tools.checkpoint_manager.CHECKPOINT_BASE", + tmp_path / "checkpoints", + ) + + manager = CheckpointManager(enabled=True) + agent = SimpleNamespace(_checkpoint_mgr=manager) + + _ensure_file_checkpoint( + agent, + "write_file", + {"path": "test_permissions2.txt"}, + "gateway-session", + ) + + assert manager.list_checkpoints(str(workspace_cwd)) + assert manager.list_checkpoints(str(process_cwd)) == [] diff --git a/tests/gateway/test_agent_cache.py b/tests/gateway/test_agent_cache.py index 81264fa37c9d..853a9538f5d0 100644 --- a/tests/gateway/test_agent_cache.py +++ b/tests/gateway/test_agent_cache.py @@ -243,6 +243,32 @@ class TestExtractCacheBustingConfig: assert out["compression.protect_last_n"] == 25 assert out["compression.codex_app_server_auto"] == "hermes" + def test_reads_checkpoint_subkeys(self): + from gateway.run import GatewayRunner + + out = GatewayRunner._extract_cache_busting_config( + { + "checkpoints": { + "enabled": True, + "max_snapshots": 12, + "max_total_size_mb": 333, + "max_file_size_mb": 5, + } + } + ) + + assert out["checkpoints.enabled"] is True + assert out["checkpoints.max_snapshots"] == 12 + assert out["checkpoints.max_total_size_mb"] == 333 + assert out["checkpoints.max_file_size_mb"] == 5 + + def test_reads_legacy_checkpoint_boolean(self): + from gateway.run import GatewayRunner + + out = GatewayRunner._extract_cache_busting_config({"checkpoints": True}) + + assert out["checkpoints.enabled"] is True + def test_missing_keys_yield_none(self): """Absent config keys must produce None values (still contribute to signature).""" from gateway.run import GatewayRunner diff --git a/tests/gateway/test_api_server.py b/tests/gateway/test_api_server.py index a9621924b577..56734698019a 100644 --- a/tests/gateway/test_api_server.py +++ b/tests/gateway/test_api_server.py @@ -330,7 +330,7 @@ class TestAdapterInit: adapter = APIServerAdapter(config) assert adapter._port == 8642 - def test_create_agent_forwards_config_reasoning_effort(self, monkeypatch): + def test_create_agent_forwards_runtime_config(self, monkeypatch): captured = {} class FakeAgent: @@ -349,7 +349,15 @@ class TestAdapterInit: monkeypatch.setattr("gateway.run._resolve_gateway_model", lambda: "gpt-5.5") monkeypatch.setattr( "gateway.run._load_gateway_config", - lambda: {"agent": {"reasoning_effort": "xhigh"}}, + lambda: { + "agent": {"reasoning_effort": "xhigh"}, + "checkpoints": { + "enabled": True, + "max_snapshots": 7, + "max_total_size_mb": 321, + "max_file_size_mb": 4, + }, + }, ) monkeypatch.setattr( "gateway.run.GatewayRunner._load_reasoning_config", @@ -365,6 +373,10 @@ class TestAdapterInit: assert isinstance(agent, FakeAgent) assert captured["reasoning_config"] == {"enabled": True, "effort": "xhigh"} + assert captured["checkpoints_enabled"] is True + assert captured["checkpoint_max_snapshots"] == 7 + assert captured["checkpoint_max_total_size_mb"] == 321 + assert captured["checkpoint_max_file_size_mb"] == 4 def test_create_agent_refreshes_max_iterations_from_runtime_config(self, monkeypatch): captured = {} diff --git a/tests/gateway/test_background_command.py b/tests/gateway/test_background_command.py index a6c44f6f1189..462b9ae0a65a 100644 --- a/tests/gateway/test_background_command.py +++ b/tests/gateway/test_background_command.py @@ -248,7 +248,16 @@ class TestRunBackgroundTask: mock_result = {"final_response": "Hello from background!", "messages": []} + checkpoint_config = { + "checkpoints": { + "enabled": True, + "max_snapshots": 8, + "max_total_size_mb": 222, + "max_file_size_mb": 3, + } + } with patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "test-key"}), \ + patch("gateway.run._load_gateway_config", return_value=checkpoint_config), \ patch("run_agent.AIAgent") as MockAgent: mock_agent_instance = MagicMock() mock_agent_instance.shutdown_memory_provider = MagicMock() @@ -264,6 +273,11 @@ class TestRunBackgroundTask: content = call_args[1].get("content", call_args[0][1] if len(call_args[0]) > 1 else "") assert "Background task complete" in content assert "Hello from background!" in content + agent_kwargs = MockAgent.call_args.kwargs + assert agent_kwargs["checkpoints_enabled"] is True + assert agent_kwargs["checkpoint_max_snapshots"] == 8 + assert agent_kwargs["checkpoint_max_total_size_mb"] == 222 + assert agent_kwargs["checkpoint_max_file_size_mb"] == 3 mock_agent_instance.shutdown_memory_provider.assert_called_once() mock_agent_instance.close.assert_called_once() diff --git a/tests/gateway/test_checkpoint_config.py b/tests/gateway/test_checkpoint_config.py new file mode 100644 index 000000000000..51876f2f09b2 --- /dev/null +++ b/tests/gateway/test_checkpoint_config.py @@ -0,0 +1,53 @@ +"""Runtime coverage for gateway filesystem-checkpoint configuration.""" + + +def test_gateway_checkpoint_config_reaches_real_agent(tmp_path, monkeypatch): + """Raw gateway YAML must configure the real agent checkpoint manager.""" + from gateway import run as gateway_run + from run_agent import AIAgent + + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + (tmp_path / "config.yaml").write_text( + """checkpoints: + enabled: true + max_snapshots: 11 + max_total_size_mb: 345 + max_file_size_mb: 6 +""", + encoding="utf-8", + ) + + config = gateway_run._load_gateway_config() + agent = AIAgent( + model="anthropic/claude-sonnet-4", + api_key="test", + base_url="https://openrouter.ai/api/v1", + provider="openrouter", + quiet_mode=True, + skip_context_files=True, + skip_memory=True, + enabled_toolsets=[], + **gateway_run._checkpoint_agent_kwargs(config), + ) + try: + manager = agent._checkpoint_mgr + assert manager.enabled is True + assert manager.max_snapshots == 11 + assert manager.max_total_size_mb == 345 + assert manager.max_file_size_mb == 6 + finally: + agent.close() + + +def test_checkpoint_agent_kwargs_supports_legacy_boolean_config(): + from gateway.run import _checkpoint_agent_kwargs + from hermes_cli.config import DEFAULT_CONFIG + + kwargs = _checkpoint_agent_kwargs({"checkpoints": True}) + defaults = DEFAULT_CONFIG["checkpoints"] + + assert kwargs["checkpoints_enabled"] is True + assert kwargs["checkpoint_max_snapshots"] == defaults["max_snapshots"] + assert kwargs["checkpoint_max_total_size_mb"] == defaults["max_total_size_mb"] + assert kwargs["checkpoint_max_file_size_mb"] == defaults["max_file_size_mb"] diff --git a/tests/gateway/test_run_cleanup_progress.py b/tests/gateway/test_run_cleanup_progress.py index 0a66be4b30a8..b71c8b2712f5 100644 --- a/tests/gateway/test_run_cleanup_progress.py +++ b/tests/gateway/test_run_cleanup_progress.py @@ -233,6 +233,52 @@ async def test_cleanup_off_by_default_leaves_bubbles(monkeypatch, tmp_path): assert adapter.deleted == [] +@pytest.mark.asyncio +async def test_messaging_agent_forwards_checkpoint_config(monkeypatch, tmp_path): + """Writable gateway agents must receive the configured checkpoint limits.""" + captured = {} + + class CheckpointCaptureAgent(ProgressAgent): + def __init__(self, **kwargs): + captured.update(kwargs) + super().__init__(**kwargs) + + adapter = CleanupCaptureAdapter() + runner = _make_runner(adapter) + gateway_run = _install_fakes( + monkeypatch, CheckpointCaptureAgent, cleanup_on=False, + ) + monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) + monkeypatch.setattr( + gateway_run, + "_load_gateway_config", + lambda: { + "checkpoints": { + "enabled": True, + "max_snapshots": 9, + "max_total_size_mb": 444, + "max_file_size_mb": 6, + } + }, + ) + + source = SessionSource(platform=Platform.TELEGRAM, chat_id="-1001") + result = await runner._run_agent( + message="hello", + context_prompt="", + history=[], + source=source, + session_id="sess-checkpoints", + session_key="agent:main:telegram:group:-1001", + ) + + assert result["final_response"] == "done" + assert captured["checkpoints_enabled"] is True + assert captured["checkpoint_max_snapshots"] == 9 + assert captured["checkpoint_max_total_size_mb"] == 444 + assert captured["checkpoint_max_file_size_mb"] == 6 + + @pytest.mark.asyncio async def test_cleanup_registers_callback_and_deletes_on_success(monkeypatch, tmp_path): """With the flag on, the cleanup callback deletes the progress bubble.""" From b9f82ed39f42e6427b1cb1dba68159c0d50df0a3 Mon Sep 17 00:00:00 2001 From: ethernet Date: Thu, 16 Jul 2026 18:17:49 -0400 Subject: [PATCH 42/72] ci: live-updating PR review comment with structured job statuses MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the static comment-pending + comment-results two-job pattern with a live-updating comment system that polls the GitHub Actions API every 15s, re-assembles the review comment from whatever results are available, and upserts it via the marker. The comment updates in real time as each job finishes — no waiting for the full pipeline. Every CI job that wants to appear in the review comment emits a review_status output — a JSON array of objects, each with a source and a results array: [ { "source": "review-label-gate", "results": [ {"kind": "action_required", "title": "...", "summary": "...", "how_to_fix": "..."}, {"kind": "info", "title": "...", "summary": "..."} ] }, { "source": "ci timing", "results": [ {"kind": "warning", "title": "CI timings", "summary": "...", "detail": "...", "link": "..."} ] } ] One job can emit multiple results of different kinds. The source field is used to exclude the corresponding job from the synthesized error list (case-insensitive, hyphen-normalized matching against GitHub Actions job display names). | job | source | kind (on failure) | section | |----------------------------|--------------------------|---------------------------|----------------------| | review-labels | review label gate | action_required / info | Action required | | lockfile-diff | lockfile-diff | action_required | Action required | | ci-timings | ci timing | warning / info | Warnings | | supply-chain scan | supply chain | error / (none) | Job failures | | supply-chain dep-bounds | supply chain | action_required / (none) | Action required | | osv-scanner | osv scan | warning / (none) | Warnings | | uv-lockfile-check | uv.lock check | action_required / (none) | Action required | | history-check | unrelated histories | action_required | Action required | | contributor-check | contributor attribution | action_required | Action required | Jobs that find nothing emit [] (empty array) — no noise info items. A single comment-live job polls the GitHub Actions API every 15s, classifies jobs into (completed, pending), assembles the comment, and upserts it. Merges review_status outputs from all needs jobs via toJSON(needs.*.outputs.review_status), and downloads the ci-timings artifact when it becomes available. Shows commit SHA + message below the header. The assembler has ZERO job-specific knowledge. It just: 1. collect_from_statuses() — flattens all nested status objects into ReviewItems 2. collect_failed_jobs() — synthesizes errors for failed jobs with no declared status 3. _attach_job_urls() — fills in per-job log links for ALL items 4. render_comment() — groups by severity, renders with group headers Each item shows links inline next to the title: View report (job-emitted URL) and View job (auto-attached logs link). Each info item is its own collapsible
block. # ૮ >ﻌ< ა ci review running on abc1234 — commit message first line ## ❌ Job failures ### {title} · [View job](url) {summary} ## ⚠️ Action required ### {title} · [View job](url) {summary} **How to fix:** {how_to_fix} ## ⚠️ Warnings ### {title} · [View report](url) · [View job](url) {summary} {detail}
{title} {content}
Still running 3 jobs: ci-timings, docker - test_assemble_review_comment.py (48 tests): collect_from_statuses, collect_failed_jobs with exclude_sources, _attach_job_urls, render_comment (group headers, inline links, commit info, per-item details, pending footer), assemble integration - test_live_comment.py (16 tests): classify_jobs pure function - test_timings_report.py (10 tests): generate_review_status nested format - test_lockfile_diff.py (6 tests) - test_classify_changes.py (32 tests, pre-existing) --- .github/workflows/ci.yml | 116 +++- .github/workflows/contributor-check.yml | 18 + .github/workflows/history-check.yml | 12 +- .github/workflows/label-rerun.yml | 81 +++ .github/workflows/lint.yml | 126 +---- .github/workflows/lockfile-diff.yml | 81 ++- .github/workflows/osv-scanner.yml | 139 +++-- .github/workflows/review-labels.yml | 92 ++++ .github/workflows/supply-chain-audit.yml | 168 +++--- .github/workflows/uv-lockfile-check.yml | 11 + scripts/ci/assemble_review_comment.py | 424 +++++++++++++++ scripts/ci/emit_review_status.py | 143 +++++ scripts/ci/live_comment.py | 526 ++++++++++++++++++ scripts/ci/lockfile_diff.py | 34 +- scripts/ci/timings_report.py | 93 ++++ tests/ci/test_assemble_review_comment.py | 643 +++++++++++++++++++++++ tests/ci/test_live_comment.py | 162 ++++++ tests/ci/test_lockfile_diff.py | 7 +- tests/ci/test_timings_report.py | 143 +++++ 19 files changed, 2696 insertions(+), 323 deletions(-) create mode 100644 .github/workflows/label-rerun.yml create mode 100644 .github/workflows/review-labels.yml create mode 100644 scripts/ci/assemble_review_comment.py create mode 100644 scripts/ci/emit_review_status.py create mode 100644 scripts/ci/live_comment.py create mode 100644 tests/ci/test_assemble_review_comment.py create mode 100644 tests/ci/test_live_comment.py create mode 100644 tests/ci/test_timings_report.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c9dd08790778..96ff02597f0e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -17,7 +17,7 @@ on: permissions: contents: read - pull-requests: write # needed by lint (PR comment) + supply-chain (PR comment) + pull-requests: write # needed by lint (PR comment) + supply-chain review_status actions: read # needed by osv-scanner (SARIF upload) security-events: write # needed by osv-scanner (SARIF upload) packages: write # needed by docker build @@ -73,11 +73,10 @@ jobs: lint: name: Python lints needs: detect - if: needs.detect.outputs.python == 'true' || needs.detect.outputs.ci_review == 'true' + if: needs.detect.outputs.python == 'true' uses: ./.github/workflows/lint.yml with: event_name: ${{ needs.detect.outputs.event_name }} - ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} secrets: inherit js-tests: @@ -144,12 +143,20 @@ jobs: supply-chain: name: Supply-chain scan needs: detect - if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true' || needs.detect.outputs.mcp_catalog == 'true') + if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.scan == 'true' || needs.detect.outputs.deps == 'true') uses: ./.github/workflows/supply-chain-audit.yml with: event_name: ${{ needs.detect.outputs.event_name }} scan: ${{ needs.detect.outputs.scan == 'true' }} deps: ${{ needs.detect.outputs.deps == 'true' }} + + review-labels: + name: Review label gate + needs: detect + if: needs.detect.outputs.event_name == 'pull_request' && (needs.detect.outputs.ci_review == 'true' || needs.detect.outputs.mcp_catalog == 'true') + uses: ./.github/workflows/review-labels.yml + with: + ci_review: ${{ needs.detect.outputs.ci_review == 'true' }} mcp_catalog: ${{ needs.detect.outputs.mcp_catalog == 'true' }} secrets: inherit @@ -158,12 +165,90 @@ jobs: uses: ./.github/workflows/osv-scanner.yml secrets: inherit + # ───────────────────────────────────────────────────────────────────── + # Live-updating PR review comment. + # + # A single ``comment-live`` job polls the GitHub Actions API every 15s + # for job statuses in this run, re-assembles the review comment from + # whatever results are available, and upserts it via the + # ```` marker. + # + # The poller exits when all non-infra jobs are completed (or on + # timeout). ci-timings' review_status is picked up automatically when + # its artifact becomes available — the poller downloads and merges it. + # ───────────────────────────────────────────────────────────────────── + comment-live: + name: CI review comment (live) + needs: [detect, review-labels, lockfile-diff, supply-chain, osv-scanner, uv-lockfile, history-check, contributor-check] + if: always() && github.event_name == 'pull_request' && github.event.pull_request.head.repo.fork != true + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Run live comment poller + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_REPOSITORY: ${{ github.repository }} + GITHUB_RUN_ID: ${{ github.run_id }} + PR_NUMBER: ${{ github.event.pull_request.number }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + # Commit info for the review comment header. + COMMIT_SHA: ${{ github.event.pull_request.head.sha }} + COMMIT_MESSAGE: ${{ github.event.pull_request.head.commit.message }} + COMMIT_URL: ${{ github.server_url }}/${{ github.repository }}/pull/${{ github.event.pull_request.number }}/commits/${{ github.event.pull_request.head.sha }} + # Structured review statuses from workflow_call jobs. + # Each job outputs a JSON array of {source, results: [...]} objects + # that the assembler renders directly — no hardcoded job-name + # matching. We merge all available outputs into one array. + REVIEW_STATUSES: ${{ toJSON(needs.*.outputs.review_status) }} + run: | + set -uo pipefail + + # REVIEW_STATUSES is a JSON array of strings (some may be empty + # when a job was skipped). Parse each string and merge into one + # flat array for the assembler. + python3 - <<'PYEOF' + import json, os, sys + + raw = os.environ.get("REVIEW_STATUSES", "") + merged = [] + if raw: + try: + arr = json.loads(raw) + except (json.JSONDecodeError, TypeError): + arr = [] + for item in arr: + if not item: + continue + try: + statuses = json.loads(item) + except (json.JSONDecodeError, TypeError): + continue + if isinstance(statuses, list): + merged.extend(statuses) + + # Write merged array to a temp file the poller reads. + with open("/tmp/review_statuses.json", "w") as f: + json.dump(merged, f) + print(f"Merged {len(merged)} review status entries") + PYEOF + + python3 scripts/ci/live_comment.py \ + --interval 15 \ + --timeout 2100 \ + --review-statuses-file /tmp/review_statuses.json + # ───────────────────────────────────────────────────────────────────── # Gate: runs after everything. ``if: always()`` ensures it reports a # status even when some deps were skipped. Only actual ``failure`` # results cause it to fail; ``skipped`` is treated as success. # # Branch protection should require ONLY this check. + # + # Outputs ``needs-json`` — a compact ``{job_name: result}`` dict — so + # the live comment poller can list failed jobs in the PR comment. # ───────────────────────────────────────────────────────────────────── all-checks-pass: name: All required checks pass @@ -179,20 +264,30 @@ jobs: - lockfile-diff - docker-lint - supply-chain + - review-labels - osv-scanner + # comment-live is a polling job — it doesn't block the gate. # we don't require docker to pass rn because it's so slow lol # - docker if: always() runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + needs-json: ${{ steps.evaluate.outputs.needs-json }} steps: - name: Evaluate job results + id: evaluate env: NEEDS: ${{ toJSON(needs) }} run: | echo "$NEEDS" | python3 -c " import json, sys needs = json.load(sys.stdin) + # Emit compact {job_name: result} for the comment assembler. + compact = {name: info['result'] for name, info in needs.items()} + print(f'needs-json={json.dumps(compact)}') + with open('$GITHUB_OUTPUT', 'a') as f: + f.write(f'needs-json={json.dumps(compact)}\n') failed = [name for name, info in needs.items() if info['result'] == 'failure'] for name, info in sorted(needs.items()): result = info['result'] @@ -209,6 +304,9 @@ jobs: # cache them on main (as a baseline), and on PRs generate an HTML diff # report with a gantt chart + per-step breakdown. The report is uploaded # as an artifact and a markdown summary is written to $GITHUB_STEP_SUMMARY. + # + # The live comment poller picks up ci-timings' completion automatically — + # it reads review-status.json from the artifact when the job finishes. # ───────────────────────────────────────────────────────────────────── ci-timings: name: CI timing report @@ -242,18 +340,20 @@ jobs: --baseline ci-timings-baseline.json \ --output ci-timings-report.html \ --json-out ci-timings.json \ - --summary-out ci-timings-summary.md + --summary-out ci-timings-summary.md \ + --review-status-out review-status.json - - name: Upload HTML report + - name: Upload HTML report + review status # Advisory report — artifact-service blips must not fail the job. continue-on-error: true uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 id: ci-timings-artifact with: name: ci-timings-report - path: ci-timings-report.html + path: | + ci-timings-report.html + review-status.json retention-days: 14 - archive: false - name: Output summary env: diff --git a/.github/workflows/contributor-check.yml b/.github/workflows/contributor-check.yml index 2c5db6f311de..014e1e2ff93d 100644 --- a/.github/workflows/contributor-check.yml +++ b/.github/workflows/contributor-check.yml @@ -2,6 +2,10 @@ name: Contributor Attribution Check on: workflow_call: + outputs: + review_status: + description: "JSON array of review status objects" + value: ${{ jobs.check-attribution.outputs.review_status }} permissions: contents: read @@ -10,12 +14,15 @@ jobs: check-attribution: runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + review_status: ${{ steps.check-emails.outputs.review_status }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # Full history needed for git log - name: Check for unmapped contributor emails + id: check-emails run: | # Get the merge base between this PR and main MERGE_BASE=$(git merge-base origin/main HEAD) @@ -25,6 +32,7 @@ jobs: if [ -z "$NEW_EMAILS" ]; then echo "No new commits to check." + echo "review_status=[]" >> "$GITHUB_OUTPUT" exit 0 fi @@ -67,6 +75,16 @@ jobs: echo "" echo "To find the GitHub username for an email:" echo " gh api 'search/users?q=EMAIL+in:email' --jq '.items[0].login'" + + # Emit review_status for unmapped emails + DETAIL=$(echo -e "$MISSING" | sed '/^$/d; s/^ //') + HOW_TO_FIX=$'Add mappings to scripts/release.py AUTHOR_MAP:\n```\n"": "",\n```\nTo find the GitHub username for an email:\n```\ngh api \'search/users?q=EMAIL+in:email\' --jq \'.items[0].login\'\n```\n' + REVIEW_STATUS=$(jq -nc \ + --arg detail "$DETAIL" \ + --arg how_to_fix "$HOW_TO_FIX" \ + '[{"source":"contributor attribution","results":[{"kind":"action_required","title":"Unmapped contributor email(s)","summary":"New contributor email(s) are not in AUTHOR_MAP.","detail":$detail,"how_to_fix":$how_to_fix}]}]') + echo "review_status=$REVIEW_STATUS" >> "$GITHUB_OUTPUT" + exit 1 else echo "✅ All contributor emails are mapped." diff --git a/.github/workflows/history-check.yml b/.github/workflows/history-check.yml index a48dba8cb8af..668f0f795dd9 100644 --- a/.github/workflows/history-check.yml +++ b/.github/workflows/history-check.yml @@ -15,6 +15,10 @@ name: History Check on: workflow_call: + outputs: + review_status: + description: "JSON array of review_status objects for the synthesizer." + value: ${{ jobs.check-common-ancestor.outputs.review_status }} permissions: contents: read @@ -23,18 +27,23 @@ jobs: check-common-ancestor: runs-on: ubuntu-latest timeout-minutes: 10 + outputs: + review_status: ${{ steps.merge-base-check.outputs.review_status }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 with: fetch-depth: 0 # full history both sides for merge-base - - name: Reject PRs with no common ancestor on main + - id: merge-base-check + name: Reject PRs with no common ancestor on main run: | # `git merge-base` exits non-zero AND prints nothing when the two # commits share no ancestor. We check both conditions explicitly # so the failure message is clear regardless of which signal fires # first. if ! BASE=$(git merge-base origin/main HEAD 2>/dev/null) || [ -z "$BASE" ]; then + STATUS='[{"source":"unrelated histories","results":[{"kind":"action_required","title":"Unrelated histories","summary":"This PR has no common ancestor with main.","detail":"","how_to_fix":"Rebase your changes onto current main:\n```\ngit fetch origin main\ngit checkout -b fix-branch origin/main\n# re-apply your changes (cherry-pick, copy files, etc.)\ngit push -f origin fix-branch\n```\n"}]}]' + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" echo "" echo "::error::This PR has no common ancestor with main." echo "" @@ -56,3 +65,4 @@ jobs: exit 1 fi echo "::notice::Common ancestor with main: $BASE" + echo "review_status=[]" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/label-rerun.yml b/.github/workflows/label-rerun.yml new file mode 100644 index 000000000000..fbd8b3b89327 --- /dev/null +++ b/.github/workflows/label-rerun.yml @@ -0,0 +1,81 @@ +name: Label rerun + +# When the ``ci-reviewed`` label is added to a PR, rerun all failed jobs in +# the latest CI run. This re-evaluates ``review-labels`` (which now sees the +# label) and GitHub automatically reruns dependent jobs (``comment-live``, +# ``all-checks-pass``) — so the review comment gets updated too. +# +# If the CI run is still in progress when the label is added, we wait for it +# to finish before rerunning (``gh run rerun`` only works on completed runs). +# The wait can be long (20+ min for a full CI run), but it's better than +# silently failing and leaving the reviewer stuck. + +on: + pull_request: + types: [labeled] + +permissions: + actions: write + pull-requests: read + +concurrency: + group: label-rerun-${{ github.event.pull_request.number }} + cancel-in-progress: true + +jobs: + rerun-review-labels: + name: Rerun review-labels job + if: github.event.label.name == 'ci-reviewed' + runs-on: ubuntu-latest + timeout-minutes: 40 + steps: + - name: Wait for CI run to finish, then rerun failed jobs + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + HEAD_SHA: ${{ github.event.pull_request.head.sha }} + run: | + set -uo pipefail + + # Find the latest CI run for this PR's head SHA. + RUN_ID=$(gh run list \ + --repo "$REPO" \ + --commit "$HEAD_SHA" \ + --workflow ci.yml \ + --limit 1 \ + --json databaseId,status \ + --jq '.[0] | "\(.databaseId) \(.status)"' 2>/dev/null || true) + + if [ -z "$RUN_ID" ]; then + echo "No CI run found for this PR — nothing to rerun." + exit 0 + fi + + # Split "RUN_ID STATUS" into two vars. + RUN_ID="${RUN_ID%% *}" + STATUS="${RUN_ID##* }" + + echo "Latest CI run: $RUN_ID (status: $STATUS)" + + # If the run is still in progress, wait for it to finish. + # gh run rerun only works on completed runs — if we try while it's + # running, GitHub rejects with "cannot be rerun; This workflow is + # already running". + if [ "$STATUS" != "completed" ]; then + echo "Run is $STATUS — waiting for completion (this may take a while)..." + # gh run watch --exit-status exits non-zero if the run fails, + # which is expected (the label gate fails). Don't let that kill + # the workflow — we WANT to rerun failed jobs. + timeout 2100 gh run watch "$RUN_ID" --repo "$REPO" --interval 15 || true + + # Verify it's actually completed now. + STATUS=$(gh run view "$RUN_ID" --repo "$REPO" --json status --jq '.status' 2>/dev/null || echo "unknown") + if [ "$STATUS" != "completed" ]; then + echo "Run is still $STATUS after wait — giving up." + exit 0 + fi + fi + + echo "Run completed. Rerunning all failed jobs..." + gh run rerun "$RUN_ID" --repo "$REPO" --failed || true + echo "Done. GitHub will rerun review-labels and all dependent jobs." diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 28df38ad3e5d..670b6f2a44a2 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -2,11 +2,14 @@ name: Lint (ruff + ty) # Two things here: # 1. Advisory diff — ruff + ty diagnostics as a diff vs the target branch. -# Posts a Markdown summary and a PR comment. Exit zero always. +# Writes a Markdown summary to the run page. Exit zero always. # 2. Blocking ``ruff check .`` — enforces the explicit rules in # ``[tool.ruff.lint.select]`` (currently PLW1514). Failure blocks merge. -# Separate job so the advisory diff still runs and posts even when -# enforcement fails. +# Separate job so the advisory diff still runs even when enforcement +# fails. +# +# CI-sensitive file review was previously here as a ``ci-review`` job but +# has moved to ``review-labels.yml`` so it can be rerun independently. on: workflow_call: @@ -15,14 +18,9 @@ on: description: The event name from the calling orchestrator (pull_request or push). type: string required: true - ci_review: - description: Whether CI-sensitive files (eslint config, workflows, actions) changed and require a review label. - type: boolean - default: false permissions: contents: read - pull-requests: write # needed to post/update PR comments concurrency: group: lint-${{ github.ref }} @@ -162,115 +160,3 @@ jobs: - name: Run footgun checker run: python scripts/check-windows-footguns.py --all - - ci-review: - # Require explicit maintainer review when CI-sensitive files change: - # eslint config, workflow YAMLs, or composite actions. These files - # influence what code the js-autofix job executes and pushes to - # main, so a malicious PR could inject arbitrary code via a custom eslint - # rule's `fix` function. The label gate ensures a human reviews before - # merge. Mirrors the mcp-catalog-reviewed pattern in supply-chain-audit.yml. - name: CI-sensitive file review - if: inputs.event_name == 'pull_request' && inputs.ci_review - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - - name: Require ci-reviewed label - id: label-check - env: - # Read-only label lookup. Use the built-in GITHUB_TOKEN (present and - # read-only on forks) so the gate works on fork PRs; fall back to it - # when AUTOFIX_BOT_PAT is empty. `|| true` degrades an API blip to - # "label absent" rather than hard-failing the step. - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} - run: | - set -euo pipefail - PR="${{ github.event.pull_request.number }}" - LABELS=$(gh pr view "$PR" --json labels --jq '.labels[].name' || true) - if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then - echo "reviewed=true" >> "$GITHUB_OUTPUT" - echo "ci-reviewed label present." - exit 0 - fi - echo "reviewed=false" >> "$GITHUB_OUTPUT" - - # On failure: find the bot's previous comment and edit it, or create - # a new one if none exists. Using an HTML comment marker so we can - # locate it reliably across runs without parsing the body text. - # Skipped on fork PRs — GITHUB_TOKEN is read-only there, so the API - # call would fail. The label gate still holds via the step below. - - name: Post or update review warning - if: steps.label-check.outputs.reviewed != 'true' && github.event.pull_request.head.repo.fork != true - env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} - run: | - set -euo pipefail - PR="${{ github.event.pull_request.number }}" - MARKER="" - BODY="${MARKER} - ## ⚠️ CI-sensitive file review required - - This PR changes CI-sensitive files (eslint config, workflow YAMLs, - or composite actions). These files influence what code the - js-autofix job executes and pushes to main. - - A maintainer should verify: - - no new eslint rules with custom \`fix\` functions that write outside linted paths, - - no workflow changes that widen permissions or remove guards, - - no composite action changes that alter what gets executed. - - After review, add the \`ci-reviewed\` label and re-run this check." - - # Find an existing comment with our marker. - COMMENT_ID=$(gh api \ - "repos/${{ github.repository }}/issues/${PR}/comments" \ - --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ - | head -1 || true) - - if [ -n "$COMMENT_ID" ]; then - gh api --method PATCH \ - "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ - -f body="$BODY" - else - gh pr comment "$PR" --body "$BODY" - fi - - # Fail the job when the label is missing — always runs (including - # fork PRs) so the security gate holds even when the comment step - # was skipped above. - - name: Fail on missing label - if: steps.label-check.outputs.reviewed != 'true' - run: | - echo "::error::CI-sensitive changes require the ci-reviewed label." - exit 1 - - # On success: if a previous warning comment exists, edit it to show - # the review passed so the PR doesn't have a stale ⚠️ sitting around. - # Skipped on fork PRs — no comment was ever posted to update. - - name: Update previous warning to passed - if: steps.label-check.outputs.reviewed == 'true' && github.event.pull_request.head.repo.fork != true - env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} - run: | - set -euo pipefail - PR="${{ github.event.pull_request.number }}" - MARKER="" - - # Find an existing comment with our marker. - COMMENT_ID=$(gh api \ - "repos/${{ github.repository }}/issues/${PR}/comments" \ - --paginate --jq ".[] | select(.body | contains(\"${MARKER}\")) | .id" \ - | head -1 || true) - - if [ -n "$COMMENT_ID" ]; then - BODY="${MARKER} - ## ✅ CI-sensitive file review passed - - The \`ci-reviewed\` label is present on this PR." - - gh api --method PATCH \ - "repos/${{ github.repository }}/issues/comments/${COMMENT_ID}" \ - -f body="$BODY" - fi diff --git a/.github/workflows/lockfile-diff.yml b/.github/workflows/lockfile-diff.yml index 2dcf66ea7c55..9d8d59f6da7e 100644 --- a/.github/workflows/lockfile-diff.yml +++ b/.github/workflows/lockfile-diff.yml @@ -7,22 +7,25 @@ name: Lockfile diff # the ``packages`` map at the merge base and at HEAD and set-diffs the # {install path: version} maps instead. # -# The comment is upserted: the script embeds a hidden HTML marker and the -# workflow PATCHes the existing comment when one is found, so a PR gets -# exactly one lockfile-diff comment that tracks the latest push instead -# of a stack of stale ones. When a later push reverts all lockfile -# changes, the comment is updated to say so (deleting it would be more -# surprising than telling the reviewer it's resolved). +# The semantic diff is exposed as a workflow_call output ``review_status`` +# (a JSON array in the unified status format) and an artifact +# (``lockfile-diff`` containing the markdown fragment) for the step +# summary. # -# Never blocking — this is review signal, not enforcement. Exit is 0 even -# when commenting fails (fork PRs get a read-only GITHUB_TOKEN). +# Never blocking — this is review signal, not enforcement. on: workflow_call: + outputs: + changed: + description: Whether package-lock.json changed relative to the target branch. + value: ${{ jobs.diff.outputs.changed }} + review_status: + description: JSON array of review status objects for the unified PR comment. + value: ${{ jobs.diff.outputs.review_status }} permissions: contents: read - pull-requests: write # post/update the diff comment concurrency: group: lockfile-diff-${{ github.event.pull_request.number || github.ref }} @@ -33,6 +36,9 @@ jobs: name: package-lock.json semantic diff runs-on: ubuntu-latest timeout-minutes: 5 + outputs: + changed: ${{ steps.diff.outputs.changed }} + review_status: ${{ steps.emit-status.outputs.review_status }} steps: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -54,45 +60,36 @@ jobs: --output /tmp/lockfile-diff.md if [ -s /tmp/lockfile-diff.md ]; then echo "changed=true" >> "$GITHUB_OUTPUT" - cat /tmp/lockfile-diff.md >> "$GITHUB_STEP_SUMMARY" + { + echo "## package-lock.json semantic diff" + echo "" + cat /tmp/lockfile-diff.md + } >> "$GITHUB_STEP_SUMMARY" else echo "changed=false" >> "$GITHUB_OUTPUT" + : > /tmp/lockfile-diff.md fi - - name: Post or update PR comment - env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} - REPO: ${{ github.repository }} - PR: ${{ github.event.pull_request.number }} - CHANGED: ${{ steps.diff.outputs.changed }} + - name: Emit review_status + id: emit-status run: | set -euo pipefail - MARKER='' + CHANGED="${{ steps.diff.outputs.changed }}" + STATUS="[]" - # Find our previous comment (paginated — busy PRs exceed one page). - EXISTING=$(gh api --paginate "repos/${REPO}/issues/${PR}/comments" \ - --jq ".[] | select(.body | startswith(\"$MARKER\")) | .id" \ - | head -1 || true) - - if [ "$CHANGED" != "true" ]; then - if [ -n "$EXISTING" ]; then - # A previous push changed the lockfile but the latest one - # doesn't — update the comment rather than leave stale info. - printf '%s\n✅ package-lock.json changes from an earlier push have been reverted — locked versions now match the target branch.\n' "$MARKER" > /tmp/lockfile-diff.md - else - echo "No lockfile changes and no existing comment — nothing to do." - exit 0 - fi - fi - - if [ -n "$EXISTING" ]; then - echo "Updating existing comment ${EXISTING}" - gh api --method PATCH "repos/${REPO}/issues/comments/${EXISTING}" \ - -F body=@/tmp/lockfile-diff.md > /dev/null \ - || echo "::warning::Could not update PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)" + if [ "$CHANGED" = "true" ]; then + CONTENT=$(cat /tmp/lockfile-diff.md | python3 -c "import sys,json; print(json.dumps(sys.stdin.read()))") + STATUS="[{\"source\":\"lockfile-diff\",\"results\":[{\"kind\":\"action_required\",\"title\":\"package-lock.json\",\"summary\":\"Locked npm dependency versions changed.\",\"detail\":${CONTENT},\"how_to_fix\":\"Add the \`ci-reviewed\` label after verifying the version changes are expected.\"}]}" else - echo "Creating new comment" - gh api "repos/${REPO}/issues/${PR}/comments" \ - -F body=@/tmp/lockfile-diff.md > /dev/null \ - || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)" + STATUS="[]" fi + + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" + + - name: Upload diff artifact + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7 + with: + name: lockfile-diff + path: /tmp/lockfile-diff.md + retention-days: 1 + overwrite: true diff --git a/.github/workflows/osv-scanner.yml b/.github/workflows/osv-scanner.yml index e5a983b1bca2..37fed82fbbf9 100644 --- a/.github/workflows/osv-scanner.yml +++ b/.github/workflows/osv-scanner.yml @@ -14,14 +14,14 @@ name: OSV-Scanner # code patterns in PR diffs) by covering the orthogonal "currently-pinned # dep became known-vulnerable" case. # -# Steps below are inlined from Google's officially-recommended reusable -# workflow (google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml), -# rather than called via `uses:` so we can set a `timeout-minutes` in the -# degenerate case where this job hangs. - +# Uses Google's officially-recommended reusable workflow, pinned by SHA. # Findings land in the repo's Security tab (Code Scanning > OSV-Scanner). # fail-on-vuln is disabled so the job does not block merges on pre-existing # vulnerabilities in pinned deps that we may need to patch deliberately. +# +# The reusable workflow can't emit custom outputs, so a wrapper job +# downloads the SARIF result and summarizes the vulnerability count into +# a review_status for the unified PR comment. on: workflow_call: @@ -40,62 +40,85 @@ permissions: jobs: scan: name: Scan lockfiles - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - persist-credentials: false + uses: google/osv-scanner-action/.github/workflows/osv-scanner-reusable.yml@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + with: + # Scan explicit lockfiles rather than recursing, so we only look at + # the three sources of truth and skip vendored / test / worktree dirs. + scan-args: |- + --lockfile=uv.lock + --lockfile=package-lock.json + --lockfile=website/package-lock.json + fail-on-vuln: false - - name: 'Run scanner' - uses: google/osv-scanner-action/osv-scanner-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 + emit-status: + name: Emit review status + runs-on: ubuntu-latest + needs: scan + if: always() + outputs: + review_status: ${{ steps.emit.outputs.review_status }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Download SARIF result + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: - # Scan explicit lockfiles rather than recursing, so we only look at - # the three sources of truth and skip vendored / test / worktree dirs. - scan-args: |- - --output=results.json - --format=json - --lockfile=uv.lock - --lockfile=package-lock.json - --lockfile=website/package-lock.json + name: osv-results + path: /tmp/osv-results continue-on-error: true - - name: 'Run osv-scanner-reporter' - uses: google/osv-scanner-action/osv-reporter-action@9a498708959aeaef5ef730655706c5a1df1edbc2 # v2.3.8 - with: - scan-args: |- - --output=results.sarif - --new=results.json - --gh-annotations=false - --fail-on-vuln=false - - # Upload the results as artifacts (optional). Commenting out will disable uploads of run results in SARIF - # format to the repository Actions tab. - - name: 'Upload artifact' - id: 'upload_artifact' - if: ${{ !cancelled() }} - uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f # v7.0.0 - with: - name: OSV Scanner SARIF file - path: results.sarif - retention-days: 5 - - # Upload the results to GitHub's code scanning dashboard. - - name: 'Upload to code-scanning' - if: ${{ !cancelled() }} - uses: github/codeql-action/upload-sarif@cdefb33c0f6224e58673d9004f47f7cb3e328b89 # v4.31.10 - with: - sarif_file: results.sarif - - - name: 'Print Code Scanning URL' - if: ${{ !cancelled() }} + - name: Emit review_status + id: emit run: | - echo "View the OSV-Scanner results in the 'Security' tab, using the following link:" - echo "${{ github.server_url }}/${{ github.repository }}/security/code-scanning?query=is%3Aopen+branch%3A${GITHUB_REF_NAME}+tool%3Aosv-scanner" - env: - GITHUB_REF_NAME: ${{ github.ref_name }} + set -euo pipefail + STATUS="[]" - - name: 'Error troubleshooter' - if: ${{ always() && steps.upload_artifact.outcome == 'failure' }} - run: | - echo "::error::Artifact upload failed. This is most likely caused by a error during scanning earlier in the workflow." - exit 1 + if [ -f /tmp/osv-results/osv-results.sarif ]; then + # Count vulnerabilities from the SARIF file + VULN_COUNT=$(python3 -c " + import json, sys + try: + with open('/tmp/osv-results/osv-results.sarif') as f: + data = json.load(f) + count = 0 + vulns = [] + for run in data.get('runs', []): + for result in run.get('results', []): + count += 1 + rule_id = result.get('ruleId', 'unknown') + message = result.get('message', {}).get('text', '') + loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '') + vulns.append(f'- {rule_id} in {loc}: {message}') + print(count) + if vulns: + print('\n'.join(vulns[:20]), file=sys.stderr) + except Exception: + print(0) + ") + + VULN_DETAIL="" + if [ "$VULN_COUNT" -gt 0 ] 2>/dev/null; then + VULN_PLURAL=$([ "$VULN_COUNT" -eq 1 ] && echo "y" || echo "ies") + VULN_DETAIL=$(python3 -c " + import json, sys + try: + with open('/tmp/osv-results/osv-results.sarif') as f: + data = json.load(f) + vulns = [] + for run in data.get('runs', []): + for result in run.get('results', []): + rule_id = result.get('ruleId', 'unknown') + loc = result.get('locations', [{}])[0].get('physicalLocation', {}).get('artifactLocation', {}).get('uri', '') + vulns.append(f'- {rule_id} in {loc}') + print(json.dumps('\n'.join(vulns[:20]))) + except Exception: + print(json.dumps('')) + ") + STATUS="[{\"source\":\"osv scan\",\"results\":[{\"kind\":\"warning\",\"title\":\"OSV vulnerability scan\",\"summary\":\"${VULN_COUNT} known vulnerabilit${VULN_PLURAL} found in pinned dependencies.\",\"detail\":${VULN_DETAIL},\"how_to_fix\":\"Review the findings in the [Security tab](../../security/code-scanning). Update the affected dependencies if a patched version is available.\"}]}]" + else + STATUS="[]" + fi + fi + + echo "review_status=${STATUS}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/review-labels.yml b/.github/workflows/review-labels.yml new file mode 100644 index 000000000000..f0167b486742 --- /dev/null +++ b/.github/workflows/review-labels.yml @@ -0,0 +1,92 @@ +name: Review labels + +# Require explicit maintainer review when CI-sensitive files or the MCP +# catalog change. Previously this was split across two jobs in two +# workflows: ``ci-review`` in lint.yml (gated on ``ci_review``) and +# ``mcp-catalog-review`` in supply-chain-audit.yml (gated on +# ``mcp_catalog``). Both checked for their own label. +# +# Now consolidated: a single ``ci-reviewed`` label covers both. The +# comment sections tell the reviewer exactly what to verify per area, +# so one label is enough — the human reads the comment, not the label +# name. +# +# Outputs: +# ci_reviewed — "true" / "false" / "" (empty when neither lane ran) +# review_status — JSON array of status objects consumed by the review +# comment assembler. See scripts/ci/emit_review_status.py. + +on: + workflow_call: + inputs: + ci_review: + description: Whether CI-sensitive files (eslint config, workflows, actions) changed. + type: boolean + default: false + mcp_catalog: + description: Whether the MCP catalog / installer changed. + type: boolean + default: false + outputs: + ci_reviewed: + description: Whether the ci-reviewed label is present. Empty when neither input was true. + value: ${{ jobs.check.outputs.ci_reviewed }} + review_status: + description: JSON array of status objects for the review comment assembler. + value: ${{ jobs.check.outputs.review_status }} + +permissions: + contents: read + pull-requests: read # read PR labels + +jobs: + check: + name: Review label gate + if: inputs.ci_review || inputs.mcp_catalog + runs-on: ubuntu-latest + timeout-minutes: 2 + outputs: + ci_reviewed: ${{ steps.label-check.outputs.ci_reviewed }} + review_status: ${{ steps.build-status.outputs.review_status }} + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Check ci-reviewed label + id: label-check + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + REPO: ${{ github.repository }} + run: | + set -euo pipefail + PR="${{ github.event.pull_request.number }}" + LABELS=$(gh pr view "$PR" --repo "$REPO" --json labels --jq '.labels[].name' || true) + + if echo "$LABELS" | grep -Fxq 'ci-reviewed'; then + echo "ci-reviewed label present." + echo "ci_reviewed=true" >> "$GITHUB_OUTPUT" + else + echo "ci-reviewed label missing." + echo "ci_reviewed=false" >> "$GITHUB_OUTPUT" + fi + + - name: Build review_status JSON + id: build-status + env: + CI_REVIEW: ${{ inputs.ci_review }} + MCP_CATALOG: ${{ inputs.mcp_catalog }} + LABEL_PRESENT: ${{ steps.label-check.outputs.ci_reviewed }} + run: | + set -euo pipefail + ARGS="" + if [ "$CI_REVIEW" = "true" ]; then ARGS="$ARGS --ci-review"; fi + if [ "$MCP_CATALOG" = "true" ]; then ARGS="$ARGS --mcp-catalog"; fi + if [ "$LABEL_PRESENT" = "true" ]; then ARGS="$ARGS --label-present"; fi + + python3 scripts/ci/emit_review_status.py $ARGS --output "$GITHUB_OUTPUT" + + - name: Fail on missing label + if: steps.label-check.outputs.ci_reviewed != 'true' + run: | + echo "::error::CI-sensitive changes require the ci-reviewed label. Add the label and re-run this check." + exit 1 diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index 648a1f7c6a63..e8a4508328f8 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -10,9 +10,17 @@ name: Supply Chain Audit # advisory-only workflow instead. # # Path-gating is handled centrally by the ``ci.yml`` orchestrator's -# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` / -# ``mcp_catalog`` booleans as inputs; this workflow's jobs gate on those -# inputs instead of re-computing the diff. +# ``detect`` job. The orchestrator passes ``scan`` / ``deps`` booleans as +# inputs; this workflow's jobs gate on those inputs instead of re-computing +# the diff. MCP catalog review was previously here but has moved to +# ``review-labels.yml`` so it can be rerun independently. +# +# Outputs: +# review_status — JSON array of status objects consumed by the review +# comment assembler (scripts/ci/assemble_review_comment.py). +# Each job (``scan``, ``dep-bounds``) emits its own +# array; an ``aggregate`` job merges them into the +# workflow-level output. on: workflow_call: @@ -29,10 +37,10 @@ on: description: Whether pyproject.toml changed. type: boolean required: true - mcp_catalog: - description: Whether the MCP catalog / installer changed. - type: boolean - required: true + outputs: + review_status: + description: JSON array of review status objects for the review comment assembler. + value: ${{ jobs.aggregate.outputs.review_status }} permissions: pull-requests: write @@ -44,6 +52,8 @@ jobs: if: inputs.scan runs-on: ubuntu-latest timeout-minutes: 15 + outputs: + review_status: ${{ steps.emit-status.outputs.review_status }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -61,7 +71,7 @@ jobs: HEAD="${{ github.event.pull_request.head.sha }}" # Added lines only, excluding lockfiles. - # Three-dot diff (base...head) diffs from the merge base to HEAD, + # Three-point diff (base...head) diffs from the merge base to HEAD, # so only changes introduced by this PR are included — not changes # that landed on main after the PR branched off. DIFF=$(git diff "$BASE"..."$HEAD" -- . ':!uv.lock' ':!*.lock' ':!package-lock.json' ':!yarn.lock' || true) @@ -139,26 +149,41 @@ jobs: echo "found=false" >> "$GITHUB_OUTPUT" fi - - name: Post critical finding comment - if: steps.scan.outputs.found == 'true' + - name: Emit review_status + id: emit-status + if: always() env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + FOUND: ${{ steps.scan.outputs.found }} run: | - BODY="## 🚨 CRITICAL Supply Chain Risk Detected + python3 - <<'PYEOF' + import json, os - This PR contains a pattern that has been used in real supply chain attacks. A maintainer must review the flagged code carefully before merging. + found = os.environ.get("FOUND", "") == "true" - $(cat /tmp/findings.md) + if found: + with open("/tmp/findings.md", encoding="utf-8") as f: + detail = f.read() + status = [{ + "source": "supply chain", + "results": [{ + "kind": "error", + "title": "Critical supply chain risk", + "summary": "Critical supply chain risk patterns detected in this PR.", + "detail": detail, + "how_to_fix": "Review the flagged code carefully. If intentional, add the `ci-reviewed` label." + }] + }] + else: + status = [] - --- - *Scanner only fires on high-signal indicators: .pth files, base64+exec/eval combos, subprocess with encoded commands, or install-hook files. Low-signal warnings were removed intentionally — if you're seeing this comment, the finding is worth inspecting.*" - - gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs — GITHUB_TOKEN is read-only)" + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"review_status={json.dumps(status)}\n") + PYEOF - name: Fail on critical findings if: steps.scan.outputs.found == 'true' run: | - echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the PR comment for details." + echo "::error::CRITICAL supply chain risk patterns detected in this PR. See the review comment for details." exit 1 dep-bounds: @@ -166,6 +191,8 @@ jobs: if: inputs.deps runs-on: ubuntu-latest timeout-minutes: 15 + outputs: + review_status: ${{ steps.emit-status.outputs.review_status }} steps: - name: Checkout uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 @@ -188,7 +215,7 @@ jobs: exit 0 fi - # Match PyPI dep specs that have >= but no < ceiling. + # Match PyPI dep specs that have >= and no < ceiling. # Pattern: "package>=version" without a following ",<" bound. # Excludes git+ URLs (which use commit SHAs) and comments. UNBOUNDED=$(echo "$ADDED" | grep -oE '"[a-zA-Z0-9_-]+(\[[^\]]*\])?>=[ 0-9.]+"' | grep -v ',<' || true) @@ -200,26 +227,36 @@ jobs: echo "found=false" >> "$GITHUB_OUTPUT" fi - - name: Post unbounded dep warning - if: steps.bounds.outputs.found == 'true' + - name: Emit review_status + id: emit-status + if: always() env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + FOUND: ${{ steps.bounds.outputs.found }} run: | - BODY="## ⚠️ Unbounded PyPI Dependency Detected + python3 - <<'PYEOF' + import json, os - This PR adds PyPI dependencies without a \`=floor,=1.2.0,<2"`. See CONTRIBUTING.md dependency pinning policy.' + }] + }] + else: + status = [] - **Fix:** Add an upper bound, e.g. \`"package>=1.2.0,<2"\` - - --- - *See PR #2810 and CONTRIBUTING.md for the full policy rationale.*" - - gh pr comment "${{ github.event.pull_request.number }}" --body "$BODY" || echo "::warning::Could not post PR comment (expected for fork PRs)" + with open(os.environ["GITHUB_OUTPUT"], "a", encoding="utf-8") as f: + f.write(f"review_status={json.dumps(status)}\n") + PYEOF - name: Fail on unbounded deps if: steps.bounds.outputs.found == 'true' @@ -227,45 +264,36 @@ jobs: echo "::error::PyPI dependencies without upper bounds detected. Add > "$GITHUB_OUTPUT" exit 1 fi + review_status='[]' + echo "review_status=${review_status}" >> "$GITHUB_OUTPUT" diff --git a/scripts/ci/assemble_review_comment.py b/scripts/ci/assemble_review_comment.py new file mode 100644 index 000000000000..0f701f13e338 --- /dev/null +++ b/scripts/ci/assemble_review_comment.py @@ -0,0 +1,424 @@ +#!/usr/bin/env python3 +"""Assemble the unified CI review comment for a pull request. + +Every CI job that wants to appear in the review comment emits a +``review_status`` output: a JSON array of objects, each with a ``source`` +(the workflow name, used for dedup) and a ``results`` array of typed +result objects:: + + [ + { + "source": "review-label-gate", + "results": [ + {"kind": "action_required", "title": "...", "summary": "...", + "how_to_fix": "..."}, + {"kind": "info", "title": "...", "summary": "..."} + ] + }, + { + "source": "ci-timings", + "results": [ + {"kind": "warning", "title": "CI timings", "summary": "...", + "detail": "...", "link": "..."} + ] + } + ] + +Each result object has: + + kind: "error" | "action_required" | "warning" | "info" + title: section heading + summary: one-line description + detail: markdown detail (optional) + how_to_fix: markdown checklist (optional) + link: URL (optional) + link_label: label for the link (optional, default "View logs") + +The assembler flattens all results into a flat list of ReviewItems, +grouped by severity in the comment. Jobs that failed (from the +``needs`` context) but didn't emit any status get synthesized ❌ Error +items. Jobs that DID emit a status are excluded from the synthesized +error list — their own output is the authority for their classification. + +Exits 0 always — comment posting is best-effort (fork PRs are read-only). +""" + +from __future__ import annotations + +import argparse +import json +import sys +from dataclasses import dataclass +from pathlib import Path + +# Hidden marker the comment system uses to find-and-edit its +# previous comment instead of stacking new ones on each run. +MARKER = "" + +# Severity ordering for display. +_SEVERITY_ORDER = ["error", "action_required", "warning", "info"] + +# Severities that trigger the "blocking issues" layout (vs. the +# "looks good!" banner). +_BLOCKING_SEVERITIES = ("error", "action_required", "warning") + +_SEVERITY_GROUP_HEADER = { + "error": "## ❌ Job failures", + "action_required": "## ⚠️ Action required", + "warning": "## ⚠️ Warnings", + "info": "## ℹ️ Details", +} + + +@dataclass +class ReviewItem: + """A single piece of review information with a severity tag.""" + + severity: str # "error" | "action_required" | "warning" | "info" + title: str # short section title, e.g. "package-lock.json" + summary: str # one-line summary + detail: str = "" # optional markdown detail (tables, bullet lists, etc.) + link: str = "" # optional URL emitted by the job (e.g. report URL) + link_label: str = "View report" # label for the emitted link + how_to_fix: str = "" # optional markdown checklist for action_required items + source: str = "" # workflow that declared this status (for dedup) + job_url: str = "" # auto-attached per-job log link (from the live poller) + + +# --------------------------------------------------------------------------- +# Collectors — each returns a list of ReviewItems (possibly empty) +# --------------------------------------------------------------------------- + + +def collect_from_statuses(review_statuses_json: str) -> tuple[list[ReviewItem], set[str]]: + """Parse the nested review_status JSON into flat ReviewItems. + + The input is a JSON array of ``{source, results: [...]}`` objects. + Each entry in ``results`` becomes one ReviewItem, tagged with the + parent's ``source``. + + Returns ``(items, sources)`` where ``sources`` is the set of source + values — used by :func:`collect_failed_jobs` to exclude jobs that + already declared their own status (so a failing job that emitted an + ``action_required`` status doesn't also show as a synthesized ❌ Error). + """ + if not review_statuses_json: + return [], set() + try: + data = json.loads(review_statuses_json) + except (json.JSONDecodeError, TypeError): + return [], set() + if not isinstance(data, list): + return [], set() + + items: list[ReviewItem] = [] + sources: set[str] = set() + + for entry in data: + if not isinstance(entry, dict): + continue + source = entry.get("source", "") + if source: + sources.add(source) + for r in entry.get("results", []): + if not isinstance(r, dict): + continue + kind = r.get("kind", "info") + if kind not in _SEVERITY_ORDER: + kind = "info" + items.append(ReviewItem( + severity=kind, + title=r.get("title", "Unknown"), + summary=r.get("summary", ""), + detail=r.get("detail", ""), + link=r.get("link", ""), + link_label=r.get("link_label", "View logs"), + how_to_fix=r.get("how_to_fix", ""), + source=source, + )) + + return items, sources + + +def collect_failed_jobs( + needs_json: str, + run_url: str, + exclude_sources: set[str] | None = None, + job_urls: dict[str, str] | None = None, +) -> list[ReviewItem]: + """Build error items for failed CI jobs from the ``needs`` context. + + ``needs_json`` is the JSON string emitted by ``all-checks-pass`` — a + ``{job_name: result}`` dict where result is ``success`` / ``failure`` + / ``skipped``. Only ``failure`` entries become error items. + + ``exclude_sources`` is a set of ``source`` values from status objects + declared by workflow_call jobs. Job names containing any of these + source strings are excluded — their failure is already covered by their + own status output. + + ``job_urls`` is an optional ``{job_name: html_url}`` dict from the + live poller. When a job's name is in this dict, the ❌ Error link + points directly to that job's logs page instead of the whole run. + Falls back to ``run_url`` when no per-job URL is available. + """ + if not needs_json: + return [] + try: + needs = json.loads(needs_json) + except (json.JSONDecodeError, TypeError): + return [] + + # Pre-normalize exclude sources once: lowercase + hyphens→spaces, so + # "review-label-gate" matches "Review label gate / Review label gate". + norm_sources = { + src.lower().replace("-", " ") for src in (exclude_sources or set()) + } + + items: list[ReviewItem] = [] + for name, result in sorted(needs.items()): + if result != "failure": + continue + if norm_sources: + norm = name.lower().replace("-", " ") + if any(src in norm for src in norm_sources): + continue + job_url = (job_urls or {}).get(name, run_url) + items.append(ReviewItem( + severity="error", + title=name, + summary=f"Job **{name}** failed.", + job_url=job_url, + )) + return items + + +# --------------------------------------------------------------------------- +# Rendering +# --------------------------------------------------------------------------- + + +def _render_item(item: ReviewItem) -> str: + """Render a single ReviewItem as a markdown block. + + The group header (``## ❌ Job failures`` etc.) carries the severity + emoji, so items don't repeat it. Links are shown inline next to the + title. Layout per item:: + + ### {title} · [View report](url) · [View job](url) + + {summary} + + {detail} + + **How to fix:** + + {how_to_fix} + """ + title = f"### {item.title}" + # Build inline links next to the title. + links: list[str] = [] + if item.link: + links.append(f"[{item.link_label}]({item.link})") + if item.job_url: + links.append(f"[View job]({item.job_url})") + if links: + title += " · " + " · ".join(links) + + parts = [title, "", item.summary] + + if item.detail: + parts += ["", item.detail] + if item.how_to_fix: + parts += ["", "**How to fix:**", "", item.how_to_fix] + + return "\n".join(parts) + + +def _render_group(header: str, items: list[ReviewItem]) -> str: + """Render a severity group: ``##`` header + items separated by ``---``.""" + blocks = [_render_item(i) for i in items] + return f"{header}\n\n" + "\n\n---\n\n".join(blocks) + + +def _render_info_details(items: list[ReviewItem]) -> str: + """Render each info item as its own collapsible ``
`` block.""" + blocks = [] + for item in items: + inner = _render_item(item) + blocks.append( + f"
\n{item.title}\n\n{inner}\n\n
" + ) + return "\n\n".join(blocks) + + +def _render_pending_items(pending_jobs: list[str]) -> str: + """Render the dimmed ```` items for jobs still running.""" + job_list = ", ".join(f"`{j}`" for j in sorted(pending_jobs)) + return f"\n\n---\n\nStill running {len(pending_jobs)} job{'s' if len(pending_jobs) != 1 else ''}: {job_list}\n" + + +def render_comment(items: list[ReviewItem], pending_jobs: list[str] | None = None, commit_info: str = "") -> str: + """Render the full comment body from a list of review items. + + Items are grouped by severity under ``##`` group headers, separated + by ``---``. Errors and action_required items are always visible. + Warnings are shown only when present. Info items are in a collapsible + ``
`` block. If ``pending_jobs`` is non-empty, a dimmed + ```` footer is appended listing jobs still running. + + When there are no errors, action_required, or warnings (only info + items, or nothing at all), a "looks good!" banner is shown at the top, + and info items (if any) follow in a collapsible ``
`` block. + """ + pending = pending_jobs or [] + + # Group by severity + by_severity: dict[str, list[ReviewItem]] = {s: [] for s in _SEVERITY_ORDER} + for item in items: + by_severity.setdefault(item.severity, []).append(item) + + info = by_severity.get("info", []) + has_blocking = any(by_severity.get(s) for s in _BLOCKING_SEVERITIES) + + body = f"{MARKER}\n# ૮ >ﻌ< ა ci review\n\n" + + if commit_info: + body += f"{commit_info}\n\n" + + if not items and not pending: + return f"{body}looks good to me!" + + sections: list[str] = [] + + for sev in _BLOCKING_SEVERITIES: + group = by_severity.get(sev, []) + if group: + sections.append(_render_group(_SEVERITY_GROUP_HEADER[sev], group)) + + # Info: collapsible
+ if info: + sections.append(_render_info_details(info)) + + if pending: + body += _render_pending_items(pending) + + if sections: + body += "\n\n---\n\n".join(sections) + + return body + + +# --------------------------------------------------------------------------- +# Assembly +# --------------------------------------------------------------------------- + + +def _attach_job_urls(items: list[ReviewItem], job_urls: dict[str, str], run_url: str) -> None: + """Fill in per-job log links for all items. + + Uses the same case-insensitive, hyphen-normalized matching as + :func:`collect_failed_jobs`: the item's ``source`` is matched against + job names in ``job_urls``. Sets ``job_url`` on the item — this is + separate from ``link`` (the job-emitted URL, e.g. a report artifact), + so both can appear in the rendered comment. + """ + if not job_urls and not run_url: + return + # Pre-normalize job_url keys once. + norm_urls: dict[str, str] = {} + for name, url in job_urls.items(): + norm_urls[name.lower().replace("-", " ")] = url + + for item in items: + if item.job_url: + continue + src = item.source.lower().replace("-", " ") + # Try exact match first, then substring match. + if src in norm_urls: + item.job_url = norm_urls[src] + continue + for norm_name, url in norm_urls.items(): + if src and src in norm_name: + item.job_url = url + break + # If no per-job URL found, fall back to run_url for items with a source. + if not item.job_url and item.source and run_url: + item.job_url = run_url + + +def assemble( + needs_json: str = "", + run_url: str = "", + job_urls: dict[str, str] | None = None, + review_statuses_json: str = "", + pending_jobs: list[str] | None = None, + commit_info: str = "", +) -> str: + """Assemble the full comment body from all available inputs.""" + items: list[ReviewItem] = [] + + # 1. Structured statuses from workflow_call jobs (review-labels, etc.) + status_items, sources = collect_from_statuses(review_statuses_json) + items.extend(status_items) + + # 2. Synthesized error items for failed jobs not covered by statuses + items.extend(collect_failed_jobs(needs_json, run_url, exclude_sources=sources, job_urls=job_urls)) + + # 3. Attach per-job log links to all items (not just synthesized errors) + _attach_job_urls(items, job_urls or {}, run_url) + + return render_comment(items, pending_jobs, commit_info) + + +# --------------------------------------------------------------------------- +# CLI +# --------------------------------------------------------------------------- + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--needs-json", + default="", + help="JSON string of {job_name: result} from the all-checks-pass job.", + ) + parser.add_argument( + "--run-url", + default="", + help="URL to the CI run summary page (for failed job links).", + ) + parser.add_argument( + "--review-statuses-json", + default="", + help="JSON array of {source, results: [...]} objects from workflow_call jobs.", + ) + parser.add_argument( + "--pending-jobs", + default="", + help="Comma-separated list of job names still running (shown in a dimmed footer).", + ) + parser.add_argument( + "--output", + type=Path, + required=True, + help="Output file for the assembled comment body.", + ) + args = parser.parse_args() + + pending = [j.strip() for j in args.pending_jobs.split(",") if j.strip()] if args.pending_jobs else None + + body = assemble( + needs_json=args.needs_json, + run_url=args.run_url, + review_statuses_json=args.review_statuses_json, + pending_jobs=pending, + ) + + args.output.write_text(body) + print(f"Wrote {len(body)} chars to {args.output}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/emit_review_status.py b/scripts/ci/emit_review_status.py new file mode 100644 index 000000000000..dcf58a7a0e19 --- /dev/null +++ b/scripts/ci/emit_review_status.py @@ -0,0 +1,143 @@ +#!/usr/bin/env python3 +"""Emit review_status JSON for the review-labels workflow. + +Builds a JSON array with one entry:: + + [ + { + "source": "review-label-gate", + "results": [ + {"kind": "action_required", "title": "...", "summary": "...", + "how_to_fix": "..."}, + {"kind": "info", "title": "...", "summary": "..."} + ] + } + ] + +The ``source`` field is the workflow name that declared the status; the +assembler uses it to exclude the corresponding job from the synthesized +❌ Error list (the job already has its own status section). + +The array can contain 0, 1, or 2 results — one per lane that ran +(``ci_review``, ``mcp_catalog``). When the ``ci-reviewed`` label is +present, the kind is ``info``; when missing, it's ``action_required`` +with the verification checklist. +""" + +from __future__ import annotations + +import argparse +import json +import sys + +# The source identifier used for error-synthesis exclusion. This must +# match (as a normalized substring) the job name as it appears in the +# GitHub Actions API. The ci.yml job key is ``review-labels`` with +# ``name: Review label gate``, and the reusable workflow's job is also +# ``name: Review label gate``, so the API shows the job as +# "Review label gate / Review label gate". Normalizing "review-label-gate" +# (lowercase, hyphens→spaces) gives "review label gate", which is a +# substring of "review label gate / review label gate". +SOURCE = "review-label-gate" + + +def build_results( + ci_review: bool, + mcp_catalog: bool, + label_present: bool, +) -> list[dict]: + """Build the list of result objects for this source.""" + results: list[dict] = [] + + if ci_review: + if label_present: + results.append({ + "kind": "info", + "title": "CI-sensitive file review", + "summary": "`ci-reviewed` label is present.", + }) + else: + results.append({ + "kind": "action_required", + "title": "CI-sensitive file review", + "summary": ( + "This PR changes CI-sensitive files (eslint config, " + "workflow YAMLs, or composite actions). These influence " + "what the js-autofix job executes and pushes to main." + ), + "how_to_fix": ( + "Add the `ci-reviewed` label after verifying:\n" + "- no new eslint rules with custom `fix` functions that write outside linted paths,\n" + "- no workflow changes that widen permissions or remove guards,\n" + "- no composite action changes that alter what gets executed." + ), + }) + + if mcp_catalog: + if label_present: + results.append({ + "kind": "info", + "title": "MCP catalog security review", + "summary": "`ci-reviewed` label is present.", + }) + else: + results.append({ + "kind": "action_required", + "title": "MCP catalog security review", + "summary": ( + "This PR changes the bundled MCP catalog or MCP catalog " + "installer code. MCP entries can define local commands " + "that users later install into `mcp_servers`, so this " + "needs explicit maintainer review before merge." + ), + "how_to_fix": ( + "Add the `ci-reviewed` label after verifying:\n" + "- any new/changed `optional-mcps/**/manifest.yaml` command and args are expected,\n" + "- stdio transports do not use shell+egress/exfiltration payloads,\n" + "- git install refs are pinned and bootstrap commands are minimal,\n" + "- requested env vars/secrets match the upstream MCP's documented needs." + ), + }) + + return results + + +def build_statuses( + ci_review: bool, + mcp_catalog: bool, + label_present: bool, +) -> list[dict]: + """Build the full review_status array (one entry with a results list).""" + results = build_results(ci_review, mcp_catalog, label_present) + if not results: + return [] + return [{"source": SOURCE, "results": results}] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--ci-review", action="store_true", + help="Whether CI-sensitive files changed.") + parser.add_argument("--mcp-catalog", action="store_true", + help="Whether the MCP catalog / installer changed.") + parser.add_argument("--label-present", action="store_true", + help="Whether the ci-reviewed label is present.") + parser.add_argument("--output", default="-", + help="Output file ('-' for stdout, or a GITHUB_OUTPUT path).") + args = parser.parse_args() + + statuses = build_statuses(args.ci_review, args.mcp_catalog, args.label_present) + json_str = json.dumps(statuses) + + if args.output == "-": + print(json_str) + else: + # GITHUB_OUTPUT format: key=value\n + with open(args.output, "a", encoding="utf-8") as f: + f.write(f"review_status={json_str}\n") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/live_comment.py b/scripts/ci/live_comment.py new file mode 100644 index 000000000000..e4ca5c9e2de5 --- /dev/null +++ b/scripts/ci/live_comment.py @@ -0,0 +1,526 @@ +#!/usr/bin/env python3 +"""Live-updating CI review comment. + +Polls the GitHub Actions API for job statuses in the current run, assembles +the review comment from whatever results are available, and upserts it as a +PR comment. Repeats every ``--interval`` seconds until all jobs are +completed (or ``--timeout`` is reached), so the comment updates in real time +as each job finishes. + +The comment is identified by the ```` marker +— the same one ``assemble_review_comment.py`` uses — so it replaces any +previous comment from an earlier run. + +Architecture: + + - :func:`classify_jobs` (pure, testable) — takes a list of raw API job + dicts and returns ``(completed, pending, job_urls)`` where ``completed`` + is a ``{name: result}`` dict (for :func:`assemble_review_comment.assemble`) + and ``pending`` is a list of job names still running. + + - :func:`find_comment_id` / :func:`upsert_comment` — thin API wrappers. + + - :func:`_fetch_timings_statuses` — downloads the ci-timings artifact + (if available) and parses the ``review_status=`` line from it, merging + the status objects into the review statuses array. + + - :func:`run` — the polling loop. Calls the API, classifies, assembles, + upserts, sleeps, repeats. Exits when all jobs are completed. + +The orchestrator job names (detect, all-checks-pass, comment-live, etc.) +are excluded from the comment — they're infrastructure, not review signal. +""" + +from __future__ import annotations + +import argparse +import json +import os +import subprocess +import sys +import time +import urllib.error +import urllib.request +from pathlib import Path + +API_BASE = "https://api.github.com" + +# Job names that are infrastructure (this script, the gate, the detector) +# and should never appear in the review comment. +_INFRA_JOBS = frozenset({ + "detect", + "all-checks-pass", + "comment-pending", + "comment-results", + "comment-live", + "CI review comment (pending)", + "CI review comment (results)", + "CI review comment (live)", + "All required checks pass", + "Detect affected areas", +}) + +# Map GitHub API conclusion values to our result strings. +_CONCLUSION_MAP = { + "success": "success", + "failure": "failure", + "skipped": "skipped", + "cancelled": "skipped", + "neutral": "skipped", + "timed_out": "failure", + "action_required": "skipped", +} + + +def classify_jobs(api_jobs: list[dict]) -> tuple[dict[str, str], list[str], dict[str, str]]: + """Classify raw API job dicts into completed + pending + job_urls. + + Returns ``(completed, pending, job_urls)``: + + - ``completed``: ``{job_name: result}`` where result is + ``"success"`` / ``"failure"`` / ``"skipped"``. Only non-infra jobs + that have finished. + - ``pending``: list of job names still running (in_progress / queued + / waiting). Excludes infra jobs. + - ``job_urls``: ``{job_name: html_url}`` — direct links to each + job's logs page, for the assembler to use in ❌ Error links. + + The API returns orchestrator-level jobs and sub-workflow jobs + (workflow_call) in separate runs — :func:`collect_run_jobs` merges + them. Each sub-workflow job has a ``_workflow_name`` prefix so the + display name is ``"Workflow / job"``. + """ + completed: dict[str, str] = {} + pending: list[str] = [] + job_urls: dict[str, str] = {} + + for job in api_jobs: + name = job.get("name", "unknown") + if job.get("_workflow_name"): + name = f"{job['_workflow_name']} / {name}" + if name in _INFRA_JOBS: + continue + status = job.get("status", "") + conclusion = job.get("conclusion", "") + html_url = job.get("html_url", "") + + if html_url: + job_urls[name] = html_url + + if status in ("in_progress", "queued", "waiting"): + pending.append(name) + elif status == "completed": + result = _CONCLUSION_MAP.get(conclusion, "skipped") + completed[name] = result + # else: unknown status → skip + + return completed, pending, job_urls + + +# --------------------------------------------------------------------------- +# API helpers +# --------------------------------------------------------------------------- + + +def _api_request(url: str, token: str) -> dict: + """Authenticated GitHub API GET (single page).""" + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ci-live-comment", + }) + with urllib.request.urlopen(req) as resp: + data: dict = json.loads(resp.read()) + return data + + +def _api_get_paginated(url: str, token: str, list_key: str | None = None) -> list: + """Authenticated GitHub API GET with pagination.""" + results: list = [] + while url: + req = urllib.request.Request(url, headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "User-Agent": "ci-live-comment", + }) + with urllib.request.urlopen(req) as resp: + data = json.loads(resp.read()) + link_header = resp.headers.get("Link", "") + + if list_key: + results.extend(data.get(list_key, [])) + elif isinstance(data, list): + results.extend(data) + else: + return data + + next_url = None + for part in link_header.split(","): + part = part.strip() + if 'rel="next"' in part: + next_url = part[part.find("<") + 1:part.find(">")] + break + url = next_url + + return results + + +def collect_run_jobs(token: str, repo: str, run_id: str) -> list[dict]: + """Collect all jobs in the orchestrator run + sub-workflow runs. + + Returns a flat list of job dicts (same shape as the API returns, plus + ``_workflow_name`` on sub-workflow jobs). + """ + owner, repo_name = repo.split("/") + run_info = _api_request(f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}", token) + created_at = run_info.get("created_at", "") + head_sha = run_info.get("head_sha", "") + + # Orchestrator jobs + orch_jobs = _api_get_paginated( + f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{run_id}/jobs", + token, list_key="jobs", + ) + + # Sub-workflow runs (workflow_call) + sub_runs = _api_get_paginated( + f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs?head_sha={head_sha}&event=workflow_call&per_page=100", + token, list_key="workflow_runs", + ) + sub_runs = [r for r in sub_runs if r.get("created_at", "") >= created_at] + + all_jobs: list[dict] = [] + # Orchestrator jobs: skip workflow-call placeholder steps (they're + # sub-workflow triggers, not review signal), but KEEP in_progress / + # queued jobs so the poller knows they're still running. + for job in orch_jobs: + steps = job.get("steps") or [] + if any(s.get("name", "").startswith("Run ./.github/") for s in steps): + continue + all_jobs.append(job) + + # Sub-workflow jobs (workflow_call). + # These runs may not exist yet on the first few polls — that's fine, + # classify_jobs() will just show 0 pending for them. + for sr in sub_runs: + sr_id = sr["id"] + sr_name = sr.get("name", "") + sr_jobs = _api_get_paginated( + f"{API_BASE}/repos/{owner}/{repo_name}/actions/runs/{sr_id}/jobs", + token, list_key="jobs", + ) + for j in sr_jobs: + j["_workflow_name"] = sr_name + all_jobs.append(j) + + return all_jobs + + +def find_comment_id(token: str, repo: str, pr_number: str) -> int | None: + """Find our existing review comment by marker prefix.""" + owner, repo_name = repo.split("/") + comments = _api_get_paginated( + f"{API_BASE}/repos/{owner}/{repo_name}/issues/{pr_number}/comments", + token, + ) + for c in comments: + body = c.get("body", "") if isinstance(c, dict) else "" + if body.startswith(""): + return c.get("id") if isinstance(c, dict) else None + return None + + +def upsert_comment( + token: str, repo: str, pr_number: str, body: str, comment_id: int | None = None +) -> int | None: + """Create or update the review comment. Returns the comment ID.""" + owner, repo_name = repo.split("/") + if comment_id is None: + comment_id = find_comment_id(token, repo, pr_number) + + if comment_id: + url = f"{API_BASE}/repos/{owner}/{repo_name}/issues/comments/{comment_id}" + method = "PATCH" + else: + url = f"{API_BASE}/repos/{owner}/{repo_name}/issues/{pr_number}/comments" + method = "POST" + + data = json.dumps({"body": body}).encode("utf-8") + req = urllib.request.Request(url, data=data, method=method, headers={ + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", + "Content-Type": "application/json", + "User-Agent": "ci-live-comment", + }) + try: + with urllib.request.urlopen(req) as resp: + result = json.loads(resp.read()) + return result.get("id") + except urllib.error.HTTPError as e: + print(f" API error {e.code}: {e.reason}", file=sys.stderr) + return None + + +# --------------------------------------------------------------------------- +# Artifact fetching (ci-timings review_status) +# --------------------------------------------------------------------------- + + +def _fetch_artifact_statuses( + token: str, repo: str, run_id: str, artifact_name: str, +) -> list[dict]: + """Download a workflow artifact and extract review_status entries. + + The ci-timings job writes a ``review-status.json`` file containing + ``review_status=`` (GITHUB_OUTPUT format) into its artifact. + This function downloads the artifact, parses the line, and returns + the parsed status array. Returns ``[]`` if the artifact doesn't exist + yet or can't be parsed. + """ + try: + result = subprocess.run( + ["gh", "run", "download", run_id, "--repo", repo, + "--name", artifact_name, "--dir", "/tmp/artifact-dl"], + capture_output=True, timeout=30, + ) + if result.returncode != 0: + return [] + except Exception: + return [] + + status_file = Path("/tmp/artifact-dl/review-status.json") + if not status_file.exists(): + return [] + + try: + content = status_file.read_text(encoding="utf-8").strip() + # GITHUB_OUTPUT format: review_status= + if content.startswith("review_status="): + content = content[len("review_status="):] + statuses = json.loads(content) + if isinstance(statuses, list): + return statuses + except (json.JSONDecodeError, OSError): + pass + + return [] + + +# --------------------------------------------------------------------------- +# Comment assembly +# --------------------------------------------------------------------------- + + +def _import_assembler(): + """Import assemble_review_comment.py from the same directory.""" + here = Path(__file__).resolve().parent + sys.path.insert(0, str(here)) + import assemble_review_comment as asm + return asm + + +def build_comment_body( + asm_mod, + completed: dict[str, str], + pending: list[str], + run_url: str, + job_urls: dict[str, str], + review_statuses_json: str, + commit_info: str = "", +) -> str: + """Assemble the comment body from current job states + static inputs.""" + needs_json = json.dumps(completed) if completed else "" + + return asm_mod.assemble( + needs_json=needs_json, + run_url=run_url, + job_urls=job_urls, + review_statuses_json=review_statuses_json, + pending_jobs=pending if pending else None, + commit_info=commit_info, + ) + + +def _merge_statuses( + base_statuses: list[dict], extra_statuses: list[dict] +) -> str: + """Merge two status arrays into one JSON string.""" + merged = list(base_statuses) + list(extra_statuses) + return json.dumps(merged) if merged else "" + + +# --------------------------------------------------------------------------- +# Polling loop +# --------------------------------------------------------------------------- + + +def run( + token: str, + repo: str, + run_id: str, + pr_number: str, + run_url: str, + review_statuses_json: str = "", + commit_info: str = "", + interval: int = 15, + timeout: int = 1800, + dry_run: bool = False, +) -> int: + """Poll for job statuses and update the PR comment until all done. + + Returns 0 always — comment posting is best-effort. + """ + asm = _import_assembler() + start = time.time() + last_body = "" + + # Parse the base statuses once (from review-labels, lockfile-diff, etc.) + try: + base_statuses = json.loads(review_statuses_json) if review_statuses_json else [] + except (json.JSONDecodeError, TypeError): + base_statuses = [] + print(f" Loaded {len(base_statuses)} base review status entries") + + while True: + elapsed = time.time() - start + if elapsed > timeout: + print(f"Timeout ({timeout}s) reached — stopping poll.", file=sys.stderr) + break + + try: + jobs = collect_run_jobs(token, repo, run_id) + except Exception as e: + print(f" API error collecting jobs: {e}", file=sys.stderr) + time.sleep(interval) + continue + + completed, pending, job_urls = classify_jobs(jobs) + total = len(completed) + len(pending) + print(f" [{elapsed:.0f}s] {len(completed)} completed, {len(pending)} pending " + f"({total} total jobs)") + + # Try to fetch ci-timings artifact statuses (may not exist yet). + artifact_statuses = _fetch_artifact_statuses( + token, repo, run_id, "ci-timings-report", + ) + if artifact_statuses: + print(f" Found ci-timings artifact with {len(artifact_statuses)} status entries") + + merged_json = _merge_statuses(base_statuses, artifact_statuses) + + body = build_comment_body( + asm, completed, pending, run_url, job_urls, + merged_json, + commit_info, + ) + + if body != last_body: + if dry_run: + print("--- DRY RUN — comment body ---") + print(body) + print("--- END ---") + else: + cid = upsert_comment(token, repo, pr_number, body) + if cid: + print(f" Updated comment {cid}") + else: + print(" Failed to update comment (will retry)", file=sys.stderr) + last_body = body + else: + print(" No change since last poll.") + + if not pending: + # Check if any dependency failed. If so, exit non-zero so the + # run shows as failed — this lets ``gh run rerun --failed`` + # (e.g. from label-rerun.yml) pick up and rerun the failed jobs. + failed_deps = [name for name, result in completed.items() if result == "failure"] + if failed_deps: + print(f" All jobs done, but {len(failed_deps)} failed: {', '.join(failed_deps)}") + print(" Exiting with error so the run can be rerun via --failed.") + return 1 + print(" All jobs completed — done.") + break + + time.sleep(interval) + + return 0 + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--interval", type=int, default=15, + help="Seconds between polls (default: 15).") + parser.add_argument("--timeout", type=int, default=1800, + help="Max seconds to poll before giving up (default: 1800).") + parser.add_argument("--review-statuses-file", type=Path, default=None, + help="Path to a JSON file with merged review statuses from workflow_call jobs.") + parser.add_argument("--dry-run", action="store_true", + help="Print comment body instead of posting to PR.") + args = parser.parse_args() + + token = os.environ.get("GITHUB_TOKEN", "") + repo = os.environ.get("GITHUB_REPOSITORY", "") + run_id = os.environ.get("GITHUB_RUN_ID", "") + pr_number = os.environ.get("PR_NUMBER", "") + run_url = os.environ.get("RUN_URL", "") + + if not args.dry_run: + if not token: + print("GITHUB_TOKEN is required", file=sys.stderr) + return 1 + if not repo: + print("GITHUB_REPOSITORY is required", file=sys.stderr) + return 1 + if not run_id: + print("GITHUB_RUN_ID is required", file=sys.stderr) + return 1 + if not pr_number: + print("PR_NUMBER is required", file=sys.stderr) + return 1 + + # Read merged review statuses from file (prepared by the ci.yml step). + review_statuses_json = "" + if args.review_statuses_file: + try: + review_statuses_json = args.review_statuses_file.read_text(encoding="utf-8") + except OSError as e: + print(f"Warning: could not read review statuses file: {e}", file=sys.stderr) + + # Build commit info line from env vars (set by ci.yml). + commit_sha = os.environ.get("COMMIT_SHA", "") + commit_msg = os.environ.get("COMMIT_MESSAGE", "") + commit_url = os.environ.get("COMMIT_URL", "") + commit_info = "" + if commit_sha: + short_sha = commit_sha[:7] + if commit_msg: + # Truncate commit message to first line, max 60 chars. + first_line = commit_msg.split("\n")[0][:60] + if commit_url: + commit_info = f"running on [{short_sha}]({commit_url}) — {first_line}" + else: + commit_info = f"running on {short_sha} — {first_line}" + elif commit_url: + commit_info = f"running on [{short_sha}]({commit_url})" + else: + commit_info = f"running on {short_sha}" + + return run( + token=token, + repo=repo, + run_id=run_id, + pr_number=pr_number, + run_url=run_url, + review_statuses_json=review_statuses_json, + commit_info=commit_info, + interval=args.interval, + timeout=args.timeout, + dry_run=args.dry_run, + ) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/ci/lockfile_diff.py b/scripts/ci/lockfile_diff.py index 0a0e03c691ed..46712ad3760e 100644 --- a/scripts/ci/lockfile_diff.py +++ b/scripts/ci/lockfile_diff.py @@ -15,11 +15,11 @@ Usage (from a checkout that still has the base ref available): --output diff.md [--repo-root .] Reads every ``package-lock.json`` tracked at either ref (top-level and -nested — the repo has several), diffs each, and writes a Markdown report -to ``--output``. Exits 0 always; an empty report file means "no version -changes" (the caller uses that to decide whether to post/update the PR -comment). The report embeds ``COMMENT_MARKER`` so the workflow can find -and update its own previous comment instead of stacking new ones. +nested — the repo has several), diffs each, and writes a Markdown fragment +to ``--output``. Exits 0 always; an empty output file means "no version +changes" (the caller uses that to decide whether to include the section). +The fragment is consumed by ``scripts/ci/assemble_review_comment.py``, +which wraps it in a section with a header and action note. """ from __future__ import annotations @@ -29,9 +29,6 @@ import json import subprocess import sys -# Hidden marker used to locate the bot's previous comment for in-place update. -COMMENT_MARKER = "" - def parse_lockfile(text: str) -> dict[str, str]: """Reduce lockfile JSON to ``{install path: version}``. @@ -79,21 +76,24 @@ def _display_name(path: str) -> str: def render_markdown(diffs: dict[str, dict[str, list]]) -> str: - """Render per-lockfile diffs as a Markdown PR comment body. + """Render per-lockfile diffs as a Markdown fragment. ``diffs`` maps lockfile repo-path → the output of :func:`diff_locks`. Lockfiles with no version changes are omitted. Returns ``""`` when - nothing changed anywhere (caller skips commenting entirely). + nothing changed anywhere (caller skips the section entirely). + + The output is a fragment — per-lockfile ``####`` subsections with + tables — not a standalone comment. The ``assemble_review_comment`` + script wraps this in a section with its own header and action note, + so no top-level header or comment marker is emitted here. """ sections = [] - total = 0 for lockfile, d in sorted(diffs.items()): added, removed, updated = d["added"], d["removed"], d["updated"] n = len(added) + len(removed) + len(updated) if n == 0: continue - total += n - lines = [f"### `{lockfile}`", ""] + lines = [f"#### `{lockfile}`", ""] lines.append("| Package | Before | After |") lines.append("| --- | --- | --- |") for path, old, new in updated: @@ -107,13 +107,7 @@ def render_markdown(diffs: dict[str, dict[str, list]]) -> str: if not sections: return "" - header = ( - f"{COMMENT_MARKER}\n" - f"## ⚠️ `package-lock.json` changes ({total} package" - f"{'s' if total != 1 else ''})\n\n" - "This PR changes locked npm dependency versions." - ) - return header + "\n" + "\n\n".join(sections) + "\n" + return "\n\n".join(sections) + "\n" def _git_show(ref: str, path: str, repo_root: str) -> str | None: diff --git a/scripts/ci/timings_report.py b/scripts/ci/timings_report.py index 0e19e539c63a..f28bd9d703f8 100644 --- a/scripts/ci/timings_report.py +++ b/scripts/ci/timings_report.py @@ -895,6 +895,86 @@ def generate_summary(timings: dict, baseline: dict | None = None) -> str: return "\n".join(lines) +# --------------------------------------------------------------------------- +# Review status JSON for the unified PR comment +# --------------------------------------------------------------------------- + +# Wall-time regressions above this fraction of baseline are "warning" severity. +_TIMINGS_WARN_PCT = 0.25 + + +def generate_review_status( + timings: dict, baseline: dict | None, report_url: str | None = None +) -> list[dict]: + """Produce a review_status JSON array for the CI timings review section. + + Returns a list with one ``{source, results: [...]}`` entry. The + result kind is ``"info"`` or ``"warning"`` (timings is never error — + it's an observability job). *summary* is a single short line suitable + for the PR comment. *detail* has the per-job deltas as a markdown + fragment. + """ + stats = compute_stats(timings, baseline) + + if baseline is None: + severity = "info" + summary = f"Wall time {fmt_dur(stats['wall'])} (no baseline yet)." + else: + wall = stats["wall"] + bl_wall = stats["bl_wall"] or 0 + if bl_wall > 0: + pct = (wall - bl_wall) / bl_wall * 100 + wall_str = f"Wall time {fmt_dur(wall)} vs {fmt_dur(bl_wall)} ({pct:+.1f}%)." + if pct > _TIMINGS_WARN_PCT * 100: + severity = "warning" + else: + severity = "info" + else: + wall_str = f"Wall time {fmt_dur(wall)}." + severity = "info" + + if stats["slower"]: + wall_str += f" {stats['slower']} job(s) slower," + if stats["faster"]: + wall_str += f" {stats['faster']} faster," + if stats["unchanged"]: + wall_str += f" {stats['unchanged']} unchanged." + summary = wall_str + + # Per-job delta detail (top 5 by absolute change) + detail_lines: list[str] = [] + if baseline: + bl_map = {j["name"]: j for j in baseline.get("jobs", [])} + deltas: list[tuple[float, str, str]] = [] + for j in timings.get("jobs", []): + if is_skipped(j): + continue + bl = bl_map.get(j["name"]) + if not bl or is_skipped(bl): + continue + cur = j.get("duration_s") or 0 + bl_d = bl.get("duration_s") or 0 + diff = cur - bl_d + if abs(diff) < 1.0: + continue + deltas.append((abs(diff), j["name"], f"{diff:+.1f}s")) + deltas.sort(reverse=True) + for _, name, delta_str in deltas[:5]: + detail_lines.append(f"- {name}: {delta_str}") + + result: dict = { + "kind": severity, + "title": "CI timings", + "summary": summary, + "detail": "\n".join(detail_lines), + } + if report_url: + result["link"] = report_url + result["link_label"] = "View report" + + return [{"source": "ci timing", "results": [result]}] + + # --------------------------------------------------------------------------- # Main # --------------------------------------------------------------------------- @@ -916,6 +996,8 @@ def main(): help="JSON output path (default: ci-timings.json)") parser.add_argument("--summary-out", default="ci-timings-summary.md", help="Markdown summary output path (default: ci-timings-summary.md)") + parser.add_argument("--review-status-out", default="", + help="If set, write a review-status JSON for the unified PR comment.") args = parser.parse_args() # Collect or load timings @@ -974,6 +1056,17 @@ def main(): f.write(summary) print(f"Wrote summary to {args.summary_out}") + # Write review status for the unified PR comment. + # The output goes to GITHUB_OUTPUT (or a file with the same key=value + # format) so the ci-timings job can expose it as a workflow_call output. + if args.review_status_out: + report_url = os.environ.get("CI_TIMINGS_REPORT_URL", "") + statuses = generate_review_status(timings, baseline, report_url) + json_str = json.dumps(statuses) + with open(args.review_status_out, "a", encoding="utf-8") as f: + f.write(f"review_status={json_str}\n") + print(f"Wrote review status to {args.review_status_out}") + if __name__ == "__main__": main() diff --git a/tests/ci/test_assemble_review_comment.py b/tests/ci/test_assemble_review_comment.py new file mode 100644 index 000000000000..373fb6a3a47f --- /dev/null +++ b/tests/ci/test_assemble_review_comment.py @@ -0,0 +1,643 @@ +"""Tests for scripts/ci/assemble_review_comment.py. + +The assembler collects status from every CI sub-workflow into ReviewItems +classified by severity (error / action_required / warning / info), then +renders them into a single PR comment body. + +Status data comes from two sources: + 1. --review-statuses-json: JSON array of {source, results: [...]} objects + from workflow_call jobs. Each result has kind/title/summary/detail/ + how_to_fix/link. The assembler flattens all results into ReviewItems. + 2. --needs-json: {job_name: result} from all-checks-pass. Failed jobs not + claimed by any status become synthesized ❌ Error items. + +Layout rules tested here: + - group headers: ## ❌ Job failures, ## ⚠️ Action required, ## ⚠️ Warnings + - each item is a ### section under its group header + - errors + action_required always visible + - warnings shown only when present + - info in a collapsible
block + - sections separated by --- + - how_to_fix rendered at bottom of action_required items + - empty → clean banner + - jobs with declared statuses excluded from failed-jobs list + - per-job URLs used for failed job links when available +""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "assemble_review_comment.py" +_spec = importlib.util.spec_from_file_location("assemble_review_comment", _PATH) +if _spec is None or _spec.loader is None: + raise ImportError("Failed to load assemble_review_comment.py") +_mod = importlib.util.module_from_spec(_spec) +sys.modules["assemble_review_comment"] = _mod +_spec.loader.exec_module(_mod) + +MARKER = _mod.MARKER +ReviewItem = _mod.ReviewItem + + +def _status(source: str, results: list[dict]) -> str: + """Helper: build a review_statuses JSON string with one source entry.""" + return json.dumps([{"source": source, "results": results}]) + + +# ─── collect_from_statuses ────────────────────────────────────────── + + +def test_statuses_empty_json(): + items, sources = _mod.collect_from_statuses("") + assert items == [] + assert sources == set() + + +def test_statuses_bad_json(): + items, sources = _mod.collect_from_statuses("not json") + assert items == [] + assert sources == set() + + +def test_statuses_action_required(): + statuses = _status("review-label-gate", [{ + "kind": "action_required", + "title": "CI-sensitive file review", + "summary": "Changes detected.", + "how_to_fix": "Add the label.", + }]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 1 + assert items[0].severity == "action_required" + assert items[0].title == "CI-sensitive file review" + assert items[0].how_to_fix == "Add the label." + assert items[0].source == "review-label-gate" + assert sources == {"review-label-gate"} + + +def test_statuses_info(): + statuses = _status("review-label-gate", [{ + "kind": "info", + "title": "CI-sensitive file review", + "summary": "Label present.", + }]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 1 + assert items[0].severity == "info" + assert sources == {"review-label-gate"} + + +def test_statuses_multiple_results_same_source(): + """One source can emit multiple results of different kinds.""" + statuses = _status("review-label-gate", [ + {"kind": "action_required", "title": "CI review", "summary": "Missing label."}, + {"kind": "action_required", "title": "MCP review", "summary": "Missing label."}, + ]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 2 + assert sources == {"review-label-gate"} + + +def test_statuses_mixed_kinds_same_source(): + """One source can emit both a warning and an info.""" + statuses = _status("ci-timings", [ + {"kind": "warning", "title": "CI timings", "summary": "Slower."}, + {"kind": "info", "title": "Baseline", "summary": "OK."}, + ]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 2 + assert items[0].severity == "warning" + assert items[1].severity == "info" + assert sources == {"ci-timings"} + + +def test_statuses_multiple_sources(): + statuses = json.dumps([ + {"source": "review-label-gate", "results": [ + {"kind": "action_required", "title": "CI review", "summary": "Missing."}, + ]}, + {"source": "lockfile-diff", "results": [ + {"kind": "info", "title": "package-lock.json", "summary": "No changes."}, + ]}, + ]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 2 + assert sources == {"review-label-gate", "lockfile-diff"} + + +def test_statuses_unknown_kind_becomes_info(): + statuses = _status("some-job", [{ + "kind": "bogus", + "title": "X", + "summary": "Y", + }]) + items, _ = _mod.collect_from_statuses(statuses) + assert items[0].severity == "info" + + +def test_statuses_no_source(): + """Status without a source — still rendered, just not excluded from errors.""" + statuses = json.dumps([{ + "results": [{"kind": "info", "title": "X", "summary": "Y"}], + }]) + items, sources = _mod.collect_from_statuses(statuses) + assert len(items) == 1 + assert sources == set() + + +def test_statuses_passes_through_optional_fields(): + statuses = _status("ci-timings", [{ + "kind": "warning", + "title": "CI timings", + "summary": "Slower.", + "detail": "- job: +5s", + "link": "https://report", + "link_label": "View report", + "how_to_fix": "Optimize.", + }]) + items, _ = _mod.collect_from_statuses(statuses) + assert items[0].detail == "- job: +5s" + assert items[0].link == "https://report" + assert items[0].link_label == "View report" + assert items[0].how_to_fix == "Optimize." + + +# ─── collect_failed_jobs ───────────────────────────────────────────── + + +def test_failed_jobs_empty_needs(): + assert _mod.collect_failed_jobs("", "https://run") == [] + + +def test_failed_jobs_no_failures(): + needs = json.dumps({"tests": "success", "lint": "skipped"}) + assert _mod.collect_failed_jobs(needs, "https://run") == [] + + +def test_failed_jobs_collects_only_failures(): + needs = json.dumps({"tests": "success", "lint": "failure", "js-tests": "failure"}) + items = _mod.collect_failed_jobs(needs, "https://run/123") + assert len(items) == 2 + assert all(i.severity == "error" for i in items) + # sorted by name + names = [i.title for i in items] + assert names == ["js-tests", "lint"] + assert all(i.job_url == "https://run/123" for i in items) + + +def test_failed_jobs_bad_json(): + assert _mod.collect_failed_jobs("not json", "https://run") == [] + + +def test_failed_jobs_excluded_by_source(): + """Jobs whose name contains a declared source are excluded.""" + needs = json.dumps({ + "Review label gate / Review label gate": "failure", + "tests": "failure", + }) + items = _mod.collect_failed_jobs(needs, "https://run", exclude_sources={"review-label-gate"}) + assert len(items) == 1 + assert items[0].title == "tests" + + +def test_failed_jobs_no_exclusion_without_sources(): + """Without exclude_sources, all failures are shown.""" + needs = json.dumps({"review-label-gate": "failure", "tests": "failure"}) + items = _mod.collect_failed_jobs(needs, "https://run") + assert len(items) == 2 + + +def test_failed_jobs_per_job_url(): + """When job_urls is provided, the link points to the specific job.""" + needs = json.dumps({"tests": "failure", "lint": "failure"}) + job_urls = {"tests": "https://run/1/job/2", "lint": "https://run/1/job/3"} + items = _mod.collect_failed_jobs(needs, "https://fallback", job_urls=job_urls) + assert len(items) == 2 + urls = {i.title: i.job_url for i in items} + assert urls["tests"] == "https://run/1/job/2" + assert urls["lint"] == "https://run/1/job/3" + + +def test_failed_jobs_fallback_to_run_url(): + """Jobs not in job_urls fall back to run_url.""" + needs = json.dumps({"tests": "failure", "lint": "failure"}) + job_urls = {"tests": "https://run/1/job/2"} + items = _mod.collect_failed_jobs(needs, "https://fallback", job_urls=job_urls) + urls = {i.title: i.job_url for i in items} + assert urls["tests"] == "https://run/1/job/2" + assert urls["lint"] == "https://fallback" + + +# ─── render_comment ─────────────────────────────────────────────────── + + +def test_render_empty_shows_clean_banner(): + """Completely clean — dog kaomoji + 'looks good' banner, no sections.""" + body = _mod.render_comment([]) + assert body.startswith(MARKER) + assert "૮ >ﻌ< ა" in body + assert "looks good to me!" in body + assert "##" not in body # no section headers + + +def test_render_info_only_shows_details(): + """Info items only — header + collapsible details, no blocking sections.""" + items = [ + ReviewItem(severity="info", title="lockfile", summary="No changes."), + ReviewItem(severity="info", title="timings", summary="OK."), + ] + body = _mod.render_comment(items) + assert "૮ >ﻌ< ა" in body + assert "
" in body + assert "
" in body + assert "No changes." in body + assert "OK." in body + # No blocking sections + assert "## ❌" not in body + assert "## ⚠️" not in body + + +def test_render_info_only_with_pending_shows_details_plus_footer(): + items = [ReviewItem(severity="info", title="lockfile", summary="No changes.")] + body = _mod.render_comment(items, pending_jobs=["ci-timings"]) + assert "૮ >ﻌ< ა" in body + assert "Still running" in body + assert "
" in body + assert "Still running" in body + assert "`ci-timings`" in body + + +def test_render_group_header_for_errors(): + """Errors appear under a '## ❌ Job failures' group header.""" + items = [ + ReviewItem(severity="error", title="tests", summary="Job **tests** failed.", link="https://run"), + ReviewItem(severity="error", title="lint", summary="Job **lint** failed.", link="https://run"), + ] + body = _mod.render_comment(items) + assert "## ❌ Job failures" in body + assert "### tests" in body + assert "### lint" in body + assert body.index("## ❌ Job failures") < body.index("### tests") + + +def test_render_group_header_for_action_required(): + items = [ + ReviewItem(severity="action_required", title="CI review", summary="Need label."), + ] + body = _mod.render_comment(items) + assert "## ⚠️ Action required" in body + assert "### CI review" in body + + +def test_render_group_header_for_warnings(): + items = [ + ReviewItem(severity="warning", title="CI timings", summary="Slower."), + ] + body = _mod.render_comment(items) + assert "## ⚠️ Warnings" in body + assert "### CI timings" in body + assert "
" not in body + + items2 = [ReviewItem(severity="info", title="x", summary="y")] + body2 = _mod.render_comment(items2) + assert "## ⚠️ Warnings" not in body2 + + +def test_render_no_duplicated_severity_in_item_body(): + """Items don't repeat the severity label — the group header carries it.""" + items = [ReviewItem(severity="error", title="tests", summary="Job failed.", link="https://run")] + body = _mod.render_comment(items) + assert "### tests" in body + assert "Job failed." in body + assert "**❌ Error**" not in body + + +def test_render_how_to_fix_at_bottom(): + items = [ + ReviewItem(severity="action_required", title="CI review", summary="Need label.", + how_to_fix="Add the `ci-reviewed` label."), + ] + body = _mod.render_comment(items) + assert "**How to fix:**" in body + assert "Add the `ci-reviewed` label." in body + assert body.index("Need label.") < body.index("How to fix") + + +def test_render_sections_separated_by_hr(): + items = [ + ReviewItem(severity="error", title="tests", summary="failed."), + ReviewItem(severity="action_required", title="CI review", summary="need label."), + ] + body = _mod.render_comment(items) + assert "\n\n---\n\n" in body + + +def test_render_errors_always_visible(): + items = [ + ReviewItem(severity="error", title="tests", summary="Job **tests** failed.", job_url="https://run"), + ReviewItem(severity="info", title="lockfile", summary="No changes."), + ] + body = _mod.render_comment(items) + assert "## ❌ Job failures" in body + assert "### tests" in body + assert "Job **tests** failed." in body + assert "[View job](https://run)" in body + assert "
" in body + assert "No changes." in body + + +def test_render_info_in_collapsible_details(): + """Each info item is its own
block.""" + items = [ + ReviewItem(severity="info", title="lockfile", summary="No changes."), + ReviewItem(severity="info", title="timings", summary="OK."), + ] + body = _mod.render_comment(items) + assert body.count("
") == 2 + assert body.count("
") == 2 + assert "lockfile" in body + assert "timings" in body + assert "No changes." in body + assert "OK." in body + + +def test_render_order_errors_then_action_then_warn_then_info(): + items = [ + ReviewItem(severity="info", title="i", summary="info"), + ReviewItem(severity="warning", title="w", summary="warn"), + ReviewItem(severity="action_required", title="a", summary="action"), + ReviewItem(severity="error", title="e", summary="error"), + ] + body = _mod.render_comment(items) + error_pos = body.index("## ❌ Job failures") + action_pos = body.index("## ⚠️ Action required") + warn_pos = body.index("## ⚠️ Warnings") + info_pos = body.index("
") + assert error_pos < action_pos < warn_pos < info_pos + + +# ─── render_comment (pending jobs) ──────────────────────────────────── + + +def test_render_pending_only_shows_header_with_clock(): + """Pending jobs only — header has 'still waiting', footer lists jobs, no sections.""" + body = _mod.render_comment([], pending_jobs=["ci-timings"]) + assert body.startswith(MARKER) + assert "૮ >ﻌ< ა" in body + assert "Still running" in body + assert "`ci-timings`" in body + assert "##" not in body + + +def test_render_pending_notif(): + items = [ReviewItem(severity="info", title="lockfile", summary="No changes.")] + body = _mod.render_comment(items, pending_jobs=["ci-timings"]) + assert "૮ >ﻌ< ა" in body + assert "Still running 1 job: `ci-timings`" in body + + +def test_render_pending_multiple_jobs_sorted(): + body = _mod.render_comment([], pending_jobs=["docker", "ci-timings"]) + assert "`ci-timings`" in body + assert "`docker`" in body + assert body.index("`ci-timings`") < body.index("`docker`") + + +def test_render_no_pending_no_footer(): + items = [ReviewItem(severity="info", title="x", summary="y")] + body = _mod.render_comment(items) + assert "Still running" not in body + + +# ─── assemble (integration) ────────────────────────────────────────── + + +def test_assemble_all_skipped_clean_banner(): + body = _mod.assemble() + assert body.startswith(MARKER) + assert "૮ >ﻌ< ა" in body + assert "looks good to me!" in body + assert "##" not in body + + +def test_assemble_failed_job_shown(): + needs = json.dumps({"tests": "failure", "lint": "success"}) + body = _mod.assemble(needs_json=needs, run_url="https://run/1") + assert "## ❌ Job failures" in body + assert "### tests" in body + assert "[View job](https://run/1)" in body + + +def test_assemble_with_review_statuses(): + """Statuses from review-labels render directly + exclude gate from errors.""" + statuses = _status("review-label-gate", [{ + "kind": "action_required", + "title": "CI-sensitive file review", + "summary": "Changes detected.", + "how_to_fix": "Add the label.", + }]) + needs = json.dumps({ + "Review label gate / Review label gate": "failure", + "tests": "success", + }) + body = _mod.assemble( + needs_json=needs, + run_url="https://run", + review_statuses_json=statuses, + ) + assert "## ⚠️ Action required" in body + assert "### CI-sensitive file review" in body + assert "Add the label." in body + assert "## ❌ Job failures" not in body + + +def test_assemble_pending_jobs(): + body = _mod.assemble(pending_jobs=["ci-timings"]) + assert "Still running" in body + assert "`ci-timings`" in body + + +def test_assemble_with_items_and_pending(): + needs = json.dumps({"tests": "failure"}) + body = _mod.assemble(needs_json=needs, run_url="https://run", pending_jobs=["ci-timings"]) + assert "## ❌ Job failures" in body + assert "### tests" in body + assert "Still running" in body + assert "`ci-timings`" in body + + +def test_assemble_with_timings_status(): + """Timings status from the nested format renders as info or warning.""" + statuses = _status("ci-timings", [{ + "kind": "info", + "title": "CI timings", + "summary": "Wall time 3m (no baseline yet).", + "detail": "", + "link": "https://report", + }]) + body = _mod.assemble(review_statuses_json=statuses) + assert "
" in body + assert "### CI timings" in body + assert "Wall time 3m" in body + assert "## ❌" not in body + assert "## ⚠️" not in body + + +def test_assemble_with_lockfile_status(): + """Lockfile no-changes status renders as info in the details block.""" + statuses = _status("lockfile-diff", [{ + "kind": "info", + "title": "package-lock.json", + "summary": "No lockfile changes — locked versions match the target branch.", + }]) + body = _mod.assemble(review_statuses_json=statuses) + assert "
" in body + assert "### package-lock.json" in body + assert "No lockfile changes" in body + + +def test_assemble_with_lockfile_changed_status(): + """Lockfile changed status renders as action_required with ci-reviewed how_to_fix.""" + statuses = _status("lockfile-diff", [{ + "kind": "action_required", + "title": "package-lock.json", + "summary": "Locked npm dependency versions changed.", + "detail": "#### `package-lock.json`\n\n| col | | |", + "how_to_fix": "Add the `ci-reviewed` label after verifying the version changes are expected.", + }]) + body = _mod.assemble(review_statuses_json=statuses) + assert "## ⚠️ Action required" in body + assert "### package-lock.json" in body + assert "Locked npm dependency versions changed." in body + assert "Add the `ci-reviewed` label" in body + assert "
" not in body # action_required is not in the collapsible block + + +# ─── _attach_job_urls ──────────────────────────────────────────────── + + +def test_attach_job_urls_fills_missing_links(): + """Items without a link get one from job_urls via source matching.""" + items = [ + ReviewItem(severity="info", title="Supply chain scan", + summary="No risks.", source="supply chain"), + ReviewItem(severity="warning", title="CI timings", + summary="Slower.", source="ci timings", + link="https://report"), # already has a link + ] + job_urls = { + "Supply Chain Audit / Scan PR for critical supply chain risks": "https://run/1/job/2", + } + _mod._attach_job_urls(items, job_urls, "https://fallback") + # First item gets the per-job URL as job_url (link untouched) + assert items[0].job_url == "https://run/1/job/2" + assert items[0].link == "" # no emitted link + # Second item keeps its existing link, job_url is set separately + assert items[1].link == "https://report" + assert items[1].job_url == "https://fallback" # fell back to run_url + + +def test_attach_job_urls_fallback_to_run_url(): + """Items with a source but no matching job URL fall back to run_url.""" + items = [ + ReviewItem(severity="info", title="X", summary="Y", source="some-job"), + ] + _mod._attach_job_urls(items, {}, "https://fallback") + assert items[0].job_url == "https://fallback" + + +def test_attach_job_urls_no_source_no_link(): + """Items without a source don't get a link.""" + items = [ + ReviewItem(severity="info", title="X", summary="Y"), # no source + ] + _mod._attach_job_urls(items, {"job": "https://run/1"}, "https://fallback") + assert items[0].job_url == "" + + +def test_assemble_attaches_links_to_all_items(): + """Integration: assemble() attaches job URLs to status items, not just errors.""" + statuses = _status("supply chain", [{ + "kind": "info", + "title": "Supply chain scan", + "summary": "No risks.", + }]) + job_urls = { + "Supply Chain Audit / Scan PR for critical supply chain risks": "https://run/1/job/2", + } + body = _mod.assemble( + review_statuses_json=statuses, + job_urls=job_urls, + run_url="https://fallback", + ) + assert "[View job](https://run/1/job/2)" in body + + +def test_render_commit_info_below_header(): + """Commit info is rendered below the header, above the content.""" + body = _mod.render_comment( + [ReviewItem(severity="error", title="tests", summary="failed.")], + commit_info="running on [abc1234](https://commit-url) — fix: thing", + ) + assert "# ૮ >ﻌ< ა ci review" in body + assert "running on [abc1234](https://commit-url)" in body + assert "fix: thing" in body + # Commit info appears before the content + assert body.index("abc1234") < body.index("## ❌") + + +def test_render_no_commit_info_when_empty(): + """No commit_info → no extra line below header.""" + body = _mod.render_comment([]) + assert "running on" not in body + + +def test_assemble_passes_commit_info(): + """assemble() passes commit_info through to render_comment.""" + body = _mod.assemble(commit_info="running on abc1234") + assert "running on abc1234" in body + assert "looks good to me!" in body + + +def test_render_both_emitted_link_and_job_url(): + """An item with both an emitted link and a job_url shows both.""" + item = ReviewItem( + severity="warning", + title="CI timings", + summary="Slower.", + link="https://artifact/report.html", + link_label="View report", + source="ci timings", + job_url="https://github.com/run/1/job/5", + ) + body = _mod.render_comment([item]) + assert "[View report](https://artifact/report.html)" in body + assert "[View job](https://github.com/run/1/job/5)" in body + # Both links on the same line, separated by · + assert " · " in body + + +def test_assemble_both_links_for_ci_timings(): + """Integration: ci-timings has a report URL (link) AND gets a job_url.""" + statuses = _status("ci timings", [{ + "kind": "warning", + "title": "CI timings", + "summary": "Wall time 5m vs 3m (+66%).", + "detail": "- tests: +120s", + "link": "https://artifact/report.html", + "link_label": "View report", + }]) + job_urls = {"CI timings": "https://github.com/run/1/job/5"} + body = _mod.assemble( + review_statuses_json=statuses, + job_urls=job_urls, + run_url="https://fallback", + ) + assert "[View report](https://artifact/report.html)" in body + assert "[View job](https://github.com/run/1/job/5)" in body diff --git a/tests/ci/test_live_comment.py b/tests/ci/test_live_comment.py new file mode 100644 index 000000000000..ffc952921310 --- /dev/null +++ b/tests/ci/test_live_comment.py @@ -0,0 +1,162 @@ +"""Tests for scripts/ci/live_comment.py — classify_jobs(). + +The poller's core logic is a pure function: take raw GitHub API job dicts +and split them into (completed, pending). The API wrapper + polling loop +are tested via E2E in CI, not here. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "live_comment.py" +_spec = importlib.util.spec_from_file_location("live_comment", _PATH) +if _spec is None or _spec.loader is None: + raise ImportError("Failed to load live_comment.py") +_mod = importlib.util.module_from_spec(_spec) +sys.modules["live_comment"] = _mod +_spec.loader.exec_module(_mod) + + +def _job(name: str, status: str, conclusion: str | None = None, workflow: str = "") -> dict: + """Build a raw API job dict.""" + j = {"name": name, "status": status, "conclusion": conclusion} + if workflow: + j["_workflow_name"] = workflow + return j + + +def test_classify_empty(): + completed, pending, job_urls = _mod.classify_jobs([]) + assert completed == {} + assert pending == [] + assert job_urls == {} + + +def test_classify_success(): + jobs = [_job("Python tests", "completed", "success")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "success"} + assert pending == [] + + +def test_classify_failure(): + jobs = [_job("Python tests", "completed", "failure")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "failure"} + assert pending == [] + + +def test_classify_skipped(): + jobs = [_job("Python tests", "completed", "skipped")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "skipped"} + assert pending == [] + + +def test_classify_in_progress(): + jobs = [_job("Python tests", "in_progress", None)] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {} + assert pending == ["Python tests"] + + +def test_classify_queued(): + jobs = [_job("Python tests", "queued", None)] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {} + assert pending == ["Python tests"] + + +def test_classify_waiting(): + jobs = [_job("Python tests", "waiting", None)] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {} + assert pending == ["Python tests"] + + +def test_classify_mixed(): + jobs = [ + _job("Python tests", "completed", "success"), + _job("Python lints", "completed", "failure"), + _job("JS & TS checks", "in_progress", None), + _job("Desktop E2E", "queued", None), + ] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "success", "Python lints": "failure"} + assert set(pending) == {"JS & TS checks", "Desktop E2E"} + + +def test_classify_infra_jobs_excluded(): + """Infra jobs (detect, all-checks-pass, comment-live) are never shown.""" + jobs = [ + _job("detect", "completed", "success"), + _job("Detect affected areas", "completed", "success"), + _job("all-checks-pass", "completed", "success"), + _job("All required checks pass", "completed", "success"), + _job("comment-live", "in_progress", None), + _job("CI review comment (live)", "in_progress", None), + _job("Python tests", "completed", "success"), + ] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "success"} + assert pending == [] + + +def test_classify_sub_workflow_jobs_prefixed(): + """Sub-workflow jobs get 'Workflow / job' display names.""" + jobs = [ + _job("test", "completed", "success", workflow="Tests"), + _job("check", "in_progress", None, workflow="JS Tests"), + ] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert "Tests / test" in completed + assert completed["Tests / test"] == "success" + assert "JS Tests / check" in pending + + +def test_classify_captures_html_url(): + """The poller captures html_url per job for per-job log links.""" + jobs = [ + {**_job("Python tests", "completed", "failure"), + "html_url": "https://github.com/repo/actions/runs/1/job/2"}, + _job("Python lints", "completed", "success"), + ] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert job_urls["Python tests"] == "https://github.com/repo/actions/runs/1/job/2" + # Jobs without html_url are simply absent from the dict + assert "Python lints" not in job_urls + + +def test_classify_cancelled_treated_as_skipped(): + jobs = [_job("Python tests", "completed", "cancelled")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "skipped"} + + +def test_classify_timed_out_treated_as_failure(): + jobs = [_job("Python tests", "completed", "timed_out")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "failure"} + + +def test_classify_neutral_treated_as_skipped(): + jobs = [_job("Python tests", "completed", "neutral")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "skipped"} + + +def test_classify_action_required_treated_as_skipped(): + jobs = [_job("Python tests", "completed", "action_required")] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {"Python tests": "skipped"} + + +def test_classify_unknown_status_skipped(): + """Unknown status values are silently ignored, not crashed on.""" + jobs = [_job("weird-job", "unknown_status", None)] + completed, pending, job_urls = _mod.classify_jobs(jobs) + assert completed == {} + assert pending == [] diff --git a/tests/ci/test_lockfile_diff.py b/tests/ci/test_lockfile_diff.py index 0554ed473331..46b3ff01d745 100644 --- a/tests/ci/test_lockfile_diff.py +++ b/tests/ci/test_lockfile_diff.py @@ -89,15 +89,14 @@ def test_nested_dedup_is_distinct_entry(): assert d["updated"] == [("node_modules/foo/node_modules/react", "17.0.2", "17.0.3")] -def test_render_markdown_contains_marker_and_versions(): +def test_render_markdown_contains_versions_and_nested_display(): d = _mod.diff_locks( _mod.parse_lockfile(BASE), _mod.parse_lockfile(_lock({"node_modules/react": {"version": "19.0.0"}})), ) md = _mod.render_markdown({"apps/desktop/package-lock.json": d}) - assert md.startswith(_mod.COMMENT_MARKER) # workflow finds its comment by prefix - assert "⚠️" in md - assert "`apps/desktop/package-lock.json`" in md + # Fragment starts directly with the per-lockfile subsection header. + assert md.startswith("#### `apps/desktop/package-lock.json`") assert "`18.2.0`" in md and "`19.0.0`" in md # nested display name keeps the parent chain visible assert "nested under foo" in md diff --git a/tests/ci/test_timings_report.py b/tests/ci/test_timings_report.py new file mode 100644 index 000000000000..dee48c09f309 --- /dev/null +++ b/tests/ci/test_timings_report.py @@ -0,0 +1,143 @@ +"""Tests for scripts/ci/timings_report.py — generate_review_status(). + +The review status is a JSON array in the unified nested format consumed +by the review comment assembler. It classifies the CI timings result as +info/warning (never error — timings is an observability job, not a gate) +and provides a one-line summary plus optional per-job delta detail. +""" + +from __future__ import annotations + +import importlib.util +from datetime import datetime, timezone +from pathlib import Path + +_PATH = Path(__file__).resolve().parents[2] / "scripts" / "ci" / "timings_report.py" +_spec = importlib.util.spec_from_file_location("timings_report", _PATH) +if _spec is None or _spec.loader is None: + raise ImportError("Failed to load timings_report.py") +_mod = importlib.util.module_from_spec(_spec) +_spec.loader.exec_module(_mod) + +_T0 = datetime(2025, 1, 1, 0, 0, 0, tzinfo=timezone.utc) + + +def _ts(seconds: float) -> str: + """ISO timestamp `seconds` after T0.""" + dt = _T0.timestamp() + seconds + return datetime.fromtimestamp(dt, tz=timezone.utc).isoformat().replace("+00:00", "Z") + + +def _job(name: str, dur_s: float, start_s: float = 0.0, conclusion: str = "success") -> dict: + """Build a normalized job dict with realistic timestamps for wall-time math.""" + return { + "name": name, + "duration_s": dur_s, + "conclusion": conclusion, + "started_at": _ts(start_s), + "completed_at": _ts(start_s + dur_s), + "wait_s": 0.0, + } + + +def _timings(jobs: list[dict]) -> dict: + return {"run_id": "123", "head_sha": "abc", "created_at": "", "jobs": jobs} + + +def _result(statuses: list[dict]) -> dict: + """Extract the single result dict from the nested format.""" + assert len(statuses) == 1 + assert statuses[0]["source"] == "ci timing" + results = statuses[0]["results"] + assert len(results) == 1 + return results[0] + + +def test_no_baseline_is_info(): + t = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(t, None)) + assert result["kind"] == "info" + assert "no baseline" in result["summary"].lower() + assert "link" not in result # no report_url → no link field + + +def test_no_regression_is_info(): + cur = _timings([_job("tests", 60.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert result["kind"] == "info" + assert "+0.0%" in result["summary"] + + +def test_small_regression_is_info(): + cur = _timings([_job("tests", 65.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + # +8.3% — well under the 25% warning threshold + assert result["kind"] == "info" + + +def test_large_regression_is_warning(): + cur = _timings([_job("tests", 80.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + # +33% — above the 25% threshold + assert result["kind"] == "warning" + assert "+33" in result["summary"] + + +def test_improvement_is_info(): + cur = _timings([_job("tests", 40.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert result["kind"] == "info" + assert "-33" in result["summary"] + + +def test_detail_shows_top_deltas(): + cur = _timings([_job("slow-job", 120.0), _job("fast-job", 30.0, start_s=120.0)]) + bl = _timings([_job("slow-job", 60.0), _job("fast-job", 60.0, start_s=60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert "slow-job" in result["detail"] + assert "fast-job" in result["detail"] + # Sorted by abs delta — slow-job (+60) before fast-job (-30) + assert result["detail"].index("slow-job") < result["detail"].index("fast-job") + + +def test_skipped_jobs_excluded_from_detail(): + cur = _timings([_job("skipped-job", 0.0, conclusion="skipped"), _job("tests", 60.0)]) + bl = _timings([_job("skipped-job", 0.0, conclusion="skipped"), _job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert "skipped-job" not in result["detail"] + + +def test_report_url_passed_through(): + t = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(t, None, report_url="https://artifact/123")) + assert result["link"] == "https://artifact/123" + assert result["link_label"] == "View report" + + +def test_never_error_severity(): + """Timings is observability — even huge regressions are warnings, not errors.""" + cur = _timings([_job("tests", 600.0)]) + bl = _timings([_job("tests", 60.0)]) + result = _result(_mod.generate_review_status(cur, bl)) + assert result["kind"] == "warning" + assert result["kind"] != "error" + + +def test_nested_format_structure(): + """The return value is a list with one {source, results: [...]} entry.""" + t = _timings([_job("tests", 60.0)]) + statuses = _mod.generate_review_status(t, None) + assert isinstance(statuses, list) + assert len(statuses) == 1 + assert statuses[0]["source"] == "ci timing" + assert isinstance(statuses[0]["results"], list) + assert len(statuses[0]["results"]) == 1 + r = statuses[0]["results"][0] + assert r["kind"] == "info" + assert r["title"] == "CI timings" + assert "summary" in r + assert "detail" in r From 7a69b82ad4785babc254309e119386157353985f Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 13:33:47 -0400 Subject: [PATCH 43/72] ci: migrate AUTOFIX_BOT_PAT to GitHub App token MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the long-lived fine-grained PAT (AUTOFIX_BOT_PAT) with short-lived (1-hour) installation access tokens minted via a new get-app-token composite action wrapping actions/create-github-app-token@v3.2.0. The PAT was used in 13 spots across 8 workflow files for gh CLI / GitHub API calls. The per-repo GITHUB_TOKEN (1,000 req/hr) was getting rate-limited when multiple workflows fire concurrently (deploy-site, skills-index, ci-timings, supply-chain-audit, js-autofix). App installation tokens get 5,000 req/hr per installation and are scoped to the App's permissions, not a user account. New composite action: .github/actions/get-app-token/ - Wraps actions/create-github-app-token@bcd2ba49 (v3.2.0, SHA-pinned) - Reads APP_ID + APP_PRIVATE_KEY repo secrets - Outputs a 1hr installation token via steps.app-token.outputs.token Requires two new repo secrets (set after creating the GitHub App): - APP_ID: the App's numeric ID - APP_PRIVATE_KEY: the PEM private key App installation permissions needed: contents: write (js-autofix push, pypi release upload) pull-requests: write (js-autofix PR create/merge, supply-chain comment) issues: write (skills-index-freshness issue creation) actions: write (skills-index workflow trigger) workflows: write (skills-index triggers deploy-site.yml) The AUTOFIX_BOT_PAT secret can be deleted once CI passes on this PR. The comment in js-autofix.yml noting that PAT pushes trigger downstream workflows is updated — App tokens have the same property (they are not GITHUB_TOKEN), so the concurrency-cancel loop logic is unchanged. --- .github/actions/detect-changes/action.yml | 2 +- .github/actions/get-app-token/action.yml | 59 ++++++++++++++++++++ .github/workflows/ci.yml | 17 ++++-- .github/workflows/deploy-site.yml | 8 ++- .github/workflows/js-autofix.yml | 10 +++- .github/workflows/skills-index-freshness.yml | 7 ++- .github/workflows/skills-index.yml | 11 +++- .github/workflows/supply-chain-audit.yml | 6 +- .github/workflows/upload_to_pypi.yml | 8 ++- 9 files changed, 112 insertions(+), 16 deletions(-) create mode 100644 .github/actions/get-app-token/action.yml diff --git a/.github/actions/detect-changes/action.yml b/.github/actions/detect-changes/action.yml index 7d95ba76c9a2..0794b5ed4f8e 100644 --- a/.github/actions/detect-changes/action.yml +++ b/.github/actions/detect-changes/action.yml @@ -7,7 +7,7 @@ description: >- inputs: github-token: - description: Token for the GitHub API (gh CLI). Pass secrets.AUTOFIX_BOT_PAT from the calling workflow. + description: Token for the GitHub API (gh CLI). Pass steps.app-token.outputs.token from the calling workflow. required: false default: ${{ github.token }} diff --git a/.github/actions/get-app-token/action.yml b/.github/actions/get-app-token/action.yml new file mode 100644 index 000000000000..d42e46e81364 --- /dev/null +++ b/.github/actions/get-app-token/action.yml @@ -0,0 +1,59 @@ +name: Get GitHub App Token +description: >- + Mint a short-lived (1-hour) installation access token from the repo's + GitHub App, replacing the long-lived AUTOFIX_BOT_PAT. App tokens get + 5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN) + and are scoped to the App's installation permissions, not a user account. + + Falls back to the built-in GITHUB_TOKEN when APP_ID is not set — this + happens on fork PRs where repo secrets are unavailable. The fallback + ensures classification, timings, and review comments still work on + forks (with the lower GITHUB_TOKEN rate limit). + + Requires two repo secrets (store when creating the App): + - APP_ID — the App's numeric ID (Settings → General) + - APP_PRIVATE_KEY — the PEM private key (Settings → Private keys) + + The App must be installed on the repository (or org) with the + permissions the calling workflow needs. + +inputs: + owner: + description: Repository owner (for cross-org tokens). Defaults to the current repo's owner. + required: false + default: ${{ github.repository_owner }} + +outputs: + token: + description: A GitHub App installation access token (1-hour TTL), or GITHUB_TOKEN on forks. + value: ${{ steps.app-token.outputs.token || steps.fallback.outputs.token }} + +runs: + using: composite + steps: + - name: Check if App credentials exist + id: check + shell: bash + env: + APP_ID: ${{ secrets.APP_ID }} + run: | + if [ -n "$APP_ID" ]; then + echo "has_app=true" >> "$GITHUB_OUTPUT" + else + echo "has_app=false" >> "$GITHUB_OUTPUT" + fi + + - name: Create GitHub App token + id: app-token + if: steps.check.outputs.has_app == 'true' + uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 + with: + app-id: ${{ secrets.APP_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} + owner: ${{ inputs.owner }} + + - name: Fall back to GITHUB_TOKEN + id: fallback + if: steps.check.outputs.has_app != 'true' + shell: bash + run: echo "token=${{ github.token }}" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 96ff02597f0e..d22dc3381536 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -49,13 +49,16 @@ jobs: event_name: ${{ github.event_name }} steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token - name: Detect affected areas id: classify uses: ./.github/actions/detect-changes with: - # Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to - # the built-in read-only token so classification still works there. - github-token: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} + # The get-app-token composite action falls back to GITHUB_TOKEN + # on fork PRs where APP_ID is unavailable. + github-token: ${{ steps.app-token.outputs.token }} # ───────────────────────────────────────────────────────────────────── # Lane-gated sub-workflows. Each runs in parallel after detect finishes. @@ -318,6 +321,10 @@ jobs: - name: Checkout code uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + - name: Restore baseline cache (PR only) if: github.event_name == 'pull_request' uses: actions/cache/restore@27d5ce7f107fe9357f9df03efb73ab90386fccae # v5.0.5 @@ -334,7 +341,9 @@ jobs: # Forks get no repo secrets (AUTOFIX_BOT_PAT is empty); fall back to # the built-in read-only token so the timings API read still works # there instead of hard-failing this advisory job on every fork PR. - GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT || github.token }} + # The get-app-token composite action falls back to GITHUB_TOKEN + # on fork PRs where APP_ID is unavailable. + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} run: | python3 scripts/ci/timings_report.py \ --baseline ci-timings-baseline.json \ diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index e06a0842a633..f03dbf8ec84e 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -56,6 +56,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 22 @@ -73,8 +77,8 @@ jobs: - name: Prepare skills index (unified multi-source catalog) env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} - GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }} REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }} run: | diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml index 38494abd0e38..be8b8b4473a7 100644 --- a/.github/workflows/js-autofix.yml +++ b/.github/workflows/js-autofix.yml @@ -7,7 +7,7 @@ name: auto-fix lint issues & formatting # auto-corrected on merge so PRs aren't blocked by them. The PR-time eslint # check in typecheck.yml fails only when un-fixable errors remain. # -# NOTE: AUTOFIX_BOT_PAT pushes DO trigger further workflow runs (unlike +# NOTE: App token pushes DO trigger further workflow runs (unlike # secrets.GITHUB_TOKEN). The concurrency group (ts-autofix-${{ github.ref }}) # with cancel-in-progress: true prevents an infinite loop — a re-triggered # run cancels the in-flight one, and since the second run finds no new fixes @@ -128,6 +128,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + - name: Download patch uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: @@ -170,7 +174,7 @@ jobs: - name: Create/update PR and enable auto-merge env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} BOT_BRANCH: bot/js-autofix run: | set -euo pipefail @@ -193,7 +197,7 @@ jobs: - name: Wait for merge, auto-close on failure or stale env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} START_SHA: ${{ github.sha }} run: | set -euo pipefail diff --git a/.github/workflows/skills-index-freshness.yml b/.github/workflows/skills-index-freshness.yml index 5a9bf98a0f46..70fc5c6da28e 100644 --- a/.github/workflows/skills-index-freshness.yml +++ b/.github/workflows/skills-index-freshness.yml @@ -108,10 +108,15 @@ jobs: echo "Summary: ${{ steps.probe.outputs.summary }}" fi + - name: Get GitHub App token + if: steps.probe.outputs.status != 'ok' + id: app-token + uses: ./.github/actions/get-app-token + - name: Open issue on degraded / failed probe if: steps.probe.outputs.status != 'ok' env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} STATUS: ${{ steps.probe.outputs.status }} DETAIL: ${{ steps.probe.outputs.detail }} run: | diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 8930a636fc08..5f8259b278c8 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -24,6 +24,10 @@ jobs: steps: - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: python-version: "3.11" @@ -35,7 +39,7 @@ jobs: - name: Build skills index env: - GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} run: python scripts/build_skills_index.py - name: Upload index artifact @@ -54,7 +58,10 @@ jobs: runs-on: ubuntu-latest timeout-minutes: 15 steps: + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token - name: Trigger Deploy Site workflow env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }} diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index e8a4508328f8..f4ba220917be 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -60,10 +60,14 @@ jobs: with: fetch-depth: 0 + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + - name: Scan diff for critical patterns id: scan env: - GH_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GH_TOKEN: ${{ steps.app-token.outputs.token }} run: | set -euo pipefail diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index de21ce473a4e..1c56d2978ca1 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -143,9 +143,13 @@ jobs: name: python-package-distributions path: dist/ + - name: Get GitHub App token + id: app-token + uses: ./.github/actions/get-app-token + - name: Wait for GitHub Release to exist env: - GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # release.py creates the GitHub Release after pushing the tag, # but this workflow starts from the tag push — wait for it. run: | @@ -171,7 +175,7 @@ jobs: - name: Attach signed artifacts to GitHub Release if: env.skip_sign != 'true' env: - GITHUB_TOKEN: ${{ secrets.AUTOFIX_BOT_PAT }} + GITHUB_TOKEN: ${{ steps.app-token.outputs.token }} # release.py already created the GitHub Release — just upload # the Sigstore signatures alongside the existing assets. run: >- From 1f76bdc5b2832592dc81a732d6d57ac4967232f3 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 16:50:34 -0400 Subject: [PATCH 44/72] fix(ci): pass App secrets as inputs to composite action MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Composite actions cannot access the secrets context — the runner's template engine rejects secrets.* references at load time with 'Unrecognized named-value: secrets'. Move APP_ID and APP_PRIVATE_KEY from direct secrets.* references inside the composite action to inputs passed by each calling workflow. The fallback logic (GITHUB_TOKEN when APP_ID is empty, for fork PRs) stays in the composite action's check step. --- .github/actions/get-app-token/action.yml | 32 ++++++++++---------- .github/workflows/ci.yml | 6 ++++ .github/workflows/deploy-site.yml | 3 ++ .github/workflows/js-autofix.yml | 3 ++ .github/workflows/skills-index-freshness.yml | 3 ++ .github/workflows/skills-index.yml | 6 ++++ .github/workflows/supply-chain-audit.yml | 3 ++ .github/workflows/upload_to_pypi.yml | 3 ++ 8 files changed, 43 insertions(+), 16 deletions(-) diff --git a/.github/actions/get-app-token/action.yml b/.github/actions/get-app-token/action.yml index d42e46e81364..611533f28337 100644 --- a/.github/actions/get-app-token/action.yml +++ b/.github/actions/get-app-token/action.yml @@ -5,23 +5,24 @@ description: >- 5,000 req/hr per installation (vs 1,000 for the default GITHUB_TOKEN) and are scoped to the App's installation permissions, not a user account. - Falls back to the built-in GITHUB_TOKEN when APP_ID is not set — this - happens on fork PRs where repo secrets are unavailable. The fallback + Falls back to the built-in GITHUB_TOKEN when APP_CLIENT_ID is not set — + this happens on fork PRs where repo secrets are unavailable. The fallback ensures classification, timings, and review comments still work on forks (with the lower GITHUB_TOKEN rate limit). - Requires two repo secrets (store when creating the App): - - APP_ID — the App's numeric ID (Settings → General) - - APP_PRIVATE_KEY — the PEM private key (Settings → Private keys) - - The App must be installed on the repository (or org) with the - permissions the calling workflow needs. + Composite actions cannot access the secrets context directly, so the + calling workflow must pass secrets.APP_CLIENT_ID and secrets.APP_PRIVATE_KEY + as inputs. When both are empty (fork PRs), the fallback fires. inputs: - owner: - description: Repository owner (for cross-org tokens). Defaults to the current repo's owner. + client-id: + description: GitHub App Client ID. Pass secrets.APP_CLIENT_ID from the calling workflow. required: false - default: ${{ github.repository_owner }} + default: '' + private-key: + description: GitHub App private key PEM. Pass secrets.APP_PRIVATE_KEY from the calling workflow. + required: false + default: '' outputs: token: @@ -35,9 +36,9 @@ runs: id: check shell: bash env: - APP_ID: ${{ secrets.APP_ID }} + CLIENT_ID: ${{ inputs.client-id }} run: | - if [ -n "$APP_ID" ]; then + if [ -n "$CLIENT_ID" ]; then echo "has_app=true" >> "$GITHUB_OUTPUT" else echo "has_app=false" >> "$GITHUB_OUTPUT" @@ -48,9 +49,8 @@ runs: if: steps.check.outputs.has_app == 'true' uses: actions/create-github-app-token@bcd2ba49218906704ab6c1aa796996da409d3eb1 # v3.2.0 with: - app-id: ${{ secrets.APP_ID }} - private-key: ${{ secrets.APP_PRIVATE_KEY }} - owner: ${{ inputs.owner }} + client-id: ${{ inputs.client-id }} + private-key: ${{ inputs.private-key }} - name: Fall back to GITHUB_TOKEN id: fallback diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d22dc3381536..6bb8876bcf17 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -52,6 +52,9 @@ jobs: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Detect affected areas id: classify uses: ./.github/actions/detect-changes @@ -324,6 +327,9 @@ jobs: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Restore baseline cache (PR only) if: github.event_name == 'pull_request' diff --git a/.github/workflows/deploy-site.yml b/.github/workflows/deploy-site.yml index f03dbf8ec84e..fd09205a055c 100644 --- a/.github/workflows/deploy-site.yml +++ b/.github/workflows/deploy-site.yml @@ -59,6 +59,9 @@ jobs: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: diff --git a/.github/workflows/js-autofix.yml b/.github/workflows/js-autofix.yml index be8b8b4473a7..8fb0460bc056 100644 --- a/.github/workflows/js-autofix.yml +++ b/.github/workflows/js-autofix.yml @@ -131,6 +131,9 @@ jobs: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Download patch uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 diff --git a/.github/workflows/skills-index-freshness.yml b/.github/workflows/skills-index-freshness.yml index 70fc5c6da28e..4931ccaa0101 100644 --- a/.github/workflows/skills-index-freshness.yml +++ b/.github/workflows/skills-index-freshness.yml @@ -112,6 +112,9 @@ jobs: if: steps.probe.outputs.status != 'ok' id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Open issue on degraded / failed probe if: steps.probe.outputs.status != 'ok' diff --git a/.github/workflows/skills-index.yml b/.github/workflows/skills-index.yml index 5f8259b278c8..ae05c9e70466 100644 --- a/.github/workflows/skills-index.yml +++ b/.github/workflows/skills-index.yml @@ -27,6 +27,9 @@ jobs: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 with: @@ -61,6 +64,9 @@ jobs: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Trigger Deploy Site workflow env: GH_TOKEN: ${{ steps.app-token.outputs.token }} diff --git a/.github/workflows/supply-chain-audit.yml b/.github/workflows/supply-chain-audit.yml index f4ba220917be..1b8a35cb301c 100644 --- a/.github/workflows/supply-chain-audit.yml +++ b/.github/workflows/supply-chain-audit.yml @@ -63,6 +63,9 @@ jobs: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Scan diff for critical patterns id: scan diff --git a/.github/workflows/upload_to_pypi.yml b/.github/workflows/upload_to_pypi.yml index 1c56d2978ca1..e95ef194fa71 100644 --- a/.github/workflows/upload_to_pypi.yml +++ b/.github/workflows/upload_to_pypi.yml @@ -146,6 +146,9 @@ jobs: - name: Get GitHub App token id: app-token uses: ./.github/actions/get-app-token + with: + client-id: ${{ secrets.APP_CLIENT_ID }} + private-key: ${{ secrets.APP_PRIVATE_KEY }} - name: Wait for GitHub Release to exist env: From 5c7993ec606ff4ff3694630262e2f191beddb506 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 17:08:02 -0400 Subject: [PATCH 45/72] fix(ci): add detect to all-checks-pass needs so its failure blocks merge MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit If detect fails, all downstream sub-workflows get SKIPPED (they have needs: detect). all-checks-pass used if: always() and only checked the sub-workflows — which all showed as 'skipped' (= success) — so it passed even though the root cause (detect) failed. This made the PR mergeable despite a broken CI pipeline. Add detect to all-checks-pass needs so its failure propagates to the gate job and blocks the merge. --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bb8876bcf17..9cf15a1a6e02 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -259,6 +259,7 @@ jobs: all-checks-pass: name: All required checks pass needs: + - detect - tests - lint - js-tests From d57947b493504ec4696849f78d4b01e75a25a74c Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 17:27:34 -0400 Subject: [PATCH 46/72] fix(desktop): bump skills test timeout to fix cold-start flake (#68235) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Test 1 in skills/index.test.tsx pays the full cold-start cost (jsdom env init + module transform + the @/hermes/@/store/profile import graph), which pushed past vitest's 5000ms default under load — caught at 8871ms on one run, 6.6s pure test time on another. Tests 2-4 are ~30-130ms each because all that setup is already cached, so only test 1 was at risk of timing out. Bump the describe-level timeout to 15s. Verified with 10 consecutive runs, 4 of which took 5.5-6.6s of test time and would have hard-failed under the old 5s default. --- apps/desktop/vitest.config.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/apps/desktop/vitest.config.ts b/apps/desktop/vitest.config.ts index de633561862a..dd2f38ff3193 100644 --- a/apps/desktop/vitest.config.ts +++ b/apps/desktop/vitest.config.ts @@ -8,7 +8,11 @@ const reactUi: TestProjectConfiguration = { environment: 'jsdom', setupFiles: ['./vitest.setup.ts'], include: ['src/**/*.test.{ts,tsx}'], - globals: true + globals: true, + // The first test in each file pays jsdom env init + full module transform, + // which can exceed vitest's 5000ms default under CI/load. 15s gives the + // cold start headroom without masking genuinely hung tests. + testTimeout: 15_000 } } From b586e4eff20de21df6a3aa1209d7d3b089df6bbc Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:02:44 -0500 Subject: [PATCH 47/72] feat(desktop): open multiple full app windows (electron) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add createInstanceWindow() — a full-chrome peer of the primary that renders the complete app (sidebar, routing, its own draft) against the shared backend, so several GUI windows can run at once. Mirrors the primary's window options + chatWindowWebPreferences (backgroundThrottling stays off so a streamed answer never stalls when blurred) but never overwrites the mainWindow global and doesn't respawn the backend — the renderer's getConnection() joins the running one. New windows cascade off their source via the pure, tested instanceWindowBounds(). Exposed via the hermes:window:openInstance IPC and a "New Window" File menu item. Per-window fullscreen state now targets the window itself, and titlebar/native-theme repaints reach every open chat window instead of only the primary. Retires the now-orphaned compact new-session pop-out (its only caller was ⌘⇧N, repointed in the follow-up commit): drops createNewSessionWindow, the hermes:window:openNewSession handler, and the newSession/new=1 URL flag. --- apps/desktop/electron/main.ts | 127 +++++++++++++++--- apps/desktop/electron/session-windows.test.ts | 19 ++- apps/desktop/electron/session-windows.ts | 36 ++++- 3 files changed, 149 insertions(+), 33 deletions(-) diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index b67e8b69f3a7..09f8e0131e44 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -112,6 +112,7 @@ import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, + instanceWindowBounds, SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH } from './session-windows' @@ -4613,9 +4614,9 @@ function getNativeOverlayWidth() { return computeNativeOverlayWidth({ isWindows: IS_WINDOWS, isWsl: IS_WSL, isMac: IS_MAC }) } -function getWindowState() { +function getWindowState(win = mainWindow) { return { - isFullscreen: Boolean(mainWindow?.isFullScreen?.()), + isFullscreen: Boolean(win?.isFullScreen?.()), nativeOverlayWidth: getNativeOverlayWidth(), windowButtonPosition: getWindowButtonPosition() } @@ -4716,18 +4717,21 @@ function sendOpenUpdatesRequested() { mainWindow.focus() } -function sendWindowStateChanged(nextIsFullscreen?: boolean) { - if (!mainWindow || mainWindow.isDestroyed()) { +// Push titlebar/fullscreen chrome state to a window's renderer. Defaults to the +// primary, but any full chat window (primary or a secondary "instance" peer) +// passes itself so its own fullscreen toggle drives its own traffic-light inset. +function sendWindowStateChanged(nextIsFullscreen?: boolean, target = mainWindow) { + if (!target || target.isDestroyed()) { return } - const { webContents } = mainWindow + const { webContents } = target if (!webContents || webContents.isDestroyed()) { return } - const state = getWindowState() + const state = getWindowState(target) if (typeof nextIsFullscreen === 'boolean') { state.isFullscreen = nextIsFullscreen @@ -4765,6 +4769,11 @@ function buildApplicationMenu() { template.push({ label: 'File', submenu: [ + // No accelerator: ⌘⇧N is a rebindable renderer keybind (session.newWindow); + // a menu accelerator would fight the rebind panel and (on macOS) be + // swallowed before the renderer sees it. Here purely for discoverability. + { click: () => createInstanceWindow(), label: 'New Window' }, + { type: 'separator' }, IS_MAC ? { // NO accelerator: on macOS a registered ⌘W is consumed by the OS @@ -7329,11 +7338,7 @@ function focusWindow(win) { win.focus() } -function spawnSecondaryWindow({ - sessionId, - watch, - newSession -}: { sessionId?: string; watch?: boolean; newSession?: boolean } = {}) { +function spawnSecondaryWindow({ sessionId, watch }: { sessionId?: string; watch?: boolean } = {}) { const icon = getAppIconPath() const win = new BrowserWindow({ @@ -7378,8 +7383,7 @@ function spawnSecondaryWindow({ buildSessionWindowUrl(sessionId, { devServer: DEV_SERVER, rendererIndexPath: DEV_SERVER ? undefined : resolveRendererIndex(), - watch, - newSession + watch }) ) @@ -7391,11 +7395,82 @@ function createSessionWindow(sessionId, { watch = false } = {}) { return sessionWindows.openOrFocus(sessionId, () => spawnSecondaryWindow({ sessionId, watch })) } -// Open a fresh compact window on the new-session draft (#/). Not registry-keyed: -// like ⌘N in a browser, every press opens a new window — and a draft window that -// later converts to a real session must not get refocused as if it were blank. -function createNewSessionWindow() { - return spawnSecondaryWindow({ newSession: true }) +// Additional full "instance" windows — peers of the primary that render the +// COMPLETE app (sidebar, routing, its own draft) against the shared backend, so +// a user can run multiple GUI windows at once (⌘⇧N / the "New Window" palette +// command). Unlike the compact session windows they carry no `?win` flag. The +// primary mainWindow stays the notification / deep-link / pet-overlay anchor and +// is NOT tracked here. The set holds a strong reference so an open peer isn't +// garbage-collected, and drops it on close. +const instanceWindows = new Set() + +// Cascade a new instance off whichever window spawned it so it doesn't land +// exactly on top of its source. Falls back to the persisted primary geometry +// when there's no live source window (e.g. all windows closed on macOS). The +// pure cascade math lives in session-windows.ts (instanceWindowBounds). +function nextInstanceBounds() { + const source = BrowserWindow.getFocusedWindow() || mainWindow + const fallback = computeWindowOptions(readWindowState(), screen.getAllDisplays()) + const base = source && !source.isDestroyed() ? source.getBounds() : null + + return instanceWindowBounds(base, fallback) +} + +// Open a new full-chrome instance window. Mirrors createWindow()'s window +// options (shared chatWindowWebPreferences keeps backgroundThrottling:false so a +// streamed answer never stalls in the background) but is a peer, not the +// primary: it never overwrites the mainWindow global, doesn't start the backend +// (the renderer's getConnection() joins the already-running one), and loads the +// plain renderer URL so the full app renders. +function createInstanceWindow() { + const icon = getAppIconPath() + + const win = new BrowserWindow({ + ...nextInstanceBounds(), + minWidth: WINDOW_MIN_WIDTH, + minHeight: WINDOW_MIN_HEIGHT, + title: 'Hermes', + titleBarStyle: 'hidden', + titleBarOverlay: getTitleBarOverlayOptions(), + trafficLightPosition: IS_MAC ? WINDOW_BUTTON_POSITION : undefined, + vibrancy: IS_MAC ? 'sidebar' : undefined, + opacity: windowOpacity(), + icon, + show: false, + backgroundColor: getWindowBackgroundColor(), + webPreferences: chatWindowWebPreferences(PRELOAD_PATH) + }) + + instanceWindows.add(win) + + if (IS_MAC) { + win.setWindowButtonPosition?.(WINDOW_BUTTON_POSITION) + } + + win.once('ready-to-show', () => { + if (!win.isDestroyed()) { + win.show() + } + }) + + // Per-window fullscreen chrome: send this window its own titlebar inset so its + // traffic lights hide/show independently of the primary. + win.on('enter-full-screen', () => sendWindowStateChanged(true, win)) + win.on('leave-full-screen', () => sendWindowStateChanged(false, win)) + + wireCommonWindowHandlers(win, zoomWiringForWindowKind('chat')) + + win.on('closed', () => { + instanceWindows.delete(win) + }) + + if (DEV_SERVER) { + win.loadURL(DEV_SERVER) + } else { + win.loadURL(pathToFileURL(resolveRendererIndex()).toString()) + } + + return win } // The pet overlay: a single transparent, frameless, always-on-top window that @@ -7583,7 +7658,9 @@ function createWindow() { if (!nativeThemeListenerInstalled) { nativeThemeListenerInstalled = true nativeTheme.on('updated', () => { - applyTitleBarOverlay(mainWindow) + for (const win of BrowserWindow.getAllWindows()) { + applyTitleBarOverlay(win) + } }) } } @@ -7824,8 +7901,8 @@ ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { return { ok: true } }) -ipcMain.handle('hermes:window:openNewSession', async () => { - createNewSessionWindow() +ipcMain.handle('hermes:window:openInstance', async () => { + createInstanceWindow() return { ok: true } }) @@ -8613,7 +8690,13 @@ ipcMain.on('hermes:titlebar-theme', (_event, payload) => { background: payload.background, foreground: payload.foreground } - applyTitleBarOverlay(mainWindow) + + // Repaint the native (Windows/Linux) titlebar overlay on every open chat + // window, not just the primary — instance peers and session windows share the + // one app theme. applyTitleBarOverlay no-ops on the frameless pet overlay. + for (const win of BrowserWindow.getAllWindows()) { + applyTitleBarOverlay(win) + } }) // Pin the native appearance to the app theme (see NATIVE_THEME_CONFIG_PATH). diff --git a/apps/desktop/electron/session-windows.test.ts b/apps/desktop/electron/session-windows.test.ts index fcfca8680730..5167593bfbf8 100644 --- a/apps/desktop/electron/session-windows.test.ts +++ b/apps/desktop/electron/session-windows.test.ts @@ -2,7 +2,12 @@ import assert from 'node:assert/strict' import { test } from 'vitest' -import { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry } from './session-windows' +import { + buildSessionWindowUrl, + chatWindowWebPreferences, + createSessionWindowRegistry, + instanceWindowBounds +} from './session-windows' // A minimal fake BrowserWindow: tracks listeners + destroyed state and lets a // test fire the 'closed' event, mirroring the slice of the Electron API the @@ -83,10 +88,16 @@ test('buildSessionWindowUrl adds the watch flag for spectator windows, before th assert.equal(url, 'http://localhost:5173/?win=secondary&watch=1#/abc') }) -test('buildSessionWindowUrl routes new-session windows to the draft (#/)', () => { - const url = buildSessionWindowUrl(null, { devServer: 'http://localhost:5173', newSession: true }) +test('instanceWindowBounds cascades a new window off its source bounds', () => { + const bounds = instanceWindowBounds({ x: 100, y: 120, width: 1400, height: 900 }, { width: 1, height: 1 }) - assert.equal(url, 'http://localhost:5173/?win=secondary&new=1#/') + assert.deepEqual(bounds, { width: 1400, height: 900, x: 132, y: 152 }) +}) + +test('instanceWindowBounds falls back to the persisted geometry with no source window', () => { + const fallback = { width: 1280, height: 800 } + + assert.equal(instanceWindowBounds(null, fallback), fallback) }) test('registry opens one window per session and focuses on re-open', () => { diff --git a/apps/desktop/electron/session-windows.ts b/apps/desktop/electron/session-windows.ts index af55608b0f4e..48597ee9e0ea 100644 --- a/apps/desktop/electron/session-windows.ts +++ b/apps/desktop/electron/session-windows.ts @@ -38,13 +38,12 @@ function chatWindowWebPreferences(preloadPath: string) { // flag MUST sit in the query string BEFORE the '#': anything after the '#' is // treated as the route by HashRouter and would break routeSessionId(). The // renderer reads the flag from window.location.search to suppress the install / -// onboarding overlays and the global session sidebar. `new=1` marks the compact -// scratch window; `watch=1` marks a spectator window (e.g. a running subagent's -// session): the renderer resumes it lazily so the gateway never builds an agent -// just to stream into it. -function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch, newSession }: any = {}) { - const query = `?win=secondary${newSession ? '&new=1' : ''}${watch ? '&watch=1' : ''}` - const route = newSession ? '#/' : `#/${encodeURIComponent(sessionId)}` +// onboarding overlays and the global session sidebar. `watch=1` marks a +// spectator window (e.g. a running subagent's session): the renderer resumes it +// lazily so the gateway never builds an agent just to stream into it. +function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath, watch }: any = {}) { + const query = `?win=secondary${watch ? '&watch=1' : ''}` + const route = `#/${encodeURIComponent(sessionId)}` if (devServer) { const base = devServer.endsWith('/') ? devServer.slice(0, -1) : devServer @@ -55,6 +54,28 @@ function buildSessionWindowUrl(sessionId: string, { devServer, rendererIndexPath return `${pathToFileURL(rendererIndexPath).toString()}${query}${route}` } +// Full "instance" windows (⌘⇧N / the "New Window" command) open a complete app +// peer, not a compact chat. Cascade each one off its source window's bounds so a +// new window doesn't land exactly on top of the one it was spawned from. Pure so +// it's unit-testable; the Electron glue (reading the focused window's bounds, +// constructing the BrowserWindow) stays in main.ts. `base` is the source +// window's current bounds, or null when there's no live source window — then the +// persisted primary geometry (`fallback`) is used as-is. +const INSTANCE_CASCADE_OFFSET = 32 + +function instanceWindowBounds(base: { x: number; y: number; width: number; height: number } | null, fallback: any) { + if (!base) { + return fallback + } + + return { + width: base.width, + height: base.height, + x: base.x + INSTANCE_CASCADE_OFFSET, + y: base.y + INSTANCE_CASCADE_OFFSET + } +} + // A small registry keyed by sessionId that guarantees one window per chat: // opening a session that already has a live window focuses it instead of // spawning a duplicate, and a window removes itself from the registry when it @@ -119,6 +140,7 @@ export { buildSessionWindowUrl, chatWindowWebPreferences, createSessionWindowRegistry, + instanceWindowBounds, SESSION_WINDOW_MIN_HEIGHT, SESSION_WINDOW_MIN_WIDTH } From a90ca7fe34b28d38eaef16672aeeef35ec3dde01 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:02:48 -0500 Subject: [PATCH 48/72] =?UTF-8?q?feat(desktop):=20wire=20New=20Window=20to?= =?UTF-8?q?=20=E2=8C=98=E2=87=A7N=20+=20command=20palette?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Repoint session.newWindow (⌘⇧N) from the compact new-session pop-out to openNewWindow(), which opens a full peer instance via the new openWindow bridge, and add a "New Window" entry to the ⌘K palette (shown with its hotkey hint, gated on canOpenNewWindow()). Relabel the action "New window". Drops the retired openNewSessionWindow bridge and the vestigial isNewSessionWindow()/new=1 flag; renames the shared opener helper. --- apps/desktop/electron/preload.ts | 2 +- .../desktop/src/app/command-palette/index.tsx | 14 ++++++ apps/desktop/src/app/hooks/use-keybinds.ts | 4 +- apps/desktop/src/global.d.ts | 6 ++- apps/desktop/src/i18n/en.ts | 2 +- apps/desktop/src/i18n/zh.ts | 2 +- apps/desktop/src/lib/icons.ts | 2 + apps/desktop/src/store/windows.test.ts | 43 ++++++++++++------ apps/desktop/src/store/windows.ts | 44 +++++++------------ 9 files changed, 71 insertions(+), 48 deletions(-) diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 732d13a53668..311c18637dc0 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -6,7 +6,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { touchBackend: profile => ipcRenderer.invoke('hermes:backend:touch', profile), getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), - openNewSessionWindow: () => ipcRenderer.invoke('hermes:window:openNewSession'), + openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'), petOverlay: { // Main renderer → main process: window lifecycle + drag. `request` is // `{ bounds, screen }`; resolves with the screen bounds it actually used. diff --git a/apps/desktop/src/app/command-palette/index.tsx b/apps/desktop/src/app/command-palette/index.tsx index 05a4d804cb8c..e0a669bb300d 100644 --- a/apps/desktop/src/app/command-palette/index.tsx +++ b/apps/desktop/src/app/command-palette/index.tsx @@ -13,6 +13,7 @@ import { useI18n } from '@/i18n' import { sessionTitle } from '@/lib/chat-runtime' import { Activity, + AppWindow, Archive, BarChart3, ChevronLeft, @@ -59,6 +60,7 @@ import { openPetGenerate } from '@/store/pet-generate' import { requestStartWorkSession } from '@/store/projects' import { runGatewayRestart } from '@/store/system-actions' import { applyBackendUpdate } from '@/store/updates' +import { canOpenNewWindow, openNewWindow } from '@/store/windows' import { luminance } from '@/themes/color' import { type ThemeMode, useTheme } from '@/themes/context' import { isUserTheme, resolveTheme } from '@/themes/user-themes' @@ -413,6 +415,18 @@ export function CommandPalette() { label: cc.nav.newChat.title, run: go(NEW_CHAT_ROUTE) }, + ...(canOpenNewWindow() + ? [ + { + action: 'session.newWindow', + icon: AppWindow, + id: 'nav-new-window', + keywords: ['window', 'instance', 'open', 'new'], + label: t.keybinds.actions['session.newWindow'], + run: () => void openNewWindow() + } + ] + : []), { action: 'view.showTerminal', icon: Terminal, diff --git a/apps/desktop/src/app/hooks/use-keybinds.ts b/apps/desktop/src/app/hooks/use-keybinds.ts index 34a6bfa1e1c6..4df1edb202c4 100644 --- a/apps/desktop/src/app/hooks/use-keybinds.ts +++ b/apps/desktop/src/app/hooks/use-keybinds.ts @@ -40,7 +40,7 @@ import { switcherActive, switcherJustClosed } from '@/store/session-switcher' -import { openNewSessionInNewWindow } from '@/store/windows' +import { openNewWindow } from '@/store/windows' import { useTheme } from '@/themes/context' import { requestComposerFocus, requestVoiceToggle } from '../chat/composer/focus' @@ -145,7 +145,7 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void { window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut')) }, 'session.newTab': () => deps.openNewSessionTab(), - 'session.newWindow': () => void openNewSessionInNewWindow(), + 'session.newWindow': () => void openNewWindow(), // ⌃Tab cycles the focused session/main tab strip; only a non-tabbed focus // falls through to the recent-session switcher. 'session.next': () => void (cycleTreeTabInFocusedZone(1) || stepSession(1)), diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index 9202c28e7fc6..ee40c31cc17a 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -31,8 +31,10 @@ declare global { // a spectator window (lazy resume — no agent build) for live-streaming // a running subagent's session. openSessionWindow: (sessionId: string, opts?: { watch?: boolean }) => Promise<{ ok: boolean; error?: string }> - // Open (or focus) a compact secondary window on the new-session draft. - openNewSessionWindow: () => Promise<{ ok: boolean; error?: string }> + // Open a new full-chrome app window — a peer instance of the primary that + // renders the complete app against the shared backend, so the user can run + // multiple GUI windows at once. + openWindow: () => Promise<{ ok: boolean; error?: string }> // The pop-out pet overlay: a transparent always-on-top window hosting only // the mascot. The main renderer drives it (open/close/drag + state push); // the overlay sends control messages back (pop-in, composer submit). diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts index a17ecbb3bd60..049d5da5e74c 100644 --- a/apps/desktop/src/i18n/en.ts +++ b/apps/desktop/src/i18n/en.ts @@ -225,7 +225,7 @@ export const en: Translations = { 'nav.agents': 'Open agents', 'session.new': 'New session', 'session.newTab': 'New session tab', - 'session.newWindow': 'New session in window', + 'session.newWindow': 'New window', 'session.next': 'Next session', 'session.prev': 'Previous session', 'session.slot.1': 'Switch to recent session 1', diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts index 61d6ff602aab..5e165f6be0a5 100644 --- a/apps/desktop/src/i18n/zh.ts +++ b/apps/desktop/src/i18n/zh.ts @@ -220,7 +220,7 @@ export const zh: Translations = { 'nav.agents': '打开智能体', 'session.new': '新建会话', 'session.newTab': '新建会话标签', - 'session.newWindow': '在新窗口中新建会话', + 'session.newWindow': '新建窗口', 'session.next': '下一个会话', 'session.prev': '上一个会话', 'session.slot.1': '切换到最近会话 1', diff --git a/apps/desktop/src/lib/icons.ts b/apps/desktop/src/lib/icons.ts index 0260ce82c6c7..fae9e8d76c09 100644 --- a/apps/desktop/src/lib/icons.ts +++ b/apps/desktop/src/lib/icons.ts @@ -2,6 +2,7 @@ import { IconActivity as Activity, IconAlertCircle as AlertCircle, IconAlertTriangle as AlertTriangle, + IconAppWindow as AppWindow, IconArchive as Archive, IconArchiveOff as ArchiveOff, IconArrowUp as ArrowUp, @@ -120,6 +121,7 @@ export { Activity, AlertCircle, AlertTriangle, + AppWindow, Archive, ArchiveOff, ArrowUp, diff --git a/apps/desktop/src/store/windows.test.ts b/apps/desktop/src/store/windows.test.ts index 28ae3cc39c9f..e6c3f4974a56 100644 --- a/apps/desktop/src/store/windows.test.ts +++ b/apps/desktop/src/store/windows.test.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { canOpenSessionWindow, openNewSessionInNewWindow, openSessionInNewWindow } from './windows' +import { canOpenNewWindow, canOpenSessionWindow, openNewWindow, openSessionInNewWindow } from './windows' const desktopWindow = window as unknown as { hermesDesktop?: Window['hermesDesktop'] } const initialHermesDesktop = desktopWindow.hermesDesktop @@ -13,11 +13,11 @@ vi.mock('./notifications', () => ({ function installBridge( openSessionWindow?: Window['hermesDesktop']['openSessionWindow'], - openNewSessionWindow?: Window['hermesDesktop']['openNewSessionWindow'] + openWindow?: Window['hermesDesktop']['openWindow'] ) { desktopWindow.hermesDesktop = { ...(openSessionWindow ? { openSessionWindow } : {}), - ...(openNewSessionWindow ? { openNewSessionWindow } : {}) + ...(openWindow ? { openWindow } : {}) } as unknown as Window['hermesDesktop'] } @@ -106,37 +106,54 @@ describe('openSessionInNewWindow', () => { }) }) -describe('openNewSessionInNewWindow', () => { +describe('canOpenNewWindow', () => { + it('is false when the desktop bridge is absent', () => { + delete desktopWindow.hermesDesktop + expect(canOpenNewWindow()).toBe(false) + }) + + it('is false when the bridge lacks openWindow', () => { + installBridge(vi.fn().mockResolvedValue({ ok: true })) + expect(canOpenNewWindow()).toBe(false) + }) + + it('is true when the bridge exposes openWindow', () => { + installBridge(undefined, vi.fn().mockResolvedValue({ ok: true })) + expect(canOpenNewWindow()).toBe(true) + }) +}) + +describe('openNewWindow', () => { it('no-ops gracefully when the bridge is absent (web fallback)', async () => { delete desktopWindow.hermesDesktop - await openNewSessionInNewWindow() + await openNewWindow() expect(notifyError).not.toHaveBeenCalled() }) - it('no-ops when openNewSessionWindow is missing', async () => { + it('no-ops when openWindow is missing', async () => { installBridge(vi.fn().mockResolvedValue({ ok: true })) - await openNewSessionInNewWindow() + await openNewWindow() expect(notifyError).not.toHaveBeenCalled() }) it('invokes the bridge', async () => { - const openNew = vi.fn().mockResolvedValue({ ok: true }) - installBridge(vi.fn().mockResolvedValue({ ok: true }), openNew) + const openWindow = vi.fn().mockResolvedValue({ ok: true }) + installBridge(undefined, openWindow) - await openNewSessionInNewWindow() + await openNewWindow() - expect(openNew).toHaveBeenCalledTimes(1) + expect(openWindow).toHaveBeenCalledTimes(1) expect(notifyError).not.toHaveBeenCalled() }) it('notifies on an ok:false result', async () => { - installBridge(vi.fn().mockResolvedValue({ ok: true }), vi.fn().mockResolvedValue({ ok: false, error: 'nope' })) + installBridge(undefined, vi.fn().mockResolvedValue({ ok: false, error: 'nope' })) - await openNewSessionInNewWindow() + await openNewWindow() expect(notifyError).toHaveBeenCalledTimes(1) }) diff --git a/apps/desktop/src/store/windows.ts b/apps/desktop/src/store/windows.ts index a881aa4794c6..349b29d12ba0 100644 --- a/apps/desktop/src/store/windows.ts +++ b/apps/desktop/src/store/windows.ts @@ -6,7 +6,6 @@ import { notifyError } from './notifications' // never from the router. A "secondary" window renders a single chat without the // global session sidebar or the install / onboarding overlays. const SECONDARY_WINDOW_FLAG = 'secondary' -const NEW_SESSION_WINDOW_FLAG = '1' let secondaryWindowCache: boolean | null = null @@ -28,26 +27,6 @@ export function isSecondaryWindow(): boolean { return result } -let newSessionWindowCache: boolean | null = null - -export function isNewSessionWindow(): boolean { - if (newSessionWindowCache !== null) { - return newSessionWindowCache - } - - let result = false - - try { - result = new URLSearchParams(window.location.search).get('new') === NEW_SESSION_WINDOW_FLAG - } catch { - result = false - } - - newSessionWindowCache = result - - return result -} - let watchWindowCache: boolean | null = null // A "watch" window spectates a session that is being driven elsewhere (a @@ -78,11 +57,16 @@ export function canOpenSessionWindow(): boolean { return typeof window !== 'undefined' && typeof window.hermesDesktop?.openSessionWindow === 'function' } +// True when the shell can open a full peer app window (⌘⇧N / "New Window"). +export function canOpenNewWindow(): boolean { + return typeof window !== 'undefined' && typeof window.hermesDesktop?.openWindow === 'function' +} + type WindowOpenResult = { ok: boolean; error?: string } | undefined // Run a window-open bridge call, surfacing any failure as a toast. Shared by the -// session pop-out and the new-session pop-out. -async function openWindow(call: () => Promise, failMessage: string): Promise { +// session pop-out and the new-window opener. +async function runWindowOpen(call: () => Promise, failMessage: string): Promise { try { const result = await call() @@ -102,14 +86,18 @@ export async function openSessionInNewWindow(sessionId: string, opts?: { watch?: return } - await openWindow(() => window.hermesDesktop.openSessionWindow(sessionId, opts), 'Could not open chat in a new window') + await runWindowOpen( + () => window.hermesDesktop.openSessionWindow(sessionId, opts), + 'Could not open chat in a new window' + ) } -// Open a fresh compact window on the new-session draft. -export async function openNewSessionInNewWindow(): Promise { - if (!canOpenSessionWindow() || typeof window.hermesDesktop.openNewSessionWindow !== 'function') { +// Open a new full-chrome app window — a peer instance of the primary that +// renders the complete app against the shared backend. No-ops outside Electron. +export async function openNewWindow(): Promise { + if (!canOpenNewWindow()) { return } - await openWindow(() => window.hermesDesktop.openNewSessionWindow(), 'Could not open new session window') + await runWindowOpen(() => window.hermesDesktop.openWindow(), 'Could not open a new window') } From fb0c6d9ee15a4f9e079ba49ef58b860fc7098a60 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:33:14 -0500 Subject: [PATCH 49/72] fix(desktop): de-dupe cross-window cues so peers don't spam With multiple full windows, each renderer independently reacts to the same backend event, so one-shot cues fired N times: OS notifications (the per-renderer throttle can't see other windows), the turn-end sound (playCompletionSound runs on every message.complete, ungated by focus), and auto-spoken replies (double voice when a chat is open in two windows). Add a single race-free owner in the main process (electron/event-dedupe.ts): main handles IPC serially, so the first window to claim a key within a short window wins and peers stay quiet. Notifications collapse at the hermes:notify choke point; the sound and spoken replies claim via a new hermes:ambient:claim IPC (keyed by session / reply id). Off Electron the claim falls back to "emit", preserving single-window behavior. The sound's mute check runs before the claim so a muted window can't win the cue and silence an audible peer. --- apps/desktop/electron/event-dedupe.test.ts | 37 +++++++++++++++++++ apps/desktop/electron/event-dedupe.ts | 35 ++++++++++++++++++ apps/desktop/electron/main.ts | 18 +++++++++ apps/desktop/electron/preload.ts | 1 + .../composer/hooks/use-auto-speak-replies.ts | 14 +++++-- .../hooks/use-message-stream/gateway-event.ts | 3 +- apps/desktop/src/global.d.ts | 4 ++ apps/desktop/src/lib/completion-sound.ts | 20 ++++++++-- apps/desktop/src/store/ambient.ts | 18 +++++++++ 9 files changed, 143 insertions(+), 7 deletions(-) create mode 100644 apps/desktop/electron/event-dedupe.test.ts create mode 100644 apps/desktop/electron/event-dedupe.ts create mode 100644 apps/desktop/src/store/ambient.ts diff --git a/apps/desktop/electron/event-dedupe.test.ts b/apps/desktop/electron/event-dedupe.test.ts new file mode 100644 index 000000000000..3c0f905587e3 --- /dev/null +++ b/apps/desktop/electron/event-dedupe.test.ts @@ -0,0 +1,37 @@ +import assert from 'node:assert/strict' + +import { test } from 'vitest' + +import { createEventDeduper } from './event-dedupe' + +test('collapses the same key inside the window (two windows, one event)', () => { + const isDup = createEventDeduper(1000) + + assert.equal(isDup('input:s1', 0), false, 'first window claims') + assert.equal(isDup('input:s1', 5), true, 'second window is deduped') +}) + +test('distinct keys are independent', () => { + const isDup = createEventDeduper(1000) + + assert.equal(isDup('input:s1', 0), false) + assert.equal(isDup('approval:s1', 0), false, 'different kind') + assert.equal(isDup('input:s2', 0), false, 'different session') +}) + +test('re-fires once the window elapses', () => { + const isDup = createEventDeduper(1000) + + assert.equal(isDup('turnDone:s1', 0), false) + assert.equal(isDup('turnDone:s1', 999), true, 'still within window') + assert.equal(isDup('turnDone:s1', 1000), false, 'window elapsed → fires again') +}) + +test('prunes stale keys so the map cannot grow unbounded', () => { + const isDup = createEventDeduper(1000) + + for (let i = 0; i < 100; i += 1) { + // Each far-apart key is pruned before the next, so none linger as duplicates. + assert.equal(isDup(`turnDone:s${i}`, i * 2000), false) + } +}) diff --git a/apps/desktop/electron/event-dedupe.ts b/apps/desktop/electron/event-dedupe.ts new file mode 100644 index 000000000000..ec14d67e851b --- /dev/null +++ b/apps/desktop/electron/event-dedupe.ts @@ -0,0 +1,35 @@ +// Cross-window de-dupe for one-shot side-effects (OS notifications, the turn-end +// sound, spoken replies). Every desktop window is its own renderer process, so N +// open windows each independently react to the same backend event. The main +// process is the one place they all share and it handles IPC serially, so it's +// the race-free owner: the first window to claim a key within the window wins; +// peers see it's taken and stay quiet. +// +// Pure + injectable clock so it's unit-testable without Electron. + +const DEDUPE_WINDOW_MS = 1000 + +// Returns true when `key` was already claimed within the window (caller drops +// this one). Self-evicting: stale keys are pruned on every call, so the map +// can't grow unbounded. +function createEventDeduper(windowMs = DEDUPE_WINDOW_MS) { + const lastSeenAt = new Map() + + return function isDuplicate(key: string, now = Date.now()): boolean { + for (const [k, at] of lastSeenAt) { + if (now - at >= windowMs) { + lastSeenAt.delete(k) + } + } + + if (lastSeenAt.has(key)) { + return true + } + + lastSeenAt.set(key, now) + + return false + } +} + +export { createEventDeduper, DEDUPE_WINDOW_MS } diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 09f8e0131e44..141497cf6b4d 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -67,6 +67,7 @@ import { uninstallArgsForMode } from './desktop-uninstall' import { installEmbedReferer } from './embed-referer' +import { createEventDeduper } from './event-dedupe' import { readDirForIpc } from './fs-read-dir' import { probeGatewayWebSocket } from './gateway-ws-probe' import { scanGitRepos } from './git-repo-scan' @@ -8516,11 +8517,28 @@ ipcMain.handle('hermes:api', async (_event, request) => { }) }) +// One deduper per cross-window cue — the choke point every window shares. Main +// handles IPC serially, so the first window to claim a key wins with no race. +const isDuplicateNotification = createEventDeduper() +const claimedAmbientCue = createEventDeduper() + +// A window asks "do I own this ambient cue (turn-end sound / spoken reply)?". +// The first caller within the window gets true; peers get false and stay quiet. +ipcMain.handle('hermes:ambient:claim', (_event, key) => !claimedAmbientCue(String(key ?? ''))) + ipcMain.handle('hermes:notify', (_event, payload) => { if (!Notification.isSupported()) { return false } + // Multiple full windows each run their own renderer throttle, so the same + // kind+session can arrive here twice. Collapse it at this single choke point. + // Return true (not false): a notification for the event IS being shown by the + // first caller, so the settings "send test" success probe stays honest. + if (isDuplicateNotification(`${payload?.kind ?? ''}:${payload?.sessionId ?? ''}`)) { + return true + } + // Action buttons render only on signed macOS builds; elsewhere they're dropped // and the body click still works. const actions = Array.isArray(payload?.actions) ? payload.actions : [] diff --git a/apps/desktop/electron/preload.ts b/apps/desktop/electron/preload.ts index 311c18637dc0..37f068ca986f 100644 --- a/apps/desktop/electron/preload.ts +++ b/apps/desktop/electron/preload.ts @@ -7,6 +7,7 @@ contextBridge.exposeInMainWorld('hermesDesktop', { getGatewayWsUrl: profile => ipcRenderer.invoke('hermes:gateway:ws-url', profile), openSessionWindow: (sessionId, opts) => ipcRenderer.invoke('hermes:window:openSession', sessionId, opts), openWindow: () => ipcRenderer.invoke('hermes:window:openInstance'), + claimAmbientCue: key => ipcRenderer.invoke('hermes:ambient:claim', key), petOverlay: { // Main renderer → main process: window lifecycle + drag. `request` is // `{ bounds, screen }`; resolves with the screen bounds it actually used. diff --git a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts index c3268bc9cbd3..949b8f1020c2 100644 --- a/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts +++ b/apps/desktop/src/app/chat/composer/hooks/use-auto-speak-replies.ts @@ -2,6 +2,7 @@ import { useStore } from '@nanostores/react' import { useEffect, useRef } from 'react' import { playSpeechText } from '@/lib/voice-playback' +import { ownsAmbientCue } from '@/store/ambient' import { notifyError } from '@/store/notifications' import { $messages } from '@/store/session' import { $voicePlayback } from '@/store/voice-playback' @@ -65,9 +66,16 @@ export function useAutoSpeakReplies({ } markSpoken() - void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error => - notifyError(error, failureLabel) - ) + // Only one window voices a given reply when the same chat is open in + // several (reply.id is the shared backend message id). markSpoken already + // ran in every window, so peers just stay quiet. + void ownsAmbientCue(`speak:${reply.id}`).then(owns => { + if (owns) { + void playSpeechText(reply.text, { messageId: reply.id, source: 'read-aloud' }).catch(error => + notifyError(error, failureLabel) + ) + } + }) } // Re-check on a reply completing ($messages) and on the prior clip ending diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts index 09ba6bdb52ad..9d2ded2e5f6c 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts @@ -483,7 +483,8 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) { flushQueuedDeltas(sessionId) - playCompletionSound() + // Keyed by session so only one window beeps when several are open. + playCompletionSound(sessionId) const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered) completeAssistantMessage(sessionId, finalText, payload?.response_previewed) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index ee40c31cc17a..fe2a3eaa6dc0 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -35,6 +35,10 @@ declare global { // renders the complete app against the shared backend, so the user can run // multiple GUI windows at once. openWindow: () => Promise<{ ok: boolean; error?: string }> + // Claim a one-shot cross-window ambient cue (turn-end sound / spoken + // reply). Resolves true for the first window to claim a key, false for + // peers — so N open windows don't all fire the same cue. + claimAmbientCue: (key: string) => Promise // The pop-out pet overlay: a transparent always-on-top window hosting only // the mascot. The main renderer drives it (open/close/drag + state push); // the overlay sends control messages back (pop-in, composer submit). diff --git a/apps/desktop/src/lib/completion-sound.ts b/apps/desktop/src/lib/completion-sound.ts index 4457d912b78d..f1faa150bf0f 100644 --- a/apps/desktop/src/lib/completion-sound.ts +++ b/apps/desktop/src/lib/completion-sound.ts @@ -1,6 +1,7 @@ // Completion sound bank for agent turn-end cues. // Fourteen curated presets for A/B in Settings → Appearance. Default is variant 1. +import { ownsAmbientCue } from '@/store/ambient' import { $completionSoundVariantId, resolveCompletionSoundVariantId } from '@/store/completion-sound' import { $hapticsMuted } from '@/store/haptics' @@ -452,13 +453,26 @@ export function previewCompletionSound(variantId?: number) { playVariant(resolveCompletionSoundVariantId(variantId ?? $completionSoundVariantId.get())) } -// Plays the selected completion cue on any `message.complete`. -export function playCompletionSound() { +// Plays the selected completion cue on any `message.complete`. Pass a dedupeKey +// (the session id) so only one window beeps when several are open — the mute +// check runs first, so a muted window never claims the cue out from under an +// audible peer. +export function playCompletionSound(dedupeKey?: string) { if ($hapticsMuted.get()) { return } - playVariant($completionSoundVariantId.get()) + if (!dedupeKey) { + playVariant($completionSoundVariantId.get()) + + return + } + + void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => { + if (owns) { + playVariant($completionSoundVariantId.get()) + } + }) } interface AirPuffSpec { diff --git a/apps/desktop/src/store/ambient.ts b/apps/desktop/src/store/ambient.ts new file mode 100644 index 000000000000..0c7a8ee31d6d --- /dev/null +++ b/apps/desktop/src/store/ambient.ts @@ -0,0 +1,18 @@ +// One window owns each cross-window ambient cue (turn-end sound, spoken reply) +// so N open full windows don't all fire it for the same backend event. The main +// process is the race-free owner (see electron/event-dedupe.ts). Off Electron — +// or when the bridge/claim fails — every window emits, preserving the +// single-window behavior rather than going silent. +export async function ownsAmbientCue(key: string): Promise { + const claim = window.hermesDesktop?.claimAmbientCue + + if (!claim) { + return true + } + + try { + return await claim(key) + } catch { + return true + } +} From a41346f8aeafc2e8e8ec0d49dbb743a5dbec070a Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 18:36:05 -0500 Subject: [PATCH 50/72] refactor(desktop): tidy the cross-window deduper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Drop the unused DEDUPE_WINDOW_MS export and rename its interval so "window" isn't overloaded against BrowserWindow in a multi-window feature (windowMs → intervalMs). DRY the completion-sound play path. No behavior change. --- apps/desktop/electron/event-dedupe.ts | 17 +++++++---------- apps/desktop/src/lib/completion-sound.ts | 12 ++++-------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/apps/desktop/electron/event-dedupe.ts b/apps/desktop/electron/event-dedupe.ts index ec14d67e851b..a2d12a8f2cba 100644 --- a/apps/desktop/electron/event-dedupe.ts +++ b/apps/desktop/electron/event-dedupe.ts @@ -2,22 +2,21 @@ // sound, spoken replies). Every desktop window is its own renderer process, so N // open windows each independently react to the same backend event. The main // process is the one place they all share and it handles IPC serially, so it's -// the race-free owner: the first window to claim a key within the window wins; -// peers see it's taken and stay quiet. -// -// Pure + injectable clock so it's unit-testable without Electron. +// the race-free owner: the first window to claim a key within the interval wins; +// peers see it's taken and stay quiet. Pure + injectable clock, so it's +// unit-testable without Electron. -const DEDUPE_WINDOW_MS = 1000 +const DEDUPE_INTERVAL_MS = 1000 -// Returns true when `key` was already claimed within the window (caller drops +// Returns true when `key` was already claimed within the interval (caller drops // this one). Self-evicting: stale keys are pruned on every call, so the map // can't grow unbounded. -function createEventDeduper(windowMs = DEDUPE_WINDOW_MS) { +export function createEventDeduper(intervalMs = DEDUPE_INTERVAL_MS) { const lastSeenAt = new Map() return function isDuplicate(key: string, now = Date.now()): boolean { for (const [k, at] of lastSeenAt) { - if (now - at >= windowMs) { + if (now - at >= intervalMs) { lastSeenAt.delete(k) } } @@ -31,5 +30,3 @@ function createEventDeduper(windowMs = DEDUPE_WINDOW_MS) { return false } } - -export { createEventDeduper, DEDUPE_WINDOW_MS } diff --git a/apps/desktop/src/lib/completion-sound.ts b/apps/desktop/src/lib/completion-sound.ts index f1faa150bf0f..1557c58e419e 100644 --- a/apps/desktop/src/lib/completion-sound.ts +++ b/apps/desktop/src/lib/completion-sound.ts @@ -462,17 +462,13 @@ export function playCompletionSound(dedupeKey?: string) { return } - if (!dedupeKey) { - playVariant($completionSoundVariantId.get()) + const play = () => playVariant($completionSoundVariantId.get()) - return + if (!dedupeKey) { + return play() } - void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => { - if (owns) { - playVariant($completionSoundVariantId.get()) - } - }) + void ownsAmbientCue(`sound:${dedupeKey}`).then(owns => owns && play()) } interface AirPuffSpec { From 933c823ae847d7b26427a655535107f4c1e9f6f4 Mon Sep 17 00:00:00 2001 From: ethernet Date: Mon, 20 Jul 2026 20:38:59 -0400 Subject: [PATCH 51/72] nix: add cage to devDeps --- nix/hermes-agent.nix | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/nix/hermes-agent.nix b/nix/hermes-agent.nix index 043d1e942021..f1a23b1029d5 100644 --- a/nix/hermes-agent.nix +++ b/nix/hermes-agent.nix @@ -22,6 +22,9 @@ wl-clipboard, xclip, + # linux-only dev deps + cage, + # Flake inputs — passed explicitly by packages.nix and overlays.nix uv2nix, pyproject-nix, @@ -61,8 +64,7 @@ let bundledSkills = lib.cleanSourceWith { src = ../skills; - filter = - path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path); + filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path); }; # Optional skills are NOT in the wheel (pythonSrc excludes them, see @@ -70,8 +72,7 @@ let # same mechanism Homebrew packaging uses. bundledOptionalSkills = lib.cleanSourceWith { src = ../optional-skills; - filter = - path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path); + filter = path: _type: !(lib.hasInfix "/index-cache/" path) && !(lib.hasInfix "/__pycache__/" path); }; # Import bundled plugins (memory, context_engine, platforms/*). Keeping @@ -251,7 +252,14 @@ stdenv.mkDerivation (finalAttrs: { export HERMES_PYTHON=${devPython}/bin/python3 ''; - devDeps = runtimeDeps ++ [ devPython ]; + devDeps = + runtimeDeps + ++ [ + devPython + ] + ++ lib.optionals stdenv.isLinux [ + cage # for running e2e tests without popping windows + ]; }; meta = with lib; { From 272bbaf7928e51c1f6c83c9aba0281b4fa8e7738 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:54:36 -0600 Subject: [PATCH 52/72] fix(desktop): avoid false remote gateway reauthentication (#68250) * fix(desktop): avoid false remote gateway reauthentication Co-authored-by: Rod-fernandez Co-authored-by: David Andrews (LexGenius.ai) * fix(desktop): harden remote revalidation state --------- Co-authored-by: Rod-fernandez Co-authored-by: David Andrews (LexGenius.ai) --- apps/desktop/AGENTS.md | 8 +- .../electron/connection-config.test.ts | 69 ++++- apps/desktop/electron/connection-config.ts | 62 ++++- apps/desktop/electron/main.ts | 64 ++--- apps/desktop/electron/remote-liveness.test.ts | 253 ++++++++++++++++++ apps/desktop/electron/remote-liveness.ts | 182 +++++++++++++ .../src/app/gateway/hooks/use-gateway-boot.ts | 13 +- .../app/gateway/hooks/use-gateway-request.ts | 7 +- apps/desktop/src/global.d.ts | 4 +- apps/desktop/src/lib/gateway-ws-url.test.ts | 55 +++- apps/shared/src/index.ts | 1 + apps/shared/src/websocket-url.ts | 50 +++- 12 files changed, 685 insertions(+), 83 deletions(-) create mode 100644 apps/desktop/electron/remote-liveness.test.ts create mode 100644 apps/desktop/electron/remote-liveness.ts diff --git a/apps/desktop/AGENTS.md b/apps/desktop/AGENTS.md index ca3a3d44d054..6908c2a42343 100644 --- a/apps/desktop/AGENTS.md +++ b/apps/desktop/AGENTS.md @@ -125,9 +125,11 @@ normalization alike. Learn the shape, not a snapshot of the current rungs. Two auth-flavored corollaries worth naming because they are easy to get wrong: - **One-time credentials are never reused.** An OAuth gateway connection mints a - fresh WebSocket ticket on every dial; a mint failure means reauthentication, - not "fall back to the cached URL." Only long-lived token/local auth may reuse - a cached URL as a lower rung. + fresh WebSocket ticket on every dial and never falls back to the cached URL. + Only a confirmed 401/403 (or an explicitly tagged auth rejection) means + reauthentication; timeout, network, malformed-response, and server failures + remain connectivity errors. Only long-lived token/local auth may reuse a + cached URL as a lower rung. - **A connection test must exercise the leg you'll actually use.** An HTTP status probe passing while the WebSocket/auth leg fails is a false positive that ships as "it said connected but nothing works." diff --git a/apps/desktop/electron/connection-config.test.ts b/apps/desktop/electron/connection-config.test.ts index 425e63b15f21..be7d6228d20f 100644 --- a/apps/desktop/electron/connection-config.test.ts +++ b/apps/desktop/electron/connection-config.test.ts @@ -23,6 +23,9 @@ import { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + gatewayTicketFailure, + gatewayWsUrlIpcResult, + isGatewayAuthRejection, modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, @@ -431,12 +434,14 @@ test('resolveTestWsUrl (oauth, mint ok) builds a ?ticket= URL', async () => { assert.equal(url, 'wss://gw.example.com/api/ws?ticket=tkt-9') }) -test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validation', async () => { +test('resolveTestWsUrl (oauth, auth rejected) requests sign-in and does not skip WS validation', async () => { + const cause = Object.assign(new Error('ticket mint failed'), { statusCode: 401 }) + await assert.rejects( () => resolveTestWsUrl('https://gw.example.com', 'oauth', null, { mintTicket: async () => { - throw new Error('401 ticket mint failed') + throw cause } }), (err: any) => { @@ -452,6 +457,66 @@ test('resolveTestWsUrl (oauth, mint FAILS) throws — must NOT skip WS validatio ) }) +test('resolveTestWsUrl (oauth, transport failure) remains a retryable connection error', async () => { + const cause = new Error('socket timed out') + + await assert.rejects( + () => + resolveTestWsUrl('https://gw.example.com', 'oauth', null, { + mintTicket: async () => { + throw cause + } + }), + (err: any) => { + assert.match(err.message, /could not mint a WebSocket ticket/i) + assert.equal(err.needsOauthLogin, undefined) + assert.equal(err.cause, cause) + + return true + } + ) +}) + +test('gateway ticket failures classify only explicit auth rejection statuses as reauth', () => { + assert.equal(isGatewayAuthRejection({ statusCode: 401 }), true) + assert.equal(isGatewayAuthRejection({ statusCode: 403 }), true) + assert.equal(isGatewayAuthRejection({ needsOauthLogin: true }), true) + assert.equal(isGatewayAuthRejection({ statusCode: 500 }), false) + assert.equal(isGatewayAuthRejection(new Error('network timeout')), false) + + const serverFailure = gatewayTicketFailure(new Error('network timeout'), 'sign in', 'retry connection') as any + assert.equal(serverFailure.message, 'retry connection') + assert.equal(serverFailure.needsOauthLogin, undefined) +}) + +test('gateway WS URL IPC result serializes success and the auth-vs-transport matrix', async () => { + assert.deepEqual(await gatewayWsUrlIpcResult(async () => 'wss://gateway.example.com/api/ws?ticket=fresh'), { + ok: true, + wsUrl: 'wss://gateway.example.com/api/ws?ticket=fresh' + }) + + for (const statusCode of [401, 403]) { + const error = Object.assign(new Error(`${statusCode}: rejected`), { statusCode }) + + assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), { + error: `${statusCode}: rejected`, + needsOauthLogin: true, + ok: false + }) + } + + for (const error of [ + Object.assign(new Error('500: unavailable'), { statusCode: 500 }), + new Error('Timed out connecting to Hermes backend after 8000ms'), + Object.assign(new Error('socket reset'), { code: 'ECONNRESET' }) + ]) { + assert.deepEqual(await gatewayWsUrlIpcResult(async () => Promise.reject(error)), { + error: error.message, + ok: false + }) + } +}) + test('resolveTestWsUrl (oauth) requires a mintTicket function', async () => { await assert.rejects( () => resolveTestWsUrl('https://gw.example.com', 'oauth', null), diff --git a/apps/desktop/electron/connection-config.ts b/apps/desktop/electron/connection-config.ts index 569f9cc07261..5a04da406cd5 100644 --- a/apps/desktop/electron/connection-config.ts +++ b/apps/desktop/electron/connection-config.ts @@ -88,6 +88,43 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) { return `${wsScheme}://${parsed.host}${prefix}/api/ws?ticket=${encodeURIComponent(ticket)}` } +/** True only when a gateway explicitly rejected the current OAuth session. */ +function isGatewayAuthRejection(error) { + if (error && typeof error === 'object' && (error as any).needsOauthLogin === true) { + return true + } + + const statusCode = Number(error && typeof error === 'object' ? (error as any).statusCode : NaN) + + return statusCode === 401 || statusCode === 403 +} + +function gatewayTicketFailure(error, authMessage, transportMessage) { + const needsOauthLogin = isGatewayAuthRejection(error) + const err = new Error(needsOauthLogin ? authMessage : transportMessage) + + if (needsOauthLogin) { + ;(err as any).needsOauthLogin = true + } + + err.cause = error + + return err +} + +/** Serialize a fresh-WS-URL attempt across Electron's IPC boundary. */ +async function gatewayWsUrlIpcResult(resolveWsUrl: () => Promise) { + try { + return { ok: true as const, wsUrl: await resolveWsUrl() } + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + ...(isGatewayAuthRejection(error) ? { needsOauthLogin: true as const } : {}), + ok: false as const + } + } +} + /** * Build the WS URL the renderer would connect with, so the connection test can * exercise the same transport the app actually uses. @@ -102,12 +139,10 @@ function buildGatewayWsUrlWithTicket(baseUrl, ticket) { * - oauth, mint ok → ws(s)://…/api/ws?ticket=… * - oauth, mint fails → THROWS (NOT a skip) * - * The oauth-mint-failure throw is the important case: the real boot path - * (resolveRemoteBackend in main.ts) treats a mint failure as a hard - * "session expired" auth error and refuses to connect. Swallowing it here - * would re-introduce the exact false-positive this test exists to catch — - * HTTP /api/status passes, the test reports "reachable", then the renderer - * can't authenticate /api/ws and boot dies with "Could not connect". + * The oauth-mint-failure throw is the important case: swallowing it here would + * re-introduce the exact false-positive this test exists to catch. An explicit + * 401/403 asks for sign-in; transport and server failures remain connectivity + * errors so a temporary outage is not mislabeled as an expired session. * * @param {string} baseUrl * @param {'token'|'oauth'} authMode @@ -128,14 +163,12 @@ async function resolveTestWsUrl(baseUrl, authMode, token, deps: any = {}) { try { ticket = await mintTicket(baseUrl) } catch (error) { - const err = new Error( - 'Reached the gateway over HTTP, but could not mint a WebSocket ticket for the OAuth session ' + - '(it may have expired). Open Settings → Gateway and sign in again.' + throw gatewayTicketFailure( + error, + 'Reached the gateway over HTTP, but the OAuth session was rejected while minting a WebSocket ticket. ' + + 'Open Settings → Gateway and sign in again.', + 'Reached the gateway over HTTP, but could not mint a WebSocket ticket. Check the remote gateway connection and try again.' ) - - ;(err as any).needsOauthLogin = true - err.cause = error - throw err } return buildGatewayWsUrlWithTicket(baseUrl, ticket) @@ -337,6 +370,9 @@ export { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + gatewayTicketFailure, + gatewayWsUrlIpcResult, + isGatewayAuthRejection, modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, diff --git a/apps/desktop/electron/main.ts b/apps/desktop/electron/main.ts index 141497cf6b4d..4d7108d9f794 100644 --- a/apps/desktop/electron/main.ts +++ b/apps/desktop/electron/main.ts @@ -47,6 +47,8 @@ import { cookiesHaveLiveSession, cookiesHavePrivySession, cookiesHaveSession, + gatewayTicketFailure, + gatewayWsUrlIpcResult, modeIsRemoteLike, normalizeRemoteBaseUrl, normAuthMode, @@ -109,6 +111,7 @@ import { ensureMainWindow } from './main-window-lifecycle' import { serializeJsonBody, setJsonRequestHeaders } from './oauth-net-request' import { createKeepAwake } from './power-save' import { decideProfileDeleteAction, profileNameFromDeleteRequest, resolveRouteProfile } from './profile-delete-routing' +import { RemoteLivenessTracker, RemoteRevalidationCoordinator, revalidateRemoteConnection } from './remote-liveness' import { buildSessionWindowUrl, chatWindowWebPreferences, @@ -935,6 +938,8 @@ function registerMediaProtocol() { let mainWindow = null const backendConnectionState = createBackendConnectionState, any>() +const remoteLiveness = new RemoteLivenessTracker() +const remoteRevalidation = new RemoteRevalidationCoordinator() // True while connection-config:apply soft-rehomes the primary — suppresses the // backend-exit toast so an intentional kill doesn't look like a crash. let softRehomeInProgress = false @@ -6333,13 +6338,11 @@ async function buildRemoteConnection(rawUrl, authMode, token, source) { try { ticket = await mintGatewayWsTicket(baseUrl) } catch (error) { - const err = new Error( - 'Your remote gateway session has expired. ' + 'Open Settings → Gateway and click "Sign in" again.' - ) as any - - err.needsOauthLogin = true - err.cause = error - throw err + throw gatewayTicketFailure( + error, + 'Your remote gateway session has expired. Open Settings → Gateway and click "Sign in" again.', + 'Could not reach the remote Hermes gateway while refreshing its WebSocket ticket. Try reconnecting.' + ) } return { @@ -6621,6 +6624,7 @@ function stopBackendChild(child) { // switch / crash recovery), which still resets boot progress + reloads. function resetHermesConnection({ soft = false } = {}) { backendStartFailure = null + remoteLiveness.clear() const hermesProcess = backendConnectionState.invalidate() stopBackendChild(hermesProcess) @@ -7857,42 +7861,28 @@ ipcMain.handle('hermes:connection:revalidate', async () => { return { ok: true, rebuilt: false } } - let conn = null - - try { - conn = await connectionPromise - } catch { - // The cached boot already rejected (its own catch clears the promise); - // nothing to revalidate — the next getConnection() builds fresh. - return { ok: true, rebuilt: false } - } - - if (!conn || conn.mode !== 'remote' || !conn.baseUrl) { - return { ok: true, rebuilt: false } - } - - const base = conn.baseUrl.replace(/\/+$/, '') - - try { - await fetchPublicJson(`${base}/api/status`, { timeoutMs: 2_500 }) - - return { ok: true, rebuilt: false } - } catch { - // Unreachable remote: drop the stale cache so the renderer's next reconnect - // tick rebuilds a fresh, reachable descriptor. resetHermesConnection only - // clears the connection promise for a remote (no child to SIGTERM). - rememberLog('Cached remote Hermes backend failed liveness probe; dropping stale connection.') - resetHermesConnection() - - return { ok: true, rebuilt: true } - } + // Main and every session pop-out have their own renderer reconnect loop but + // share this primary connection. Coalesce simultaneous requests so one outage + // produces one failure observation rather than exhausting the whole streak. + return remoteRevalidation.run(connectionPromise, () => + revalidateRemoteConnection({ + connectionPromise, + currentConnectionPromise: () => backendConnectionState.getPromise(), + log: rememberLog, + probe: fetchPublicJson, + resetConnection: resetHermesConnection, + tracker: remoteLiveness + }) + ) }) ipcMain.handle('hermes:backend:touch', async (_event, profile) => { touchPoolBackend(profile) return { ok: true } }) -ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => freshGatewayWsUrl(profile)) +ipcMain.handle('hermes:gateway:ws-url', async (_event, profile) => { + return gatewayWsUrlIpcResult(() => freshGatewayWsUrl(profile)) +}) ipcMain.handle('hermes:window:openSession', async (_event, sessionId, opts) => { if (typeof sessionId !== 'string' || !sessionId.trim()) { return { ok: false, error: 'invalid-session-id' } diff --git a/apps/desktop/electron/remote-liveness.test.ts b/apps/desktop/electron/remote-liveness.test.ts new file mode 100644 index 000000000000..6c52e11f69f9 --- /dev/null +++ b/apps/desktop/electron/remote-liveness.test.ts @@ -0,0 +1,253 @@ +import { describe, expect, it, vi } from 'vitest' + +import { + REMOTE_LIVENESS_FAILURE_LIMIT, + REMOTE_LIVENESS_FAILURE_WINDOW_MS, + REMOTE_LIVENESS_TIMEOUT_MS, + RemoteLivenessTracker, + RemoteRevalidationCoordinator, + revalidateRemoteConnection +} from './remote-liveness' + +describe('RemoteLivenessTracker', () => { + it('requires consecutive failures before resetting a connection', () => { + const tracker = new RemoteLivenessTracker() + + for (let failures = 1; failures < REMOTE_LIVENESS_FAILURE_LIMIT; failures += 1) { + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures, shouldReset: false }) + } + + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ + failures: REMOTE_LIVENESS_FAILURE_LIMIT, + shouldReset: true + }) + }) + + it('clears a failure streak after a successful probe', () => { + const tracker = new RemoteLivenessTracker() + + tracker.recordFailure('https://gateway.example.com') + tracker.recordFailure('https://gateway.example.com') + tracker.recordSuccess('https://gateway.example.com') + + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false }) + }) + + it('tracks different gateways independently', () => { + const tracker = new RemoteLivenessTracker(2) + + expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false }) + expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false }) + expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 2, shouldReset: true }) + expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: true }) + }) + + it('clears only the successful gateway streak', () => { + const tracker = new RemoteLivenessTracker(3) + + tracker.recordFailure('https://one.example.com') + tracker.recordFailure('https://two.example.com') + tracker.recordSuccess('https://one.example.com') + + expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false }) + expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 2, shouldReset: false }) + }) + + it('does not accumulate isolated failures across separate reconnect episodes', () => { + let now = 0 + const tracker = new RemoteLivenessTracker(3, REMOTE_LIVENESS_FAILURE_WINDOW_MS, () => now) + + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false }) + now += REMOTE_LIVENESS_FAILURE_WINDOW_MS + 1 + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false }) + }) + + it('clears all failure streaks when the connection state resets', () => { + const tracker = new RemoteLivenessTracker(3) + + tracker.recordFailure('https://one.example.com') + tracker.recordFailure('https://two.example.com') + tracker.clear() + + expect(tracker.recordFailure('https://one.example.com')).toEqual({ failures: 1, shouldReset: false }) + expect(tracker.recordFailure('https://two.example.com')).toEqual({ failures: 1, shouldReset: false }) + }) + + it('starts a fresh streak after the reset threshold is consumed', () => { + const tracker = new RemoteLivenessTracker(1) + + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true }) + expect(tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: true }) + }) + + it('rejects invalid failure limits', () => { + expect(() => new RemoteLivenessTracker(0)).toThrow(/positive integer/i) + expect(() => new RemoteLivenessTracker(1.5)).toThrow(/positive integer/i) + expect(() => new RemoteLivenessTracker(1, 0)).toThrow(/window must be positive/i) + }) +}) + +describe('RemoteRevalidationCoordinator', () => { + it('coalesces simultaneous probes for the same cached connection', async () => { + const coordinator = new RemoteRevalidationCoordinator() + const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' }) + let resolveProbe: (value: string) => void = () => undefined + + const probe = vi.fn( + () => + new Promise(resolve => { + resolveProbe = resolve + }) + ) + + const first = coordinator.run(connection, probe) + const second = coordinator.run(connection, probe) + const third = coordinator.run(connection, probe) + + await Promise.resolve() + + expect(second).toBe(first) + expect(third).toBe(first) + expect(probe).toHaveBeenCalledOnce() + + resolveProbe('healthy') + await expect(Promise.all([first, second, third])).resolves.toEqual(['healthy', 'healthy', 'healthy']) + }) + + it('runs a fresh probe after the prior one settles', async () => { + const coordinator = new RemoteRevalidationCoordinator() + const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' }) + const probe = vi.fn().mockResolvedValue('healthy') + + await coordinator.run(connection, probe) + await coordinator.run(connection, probe) + + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('does not coalesce different cached connections', async () => { + const coordinator = new RemoteRevalidationCoordinator() + const probe = vi.fn().mockResolvedValue('healthy') + + await Promise.all([coordinator.run(Promise.resolve('one'), probe), coordinator.run(Promise.resolve('two'), probe)]) + + expect(probe).toHaveBeenCalledTimes(2) + }) + + it('cleans up a rejected probe so it can be retried', async () => { + const coordinator = new RemoteRevalidationCoordinator() + const connection = Promise.resolve({ baseUrl: 'https://gateway.example.com' }) + const probe = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValueOnce('healthy') + + await expect(coordinator.run(connection, probe)).rejects.toThrow('offline') + await expect(coordinator.run(connection, probe)).resolves.toBe('healthy') + expect(probe).toHaveBeenCalledTimes(2) + }) +}) + +describe('revalidateRemoteConnection', () => { + function harness(overrides: Record = {}) { + const connection = { baseUrl: 'https://gateway.example.com/', mode: 'remote' } + const connectionPromise = Promise.resolve(connection) + const current = { promise: connectionPromise as null | Promise } + const log = vi.fn() + const probe = vi.fn().mockResolvedValue({ ok: true }) + const resetConnection = vi.fn() + const tracker = new RemoteLivenessTracker() + + return { + connectionPromise, + current, + log, + options: { + connectionPromise, + currentConnectionPromise: () => current.promise, + log, + probe, + resetConnection, + tracker, + ...overrides + }, + probe, + resetConnection, + tracker + } + } + + it('probes the normalized status URL with the production timeout', async () => { + const test = harness() + + await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false }) + expect(test.probe).toHaveBeenCalledWith('https://gateway.example.com/api/status', { + timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS + }) + expect(test.resetConnection).not.toHaveBeenCalled() + }) + + it('keeps failures one and two, then resets on the third failure', async () => { + const probe = vi.fn().mockRejectedValue(new Error('offline')) + const test = harness({ probe }) + + await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false }) + await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: false }) + await expect(revalidateRemoteConnection(test.options)).resolves.toEqual({ ok: true, rebuilt: true }) + + expect(probe).toHaveBeenCalledTimes(3) + expect(test.resetConnection).toHaveBeenCalledOnce() + expect(test.log).toHaveBeenNthCalledWith(1, expect.stringContaining('(1/3)')) + expect(test.log).toHaveBeenNthCalledWith(2, expect.stringContaining('(2/3)')) + expect(test.log).toHaveBeenLastCalledWith(expect.stringContaining('dropping stale connection')) + }) + + it('ignores a late failed probe after the cached connection is replaced', async () => { + let rejectProbe: (error: Error) => void = () => undefined + + const probe = vi.fn( + () => + new Promise((_resolve, reject) => { + rejectProbe = reject + }) + ) + + const test = harness({ probe }) + const pending = revalidateRemoteConnection(test.options) + + await Promise.resolve() + test.current.promise = Promise.resolve({ baseUrl: 'https://new.example.com', mode: 'remote' }) + rejectProbe(new Error('old connection failed')) + + await expect(pending).resolves.toEqual({ ok: true, rebuilt: false }) + expect(test.resetConnection).not.toHaveBeenCalled() + expect(test.log).not.toHaveBeenCalled() + expect(test.tracker.recordFailure('https://gateway.example.com')).toEqual({ failures: 1, shouldReset: false }) + }) + + it('does not probe a local, rejected, or already replaced connection', async () => { + const replaced = harness() + + replaced.current.promise = null + await expect(revalidateRemoteConnection(replaced.options)).resolves.toEqual({ ok: true, rebuilt: false }) + expect(replaced.probe).not.toHaveBeenCalled() + + const localConnection = { baseUrl: 'http://127.0.0.1:3000', mode: 'local' } + const localPromise = Promise.resolve(localConnection) + + const local = harness({ + connectionPromise: localPromise, + currentConnectionPromise: () => localPromise + }) + + await expect(revalidateRemoteConnection(local.options)).resolves.toEqual({ ok: true, rebuilt: false }) + expect(local.probe).not.toHaveBeenCalled() + + const rejectedPromise = Promise.reject(new Error('boot failed')) + + const rejected = harness({ + connectionPromise: rejectedPromise, + currentConnectionPromise: () => rejectedPromise + }) + + await expect(revalidateRemoteConnection(rejected.options)).resolves.toEqual({ ok: true, rebuilt: false }) + expect(rejected.probe).not.toHaveBeenCalled() + }) +}) diff --git a/apps/desktop/electron/remote-liveness.ts b/apps/desktop/electron/remote-liveness.ts new file mode 100644 index 000000000000..eedc16682e7d --- /dev/null +++ b/apps/desktop/electron/remote-liveness.ts @@ -0,0 +1,182 @@ +export const REMOTE_LIVENESS_TIMEOUT_MS = 10_000 +export const REMOTE_LIVENESS_FAILURE_LIMIT = 3 +// Even at the capped retry path, consecutive liveness observations are at most +// about 48s apart (ticket mint + socket open + backoff + the next status probe). +// One minute keeps a continuous outage together without carrying old failures. +export const REMOTE_LIVENESS_FAILURE_WINDOW_MS = 60_000 + +export interface RemoteLivenessFailure { + failures: number + shouldReset: boolean +} + +interface RemoteConnectionDescriptor { + baseUrl?: null | string + mode?: null | string +} + +export interface RevalidateRemoteConnectionOptions { + connectionPromise: Promise + currentConnectionPromise: () => null | Promise + log: (message: string) => void + probe: (url: string, options: { timeoutMs: number }) => Promise + resetConnection: () => void + tracker: RemoteLivenessTracker +} + +export interface RemoteRevalidationResult { + ok: true + rebuilt: boolean +} + +/** + * Coalesces revalidation work for one cached connection promise. + * + * Every Desktop BrowserWindow owns a renderer gateway loop. When several + * windows observe the same disconnect they can all ask the Electron main + * process to revalidate the shared primary connection at once. Those calls + * must count as one probe, not several consecutive failures. + */ +export class RemoteRevalidationCoordinator { + readonly #inflightByConnection = new WeakMap>() + + run(connection: object, task: () => Promise): Promise { + const existing = this.#inflightByConnection.get(connection) as Promise | undefined + + if (existing) { + return existing + } + + const pending = Promise.resolve().then(task) + + const clear = () => { + if (this.#inflightByConnection.get(connection) === pending) { + this.#inflightByConnection.delete(connection) + } + } + + this.#inflightByConnection.set(connection, pending) + // Clean up on both outcomes without creating an unhandled rejected branch. + void pending.then(clear, clear) + + return pending + } +} + +/** + * Tracks consecutive remote liveness failures independently per gateway. + * A successful probe clears the streak, and reaching the limit consumes it so + * a rebuilt connection starts from a clean state. + */ +export class RemoteLivenessTracker { + readonly #failureLimit: number + readonly #failureWindowMs: number + readonly #failuresByBaseUrl = new Map() + readonly #now: () => number + + constructor( + failureLimit = REMOTE_LIVENESS_FAILURE_LIMIT, + failureWindowMs = REMOTE_LIVENESS_FAILURE_WINDOW_MS, + now: () => number = Date.now + ) { + if (!Number.isInteger(failureLimit) || failureLimit < 1) { + throw new Error('Remote liveness failure limit must be a positive integer.') + } + + if (!Number.isFinite(failureWindowMs) || failureWindowMs < 1) { + throw new Error('Remote liveness failure window must be positive.') + } + + this.#failureLimit = failureLimit + this.#failureWindowMs = failureWindowMs + this.#now = now + } + + recordSuccess(baseUrl: string): void { + this.#failuresByBaseUrl.delete(baseUrl) + } + + recordFailure(baseUrl: string): RemoteLivenessFailure { + const now = this.#now() + const previous = this.#failuresByBaseUrl.get(baseUrl) + const withinFailureWindow = previous && now - previous.lastFailureAt <= this.#failureWindowMs + const failures = (withinFailureWindow ? previous.failures : 0) + 1 + const shouldReset = failures >= this.#failureLimit + + if (shouldReset) { + this.#failuresByBaseUrl.delete(baseUrl) + } else { + this.#failuresByBaseUrl.set(baseUrl, { failures, lastFailureAt: now }) + } + + return { failures, shouldReset } + } + + clear(): void { + this.#failuresByBaseUrl.clear() + } +} + +/** + * Probe the cached primary remote connection and apply the failure policy. + * The caller owns single-flight coordination; identity checks here ensure an + * old async result cannot mutate or reset a replacement connection. + */ +export async function revalidateRemoteConnection({ + connectionPromise, + currentConnectionPromise, + log, + probe, + resetConnection, + tracker +}: RevalidateRemoteConnectionOptions): Promise { + let connection: TConnection + + try { + connection = await connectionPromise + } catch { + // The cached boot already rejected; its own recovery path will clear it. + return { ok: true, rebuilt: false } + } + + if (currentConnectionPromise() !== connectionPromise) { + return { ok: true, rebuilt: false } + } + + if (connection.mode !== 'remote' || !connection.baseUrl) { + return { ok: true, rebuilt: false } + } + + const baseUrl = connection.baseUrl.replace(/\/+$/, '') + + try { + await probe(`${baseUrl}/api/status`, { timeoutMs: REMOTE_LIVENESS_TIMEOUT_MS }) + + if (currentConnectionPromise() !== connectionPromise) { + return { ok: true, rebuilt: false } + } + + tracker.recordSuccess(baseUrl) + + return { ok: true, rebuilt: false } + } catch { + if (currentConnectionPromise() !== connectionPromise) { + return { ok: true, rebuilt: false } + } + + const failure = tracker.recordFailure(baseUrl) + + if (!failure.shouldReset) { + log( + `Cached remote Hermes backend failed liveness probe (${failure.failures}/${REMOTE_LIVENESS_FAILURE_LIMIT}); keeping connection for retry.` + ) + + return { ok: true, rebuilt: false } + } + + log('Cached remote Hermes backend failed liveness probe; dropping stale connection.') + resetConnection() + + return { ok: true, rebuilt: true } + } +} diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts index d60841813272..d199183c6d51 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-boot.ts @@ -155,9 +155,10 @@ export function useGatewayBoot({ // with a short TTL, so the ticket baked into the cached conn.wsUrl is // dead on every reconnect after the initial boot — reusing it surfaces // as an opaque "Could not connect to Hermes gateway". resolveGatewayWsUrl - // mints a fresh ticket (or throws a reauth error in OAuth mode rather - // than connecting with a stale one). For local/token gateways the URL - // carries a long-lived token and the re-mint is a cheap no-op. + // mints a fresh ticket rather than connecting with a stale one. An + // explicit auth rejection asks for sign-in; transport failures stay in + // this reconnect loop. For local/token gateways the URL carries a + // long-lived token and the re-mint is a cheap no-op. const wsUrl = await resolveGatewayWsUrl(desktop, conn) await gateway.connect(wsUrl) @@ -454,9 +455,9 @@ export function useGatewayBoot({ publish(conn) // Mint a fresh WS URL right before connecting. For OAuth gateways the // ticket is single-use with a short TTL, so the ticket baked into - // conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it and, on - // failure, throws a reauth error rather than connecting with a dead - // ticket (which would surface as an opaque "connection closed"). + // conn.wsUrl is stale; resolveGatewayWsUrl() re-mints it rather than + // connecting with a dead ticket. Auth rejection asks for sign-in; + // connectivity failures remain retryable. const wsUrl = await resolveGatewayWsUrl(desktop, conn) await gateway.connect(wsUrl) diff --git a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts index d6c9ab0e029b..72bd08c98b51 100644 --- a/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts +++ b/apps/desktop/src/app/gateway/hooks/use-gateway-request.ts @@ -69,9 +69,10 @@ export function useGatewayRequest() { setConnection(conn) // Re-mint the WS URL before reconnecting. OAuth tickets are single-use // and short-lived, so the cached conn.wsUrl ticket is dead here; - // resolveGatewayWsUrl() throws a reauth error in OAuth mode rather than - // connecting with a stale ticket. Stash it so requestGateway can show - // the actionable "sign in again" message. + // resolveGatewayWsUrl() never connects with a stale ticket. An explicit + // auth rejection becomes a reauth error; transport failures remain + // retryable. Stash only the former so requestGateway can show the + // actionable "sign in again" message. const wsUrl = await resolveGatewayWsUrl(desktop, conn) await existing.connect(wsUrl) diff --git a/apps/desktop/src/global.d.ts b/apps/desktop/src/global.d.ts index fe2a3eaa6dc0..89a14d443040 100644 --- a/apps/desktop/src/global.d.ts +++ b/apps/desktop/src/global.d.ts @@ -1,3 +1,5 @@ +import type { GatewayWsUrlResult } from '@hermes/shared' + import type { PetOverlayBounds, PetOverlayControl, @@ -24,7 +26,7 @@ declare global { // Keepalive: mark a pool profile backend as recently used so the idle // reaper spares it while its chat is active. touchBackend: (profile?: string | null) => Promise<{ ok: boolean }> - getGatewayWsUrl: (profile?: null | string) => Promise + getGatewayWsUrl: (profile?: null | string) => Promise // Open (or focus) a standalone OS window for a single chat session so // the user can work with multiple chats side by side. Returns ok:false // with an error code when the sessionId is empty/invalid. `watch` opens diff --git a/apps/desktop/src/lib/gateway-ws-url.test.ts b/apps/desktop/src/lib/gateway-ws-url.test.ts index e8b09765923f..48c7c7416d95 100644 --- a/apps/desktop/src/lib/gateway-ws-url.test.ts +++ b/apps/desktop/src/lib/gateway-ws-url.test.ts @@ -12,23 +12,56 @@ describe('resolveGatewayWsUrl', () => { expect(getGatewayWsUrl).toHaveBeenCalledOnce() }) - it('throws a reauth error instead of falling back to the stale cached ticket', async () => { - const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('401 cookie expired')) + it('uses the structured URL returned across the Electron IPC boundary', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?ticket=fresh' }) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).resolves.toBe('ws://host/api/ws?ticket=fresh') + }) + + it('throws a reauth error when the main process reports an auth rejection', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ + error: '401 cookie expired', + needsOauthLogin: true, + ok: false + }) + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBeInstanceOf( GatewayReauthRequiredError ) }) - it('preserves the underlying mint failure as the cause', async () => { - const cause = new Error('401 cookie expired') - const getGatewayWsUrl = vi.fn().mockRejectedValue(cause) + it('preserves the main-process auth failure as the cause', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ + error: '401 cookie expired', + needsOauthLogin: true, + ok: false + }) + const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e) expect(error).toBeInstanceOf(GatewayReauthRequiredError) - expect((error as GatewayReauthRequiredError).cause).toBe(cause) + expect((error as GatewayReauthRequiredError).cause).toMatchObject({ message: '401 cookie expired' }) }) - it('throws a reauth error when the preload cannot mint (no method)', async () => { - await expect(resolveGatewayWsUrl({}, oauthConn)).rejects.toBeInstanceOf(GatewayReauthRequiredError) + it('keeps a transport failure retryable instead of demanding sign-in', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ error: 'gateway timed out', ok: false }) + const error = await resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn).catch(e => e) + + expect(error).toMatchObject({ message: 'gateway timed out' }) + expect(isGatewayReauthRequired(error)).toBe(false) + }) + + it('rethrows an unexpected transport rejection unchanged', async () => { + const cause = new Error('socket closed') + const getGatewayWsUrl = vi.fn().mockRejectedValue(cause) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, oauthConn)).rejects.toBe(cause) + }) + + it('reports a missing preload method as an app capability error, not reauth', async () => { + const error = await resolveGatewayWsUrl({}, oauthConn).catch(e => e) + + expect(error).toMatchObject({ message: expect.stringMatching(/cannot refresh OAuth WebSocket tickets/i) }) + expect(isGatewayReauthRequired(error)).toBe(false) }) it('never returns the stale cached ticket on failure', async () => { @@ -45,6 +78,12 @@ describe('resolveGatewayWsUrl', () => { await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh') }) + it('uses a structured refreshed token URL when available', async () => { + const getGatewayWsUrl = vi.fn().mockResolvedValue({ ok: true, wsUrl: 'ws://host/api/ws?token=fresh' }) + + await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe('ws://host/api/ws?token=fresh') + }) + it('falls back to the cached URL when minting fails (token is long-lived)', async () => { const getGatewayWsUrl = vi.fn().mockRejectedValue(new Error('transient')) await expect(resolveGatewayWsUrl({ getGatewayWsUrl }, tokenConn)).resolves.toBe(tokenConn.wsUrl) diff --git a/apps/shared/src/index.ts b/apps/shared/src/index.ts index d2a21999182a..9fd163efa007 100644 --- a/apps/shared/src/index.ts +++ b/apps/shared/src/index.ts @@ -47,6 +47,7 @@ export { type GatewayAuthMode, GatewayReauthRequiredError, type GatewayWsConnection, + type GatewayWsUrlResult, type HermesWebSocketUrlOptions, isGatewayReauthRequired, resolveGatewayWsUrl, diff --git a/apps/shared/src/websocket-url.ts b/apps/shared/src/websocket-url.ts index 9384f30652de..24592c4642b0 100644 --- a/apps/shared/src/websocket-url.ts +++ b/apps/shared/src/websocket-url.ts @@ -12,9 +12,14 @@ export interface ResolveGatewayWsUrlDeps { * OAuth-gated gateways use single-use tickets, so callers should mint * immediately before opening the socket. */ - getGatewayWsUrl?: (profile?: null | string) => Promise + getGatewayWsUrl?: (profile?: null | string) => Promise } +export type GatewayWsUrlResult = + | string + | { ok: true; wsUrl: string } + | { error: string; needsOauthLogin?: boolean; ok: false } + export class GatewayReauthRequiredError extends Error { readonly needsOauthLogin = true @@ -37,27 +42,52 @@ export async function resolveGatewayWsUrl(deps: ResolveGatewayWsUrlDeps, conn: G if (conn.authMode === 'oauth') { if (!mint) { - throw new GatewayReauthRequiredError( - 'Your remote gateway session needs to be refreshed. Open Settings -> Gateway and click "Sign in" again.' - ) + throw new Error('This Desktop build cannot refresh OAuth WebSocket tickets. Update Hermes Desktop and try again.') } try { - return await mint(profile) + const result = await mint(profile) + + if (typeof result === 'string') { + return result + } + + if (result.ok) { + return result.wsUrl + } + + if (result.needsOauthLogin) { + throw new GatewayReauthRequiredError( + 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', + { cause: new Error(result.error) } + ) + } + + throw new Error(result.error || 'Could not refresh the remote gateway WebSocket ticket.') } catch (error) { - throw new GatewayReauthRequiredError( - 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', - { cause: error } - ) + if (isGatewayReauthRequired(error)) { + throw error instanceof GatewayReauthRequiredError + ? error + : new GatewayReauthRequiredError( + 'Your remote gateway session has expired. Open Settings -> Gateway and click "Sign in" again.', + { cause: error } + ) + } + + throw error } } if (mint) { const fresh = await mint(profile).catch(() => null) - if (fresh) { + if (typeof fresh === 'string') { return fresh } + + if (fresh?.ok) { + return fresh.wsUrl + } } return conn.wsUrl From f657840e06e03b9552cf2d28175a1e4e4af0210b Mon Sep 17 00:00:00 2001 From: xxxigm <54813621+xxxigm@users.noreply.github.com> Date: Tue, 21 Jul 2026 07:55:04 +0700 Subject: [PATCH 53/72] fix(desktop): keep composer draft across compression tip rotation (#68079) * fix(desktop): keep composer draft across compression tip rotation Auto-compression swaps the live stored session id while the user may still be typing. Scope the composer/queue key on the lineage root and migrate any tip-keyed draft/queue entries onto that durable key when the tip rotates so the in-progress prompt does not vanish when the response lands. * test(desktop): cover draft survival across compression tip rotation Add regression coverage for migrateSessionDraft, lineage-scoped composer keys, and the rotation path that previously wiped an in-progress draft. --- apps/desktop/src/app/chat/index.tsx | 26 +++++- .../hooks/use-session-actions.test.tsx | 84 +++++++++++++++++++ .../hooks/use-session-actions/index.ts | 32 +++++-- apps/desktop/src/store/composer.test.ts | 26 ++++++ apps/desktop/src/store/composer.ts | 35 ++++++++ apps/desktop/src/store/session.test.ts | 16 ++++ apps/desktop/src/store/session.ts | 21 +++++ 7 files changed, 233 insertions(+), 7 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index b47f0dfa9907..f135b0aae959 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -2,7 +2,7 @@ import { type AppendMessage, AssistantRuntimeProvider, type ThreadMessage } from import { useStore } from '@nanostores/react' import { useQuery } from '@tanstack/react-query' import type * as React from 'react' -import { Suspense, useCallback, useMemo } from 'react' +import { Suspense, useCallback, useEffect, useMemo } from 'react' import { useLocation } from 'react-router-dom' import type { SubmitTextOptions } from '@/app/session/hooks/use-prompt-actions/utils' @@ -24,6 +24,8 @@ import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' import { $gatewaySwapTarget, $profiles } from '@/store/profile' +import { migrateSessionDraft } from '@/store/composer' +import { migrateQueuedPrompts } from '@/store/composer-queue' import { $contextSuggestions, $freshDraftReady, @@ -32,6 +34,7 @@ import { $introSeed, $resumeExhaustedSessionId, $sessions, + resolveComposerSessionKey, sessionMatchesStoredId, sessionPinId } from '@/store/session' @@ -276,7 +279,26 @@ export function ChatView({ const messagesEmpty = useStore(view.$messagesEmpty) const lastVisibleIsUser = useStore(view.$lastVisibleIsUser) const selectedSessionId = useStore(view.$storedId) + const sessions = useStore($sessions) const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) + // Durable composer/queue scope (lineage root) so auto-compression tip rotation + // does not wipe an in-progress draft or orphan /queue entries. + const queueSessionKey = useMemo( + () => resolveComposerSessionKey(selectedSessionId, sessions), + [selectedSessionId, sessions] + ) + + // When the tip row arrives after compression, migrate any tip-keyed stash onto + // the durable lineage key before the composer remounts onto that key. + useEffect(() => { + if (!selectedSessionId || !queueSessionKey || selectedSessionId === queueSessionKey) { + return + } + + migrateSessionDraft(selectedSessionId, queueSessionKey) + migrateQueuedPrompts(selectedSessionId, queueSessionKey) + }, [queueSessionKey, selectedSessionId]) + // A tile IS its session — no route involved, never "mismatched". const routedSessionId = isPrimary ? routeSessionId(location.pathname) : selectedSessionId const isRoutedSessionView = Boolean(routedSessionId) @@ -524,7 +546,7 @@ export function ChatView({ onSteer={onSteer} onSubmit={onSubmit} onTranscribeAudio={onTranscribeAudio} - queueSessionKey={selectedSessionId} + queueSessionKey={queueSessionKey} sessionId={activeSessionId} state={chatBarState} /> diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index a06e642d6e0c..854a0085925d 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -7,6 +7,7 @@ import { getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' +import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { $activeSessionId, $activeSessionStoredIdRotation, @@ -196,6 +197,89 @@ describe('active stored-session id rotation routing', () => { expect($activeSessionStoredIdRotation.get()).toBeNull() }) + it('keeps draft on the previous tip when the new tip row is not loaded yet', async () => { + const tipBefore = 'tip-root' + const tipAfter = 'tip-new-unloaded' + const runtimeSessionId = 'runtime-gap' + const activeSessionIdRef: MutableRefObject = { current: runtimeSessionId } + const selectedStoredSessionIdRef: MutableRefObject = { current: tipBefore } + const navigate = vi.fn() + + setSessions([]) + stashSessionDraft(tipBefore, 'typed during gap', []) + setSelectedStoredSessionId(tipBefore) + setActiveSessionId(runtimeSessionId) + + render( + tipBefore} + navigate={navigate} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + act(() => { + setActiveSessionStoredIdRotation({ + nextStoredSessionId: tipAfter, + previousStoredSessionId: tipBefore, + runtimeSessionId + }) + }) + + await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter)) + expect(takeSessionDraft(tipBefore).text).toBe('typed during gap') + expect(takeSessionDraft(tipAfter).text).toBe('') + + clearSessionDraft(tipBefore) + clearSessionDraft(tipAfter) + setActiveSessionId(null) + }) + + it('parks an in-progress composer draft on the lineage root across tip rotation', async () => { + // Desktop draft must stay on the durable composer key (lineage root), not + // move onto the fresh tip — ChatBar scopes drafts via resolveComposerSessionKey. + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + const runtimeSessionId = 'runtime-desktop-thinking' + const activeSessionIdRef: MutableRefObject = { current: runtimeSessionId } + const selectedStoredSessionIdRef: MutableRefObject = { current: tipBefore } + const navigate = vi.fn() + const typedWhileThinking = 'follow up I am still typing during thinking' + + setSessions([storedSession({ id: tipAfter, message_count: 2, _lineage_root_id: tipBefore })]) + stashSessionDraft(tipBefore, typedWhileThinking, []) + setSelectedStoredSessionId(tipBefore) + setActiveSessionId(runtimeSessionId) + + render( + tipBefore} + navigate={navigate} + selectedStoredSessionIdRef={selectedStoredSessionIdRef} + /> + ) + + act(() => { + setActiveSessionStoredIdRotation({ + nextStoredSessionId: tipAfter, + previousStoredSessionId: tipBefore, + runtimeSessionId + }) + }) + + await waitFor(() => expect($selectedStoredSessionId.get()).toBe(tipAfter)) + // Durable key remains the lineage root — same scope ChatBar will keep using. + expect(takeSessionDraft(tipBefore).text).toBe(typedWhileThinking) + expect(takeSessionDraft(tipAfter).text).toBe('') + + clearSessionDraft(tipBefore) + clearSessionDraft(tipAfter) + setActiveSessionId(null) + setSessions([]) + }) + it('does not overwrite a newer route intent before its resume effect has synchronized selection', async () => { const activeSessionIdRef: MutableRefObject = { current: 'runtime-A' } const selectedStoredSessionIdRef: MutableRefObject = { current: 'stored-A' } diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 6934d00208c5..2eaa406adf3a 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -8,7 +8,8 @@ import { useI18n } from '@/i18n' import { type ChatMessage, preserveLocalAssistantErrors, toChatMessages } from '@/lib/chat-messages' import { isMissingRpcMethod } from '@/lib/gateway-rpc' import { setSessionYolo } from '@/lib/yolo-session' -import { clearQueuedPrompts } from '@/store/composer-queue' +import { migrateSessionDraft } from '@/store/composer' +import { clearQueuedPrompts, migrateQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { clearNotifications, notify, notifyError } from '@/store/notifications' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalizeProfileKey } from '@/store/profile' @@ -25,6 +26,7 @@ import { $sessions, $yoloActive, type NewChatWorkspaceTarget, + resolveComposerSessionKey, sessionPinId, setActiveSessionId, setActiveSessionStoredIdRotation, @@ -233,14 +235,34 @@ export function useSessionActions({ return } - setSelectedStoredSessionId(storedIdRotation.nextStoredSessionId) - selectedStoredSessionIdRef.current = storedIdRotation.nextStoredSessionId + // Park unsent draft/queue on the durable lineage key (not the new tip). + // ChatBar scopes composer state on resolveComposerSessionKey(); migrating + // onto the tip while the composer is still bound to the root can lose newer + // live editor text on a brief remount. If the new tip row is not in + // $sessions yet, resolveComposerSessionKey falls back to the tip id — prefer + // the previous id (usually the lineage root) in that gap. + const previousId = storedIdRotation.previousStoredSessionId + const nextId = storedIdRotation.nextStoredSessionId + const sessions = $sessions.get() + const resolvedNext = resolveComposerSessionKey(nextId, sessions) + const durableKey = + resolvedNext && resolvedNext !== nextId + ? resolvedNext + : (resolveComposerSessionKey(previousId, sessions) ?? previousId) + + migrateSessionDraft(previousId, durableKey) + migrateSessionDraft(nextId, durableKey) + migrateQueuedPrompts(previousId, durableKey) + migrateQueuedPrompts(nextId, durableKey) + + setSelectedStoredSessionId(nextId) + selectedStoredSessionIdRef.current = nextId // A route overlay/page has no routed session id, but the underlying selected // chat still needs to follow the continuation. Update that selection in // place without navigating out of the surface the user deliberately opened. - if (routedStoredSessionId === storedIdRotation.previousStoredSessionId) { - navigate(sessionRoute(storedIdRotation.nextStoredSessionId), { replace: true }) + if (routedStoredSessionId === previousId) { + navigate(sessionRoute(nextId), { replace: true }) } }, [activeSessionIdRef, getRoutedStoredSessionId, navigate, selectedStoredSessionIdRef, storedIdRotation]) diff --git a/apps/desktop/src/store/composer.test.ts b/apps/desktop/src/store/composer.test.ts index b57242052db5..1b3a174ba9f4 100644 --- a/apps/desktop/src/store/composer.test.ts +++ b/apps/desktop/src/store/composer.test.ts @@ -5,6 +5,7 @@ import { addComposerAttachment, clearSessionDraft, type ComposerAttachment, + migrateSessionDraft, removeComposerAttachment, SESSION_DRAFTS_STORAGE_KEY, stashSessionDraft, @@ -106,4 +107,29 @@ describe('session drafts', () => { expect(takeSessionDraft('session-a').attachments[0]?.label).toBe('doc.pdf') }) + + it('migrates a tip-keyed draft onto the post-compression tip', () => { + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + + stashSessionDraft(tipBefore, 'half typed while thinking', []) + + expect(migrateSessionDraft(tipBefore, tipAfter)).toBe(true) + expect(takeSessionDraft(tipAfter).text).toBe('half typed while thinking') + expect(takeSessionDraft(tipBefore).text).toBe('') + + clearSessionDraft(tipAfter) + }) + + it('does not overwrite a non-empty destination draft during migration', () => { + stashSessionDraft('from', 'old tip draft', []) + stashSessionDraft('to', 'already typed on new tip', []) + + expect(migrateSessionDraft('from', 'to')).toBe(false) + expect(takeSessionDraft('to').text).toBe('already typed on new tip') + expect(takeSessionDraft('from').text).toBe('old tip draft') + + clearSessionDraft('from') + clearSessionDraft('to') + }) }) diff --git a/apps/desktop/src/store/composer.ts b/apps/desktop/src/store/composer.ts index 156517c33053..b984fe8bfc3b 100644 --- a/apps/desktop/src/store/composer.ts +++ b/apps/desktop/src/store/composer.ts @@ -171,6 +171,41 @@ export function takeSessionDraft(scope: string | null | undefined): SessionDraft export const clearSessionDraft = (scope: string | null | undefined) => stashSessionDraft(scope, '', []) +/** + * Move a stashed composer draft from one session key onto another. + * + * Auto-compression rotates the live stored tip id (root → continuation) while + * the user may still be typing. Drafts keyed on the obsolete tip would otherwise + * vanish from the composer when selection follows the new tip. No-op unless both + * keys resolve, differ, and the source has content. Does not overwrite a + * non-empty destination draft. + */ +export function migrateSessionDraft(fromKey: string | null | undefined, toKey: string | null | undefined): boolean { + const from = draftKey(fromKey) + const to = draftKey(toKey) + + if (!fromKey || !toKey || from === to) { + return false + } + + const source = draftsBySession.get(from) + + if (!source || (!source.text.trim() && source.attachments.length === 0)) { + return false + } + + const dest = draftsBySession.get(to) + + if (dest && (dest.text.trim() || dest.attachments.length > 0)) { + return false + } + + stashSessionDraft(toKey, source.text, source.attachments) + clearSessionDraft(fromKey) + + return true +} + export function setComposerDraft(value: string) { $composerDraft.set(value) } diff --git a/apps/desktop/src/store/session.test.ts b/apps/desktop/src/store/session.test.ts index a3c816c762f6..e4b9b08b66d0 100644 --- a/apps/desktop/src/store/session.test.ts +++ b/apps/desktop/src/store/session.test.ts @@ -12,6 +12,7 @@ import { $unreadFinishedSessionIds, applyConfiguredDefaultProjectDir, mergeSessionPage, + resolveComposerSessionKey, sessionPinId, setCurrentCwd, setSelectedStoredSessionId, @@ -85,6 +86,21 @@ describe('sessionPinId', () => { }) }) +describe('resolveComposerSessionKey', () => { + it('keeps the lineage root across compression tip rotation', () => { + const tipBefore = '20260720_062637_ad96b3' + const tipAfter = '20260720_071049_a28905' + const sessions = [session({ id: tipAfter, _lineage_root_id: tipBefore })] + + expect(resolveComposerSessionKey(tipBefore, [session({ id: tipBefore })])).toBe(tipBefore) + expect(resolveComposerSessionKey(tipAfter, sessions)).toBe(tipBefore) + }) + + it('falls back to the live id when the tip row is not loaded yet', () => { + expect(resolveComposerSessionKey('tip-new', [])).toBe('tip-new') + }) +}) + describe('mergeSessionPage', () => { it('returns the server page untouched when there is nothing to keep', () => { const previous = [session({ id: 'a' }), session({ id: 'b' })] diff --git a/apps/desktop/src/store/session.ts b/apps/desktop/src/store/session.ts index 9d596a3b822e..3c8ea0b714e5 100644 --- a/apps/desktop/src/store/session.ts +++ b/apps/desktop/src/store/session.ts @@ -144,6 +144,27 @@ export const sessionMatchesStoredId = ( storedSessionId: string ): boolean => session.id === storedSessionId || session._lineage_root_id === storedSessionId +/** + * Stable composer + `/queue` scope for a selected stored session. + * + * Same durability rule as {@link sessionPinId}: prefer the lineage root so + * auto-compression tip rotation does not remount the composer onto an empty + * draft/queue key mid-keystroke. Falls back to the live id when the row is + * not in the in-memory list yet. + */ +export function resolveComposerSessionKey( + selectedSessionId: string | null | undefined, + sessions: readonly Pick[] +): string | null { + if (!selectedSessionId) { + return null + } + + const row = sessions.find(session => sessionMatchesStoredId(session, selectedSessionId)) + + return row ? sessionPinId(row) : selectedSessionId +} + /** Merge a fresh server session page into the in-memory list, keeping any * row the server omitted that we still want visible — both still-"working" * sessions and pinned sessions. From 477c08b44766ace8b890faa72bf82ecbcf2b3ba8 Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 01:02:12 +0000 Subject: [PATCH 54/72] fmt(js): `npm run fix` on merge (#68305) Co-authored-by: github-actions[bot] --- apps/desktop/src/app/chat/index.tsx | 5 +++-- .../src/app/session/hooks/use-session-actions.test.tsx | 2 +- .../src/app/session/hooks/use-session-actions/index.ts | 1 + 3 files changed, 5 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/chat/index.tsx b/apps/desktop/src/app/chat/index.tsx index f135b0aae959..675d17b4d572 100644 --- a/apps/desktop/src/app/chat/index.tsx +++ b/apps/desktop/src/app/chat/index.tsx @@ -20,12 +20,12 @@ import type { ChatMessage } from '@/lib/chat-messages' import { quickModelOptions, sessionTitle } from '@/lib/chat-runtime' import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime' import { cn } from '@/lib/utils' +import { migrateSessionDraft } from '@/store/composer' +import { migrateQueuedPrompts } from '@/store/composer-queue' import { $pinnedSessionIds } from '@/store/layout' import { $petActive } from '@/store/pet' import { $petOverlayActive } from '@/store/pet-overlay' import { $gatewaySwapTarget, $profiles } from '@/store/profile' -import { migrateSessionDraft } from '@/store/composer' -import { migrateQueuedPrompts } from '@/store/composer-queue' import { $contextSuggestions, $freshDraftReady, @@ -281,6 +281,7 @@ export function ChatView({ const selectedSessionId = useStore(view.$storedId) const sessions = useStore($sessions) const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId) + // Durable composer/queue scope (lineage root) so auto-compression tip rotation // does not wipe an in-progress draft or orphan /queue entries. const queueSessionKey = useMemo( diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 854a0085925d..7eb6e13a82be 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -5,9 +5,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { getSessionMessages, type SessionInfo } from '@/hermes' import { createClientSessionState } from '@/lib/chat-runtime' +import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile } from '@/store/profile' import { $projectScope, $projectTree, ALL_PROJECTS } from '@/store/projects' -import { clearSessionDraft, stashSessionDraft, takeSessionDraft } from '@/store/composer' import { $activeSessionId, $activeSessionStoredIdRotation, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 2eaa406adf3a..9deb479e7b77 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -245,6 +245,7 @@ export function useSessionActions({ const nextId = storedIdRotation.nextStoredSessionId const sessions = $sessions.get() const resolvedNext = resolveComposerSessionKey(nextId, sessions) + const durableKey = resolvedNext && resolvedNext !== nextId ? resolvedNext From 79af4725829288bf00b5bea5aff3a32996b9704b Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Mon, 20 Jul 2026 23:23:40 -0500 Subject: [PATCH 55/72] fix(cli,tui): recall real paste content on up-arrow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Large pastes collapse to a placeholder in the composer, but input history stored the placeholder — so up-arrow recall showed a truncated reference (CLI) or lost the content entirely (TUI, where the `[[…]]` label has no backing snip after submit). Store the expanded content in history instead: - CLI: `_inline_pastes()` expands `[Pasted text #N -> file]` into the buffer before `reset(append_to_history=True)`; also reused by the external editor (dedup). History nav suppresses re-collapse of recalled content. - TUI: `dispatchSubmission` pushes `expandSnips(pasteSnips)(full)`; idempotent on label-free text so re-submitting a recalled entry stays stable. --- cli.py | 59 ++++++++++++++++++++++----- tests/cli/test_cli_external_editor.py | 38 +++++++++++++++++ ui-tui/src/app/useSubmission.test.ts | 34 +++++++++++++++ ui-tui/src/app/useSubmission.ts | 26 +++++++++--- 4 files changed, 141 insertions(+), 16 deletions(-) create mode 100644 ui-tui/src/app/useSubmission.test.ts diff --git a/cli.py b/cli.py index 20929befe882..a1544a681dfa 100644 --- a/cli.py +++ b/cli.py @@ -6093,15 +6093,10 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): _cprint(f"{_DIM}No active input buffer is available for the external editor.{_RST}") return False try: - existing_text = getattr(target_buffer, "text", "") - expanded_text = self._expand_paste_references(existing_text) - if expanded_text != existing_text and hasattr(target_buffer, "text"): - self._skip_paste_collapse = True - target_buffer.text = expanded_text - if hasattr(target_buffer, "cursor_position"): - target_buffer.cursor_position = len(expanded_text) - # Set skip flag (again) so the text-change event fired when the - # editor closes does not re-collapse the returned content. + # Inline pastes so the editor (and the draft it submits) sees real + # content; skip flag unconditionally so the editor-close text-change + # doesn't re-collapse it, even when there was nothing to inline. + self._inline_pastes(target_buffer) self._skip_paste_collapse = True # Open the editor, then submit the saved draft on a clean exit — # matching the TUI's Ctrl+G (openEditor), which sends the buffer @@ -6173,6 +6168,27 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): if app is not None: app.invalidate() + def _inline_pastes(self, buffer) -> None: + """Replace collapsed-paste placeholders in ``buffer`` with real content. + + A big paste shows as a compact ``[Pasted text #N -> file]`` placeholder, + but history recall and the external editor need the actual text — a bare + reference is useless once the file is gone or on another machine. Inlining + before ``reset(append_to_history=True)`` also lets prompt_toolkit persist + the content through its normal path. Sets ``_skip_paste_collapse`` so the + ensuing text-change doesn't re-collapse it. + """ + try: + existing = getattr(buffer, "text", "") + expanded = self._expand_paste_references(existing) + if expanded != existing and hasattr(buffer, "text"): + self._skip_paste_collapse = True + buffer.text = expanded + if hasattr(buffer, "cursor_position"): + buffer.cursor_position = len(expanded) + except Exception: + logger.debug("Failed to inline paste placeholders", exc_info=True) + def _reset_input_buffer(self, buffer) -> None: """Clear an input buffer after a programmatic submit (best-effort).""" try: @@ -13369,6 +13385,9 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): pass else: self._pending_input.put(payload) + # History stores real pasted content, not the placeholder, so + # up-arrow recall restores the actual text. + self._inline_pastes(event.app.current_buffer) event.app.current_buffer.reset(append_to_history=True) _bind_prompt_submit_keys(kb, handle_enter) @@ -13579,15 +13598,33 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): lambda: not self._clarify_state and not self._approval_state and not self._slash_confirm_state and not self._sudo_state and not self._secret_state and not self._model_picker_state ) + def _recall_without_recollapse(buf, move): + """Run a history-navigation move, suppressing paste-collapse. + + Recalled history can hold the full text of a paste that was + collapsed to a placeholder at submit time. Loading it back into the + buffer looks exactly like a fresh large paste to ``_on_text_changed`` + and would be re-collapsed. Set the skip flag around the move; if the + move didn't change the text (plain cursor movement), clear the flag + so a later real paste still collapses. + """ + before = buf.text + self._skip_paste_collapse = True + move() + if buf.text == before: + self._skip_paste_collapse = False + @kb.add('up', filter=_normal_input) def history_up(event): """Up arrow: browse history when on first line, else move cursor up.""" - event.app.current_buffer.auto_up(count=event.arg) + buf = event.app.current_buffer + _recall_without_recollapse(buf, lambda: buf.auto_up(count=event.arg)) @kb.add('down', filter=_normal_input) def history_down(event): """Down arrow: browse history when on last line, else move cursor down.""" - event.app.current_buffer.auto_down(count=event.arg) + buf = event.app.current_buffer + _recall_without_recollapse(buf, lambda: buf.auto_down(count=event.arg)) @kb.add('c-l') def handle_ctrl_l(event): diff --git a/tests/cli/test_cli_external_editor.py b/tests/cli/test_cli_external_editor.py index 082c5e40fb89..639449517cb9 100644 --- a/tests/cli/test_cli_external_editor.py +++ b/tests/cli/test_cli_external_editor.py @@ -103,3 +103,41 @@ def test_open_external_editor_sets_skip_collapse_flag_during_expansion(tmp_path) # Flag is consumed by _on_text_changed, but since no handler is attached # in tests it stays True until the handler resets it. assert cli_obj._skip_paste_collapse is True + + +def test_inline_pastes_stores_full_content(tmp_path): + """History should recall the actual pasted text, not the placeholder.""" + cli_obj = _make_cli() + paste_file = tmp_path / "paste.txt" + paste_file.write_text("line one\nline two", encoding="utf-8") + buffer = _FakeBuffer(text=f"[Pasted text #1: 2 lines \u2192 {paste_file}]") + + cli_obj._inline_pastes(buffer) + + assert buffer.text == "line one\nline two" + assert buffer.cursor_position == len("line one\nline two") + # Skip flag set so the resulting text-change doesn't re-collapse. + assert cli_obj._skip_paste_collapse is True + + +def test_inline_pastes_leaves_plain_text_untouched(): + """No placeholder → buffer text and collapse flag are unchanged.""" + cli_obj = _make_cli() + buffer = _FakeBuffer(text="just a normal message") + + cli_obj._inline_pastes(buffer) + + assert buffer.text == "just a normal message" + assert cli_obj._skip_paste_collapse is False + + +def test_inline_pastes_missing_file_keeps_placeholder(tmp_path): + """A recalled reference whose file is gone stays as the placeholder.""" + cli_obj = _make_cli() + placeholder = f"[Pasted text #1: 2 lines \u2192 {tmp_path / 'gone.txt'}]" + buffer = _FakeBuffer(text=placeholder) + + cli_obj._inline_pastes(buffer) + + assert buffer.text == placeholder + assert cli_obj._skip_paste_collapse is False diff --git a/ui-tui/src/app/useSubmission.test.ts b/ui-tui/src/app/useSubmission.test.ts new file mode 100644 index 000000000000..34202104dd47 --- /dev/null +++ b/ui-tui/src/app/useSubmission.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vitest' + +import type { PasteSnippet } from './interfaces.js' +import { expandSnips } from './useSubmission.js' + +const snip = (label: string, text: string): PasteSnippet => ({ label, text }) + +describe('expandSnips (paste history recall)', () => { + it('replaces a collapsed paste label with its full content', () => { + const label = '[[ hello.. [3 lines] .. world ]]' + const full = `here: ${label} done` + const expand = expandSnips([snip(label, 'hello\nfoo\nworld')]) + + expect(expand(full)).toBe('here: hello\nfoo\nworld done') + }) + + it('is a no-op for already-expanded / label-free text (recall round-trip)', () => { + const expanded = 'hello\nfoo\nworld' + // Re-submitting a recalled history entry has no snips and no labels. + expect(expandSnips([])(expanded)).toBe(expanded) + }) + + it('expands repeated identical labels in submission order', () => { + const label = '[[ x [1 lines] ]]' + const expand = expandSnips([snip(label, 'first'), snip(label, 'second')]) + + expect(expand(`${label} then ${label}`)).toBe('first then second') + }) + + it('leaves an unmatched label intact', () => { + const label = '[[ orphan [2 lines] ]]' + expect(expandSnips([])(label)).toBe(label) + }) +}) diff --git a/ui-tui/src/app/useSubmission.ts b/ui-tui/src/app/useSubmission.ts index 881257e386f6..a5c484cc288c 100644 --- a/ui-tui/src/app/useSubmission.ts +++ b/ui-tui/src/app/useSubmission.ts @@ -16,7 +16,7 @@ import { getUiState, patchUiState } from './uiStore.js' const DOUBLE_ENTER_MS = 450 -const expandSnips = (snips: PasteSnippet[]) => { +export const expandSnips = (snips: PasteSnippet[]) => { const byLabel = new Map() for (const { label, text } of snips) { @@ -217,9 +217,14 @@ export function useSubmission(opts: UseSubmissionOptions) { return } + // History stores expanded paste content, not the `[[…]]` label: snips + // are cleared on submit, so recall must be self-contained. Idempotent on + // label-free text, so re-submitting a recalled entry stays stable. + const toHistory = expandSnips(composerState.pasteSnips)(full) + if (looksLikeSlashCommand(full)) { appendMessage({ kind: 'slash', role: 'system', text: full }) - composerActions.pushHistory(full) + composerActions.pushHistory(toHistory) slashRef.current(full) composerActions.clearIn() @@ -235,7 +240,7 @@ export function useSubmission(opts: UseSubmissionOptions) { const live = getUiState() if (!live.sid) { - composerActions.pushHistory(full) + composerActions.pushHistory(toHistory) composerActions.enqueue(full) composerActions.clearIn() @@ -271,7 +276,7 @@ export function useSubmission(opts: UseSubmissionOptions) { return sendQueued(picked) } - composerActions.pushHistory(full) + composerActions.pushHistory(toHistory) if (getUiState().busy) { return handleBusyInput(full) @@ -285,7 +290,18 @@ export function useSubmission(opts: UseSubmissionOptions) { send(full) }, - [appendMessage, composerActions, composerRefs, handleBusyInput, interpolate, send, sendQueued, shellExec, slashRef] + [ + appendMessage, + composerActions, + composerRefs, + composerState.pasteSnips, + handleBusyInput, + interpolate, + send, + sendQueued, + shellExec, + slashRef + ] ) const submit = useCallback( From 3f820a1c7c3c1d3a188dc08daf5fd23cda3dd6c4 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Sun, 19 Jul 2026 09:50:42 +0000 Subject: [PATCH 56/72] fix(cli): suppress CPR on POSIX local TTYs under load Delayed ESC[6n replies leak as ^[[row;colR into the classic CLI on SSH/slow PTYs (#13870) and on local POSIX TTYs under heavy subagent load. Suppress CPR on non-Windows platforms (layout hint only); keep native Windows on prompt_toolkit's default pending native coverage. Wire selection through _select_classic_cli_pt_output. --- cli.py | 90 ++++++++++++++++++++++++++-------------------------------- 1 file changed, 41 insertions(+), 49 deletions(-) diff --git a/cli.py b/cli.py index a1544a681dfa..a4d0116c81ac 100644 --- a/cli.py +++ b/cli.py @@ -3270,28 +3270,28 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None: pass -def _terminal_may_leak_cpr() -> bool: - """Detect terminals where CPR (ESC[6n) replies are likely to leak. +def _terminal_may_leak_cpr(*, platform: str | None = None) -> bool: + """Whether classic CLI should suppress prompt_toolkit CPR (ESC[6n) queries. - The CPR leak in #13870 is environment-specific: it shows up over SSH + - cloudflared/mux tunnels and slow PTYs, where the terminal's - ``ESC[;R`` reply round-trips slowly enough to race past the input - parser and land in the display as raw ``20;1R`` text (and the pending-CPR - future can stall the renderer, freezing the prompt). On a local terminal the - reply returns instantly and cleanly, so CPR works fine and there is nothing - to fix — we leave prompt_toolkit's default behavior untouched there. + Delayed CPR replies (``ESC[;R`` / visible ``^[[;R``) + leak into the status line and can freeze input when the reply is slow + (#13870 on SSH/slow PTYs). The same race hits **local POSIX** TTYs under + heavy subagent / status-line load — deterministic delayed-CPR PTY harness + in ``tests/cli/test_cpr_local_leak.py``. - We only suppress CPR on a remote/tunneled link (SSH env vars) or when the - user has explicitly opted out via prompt_toolkit's own ``PROMPT_TOOLKIT_NO_CPR`` - escape hatch. Keeping this narrow (not the broader WSL/Ghostty/Windows set - that ``_preserve_ctrl_enter_newline`` keys on) means the only behavior change - lands exactly where the bug reproduces. + Policy: + - ``PROMPT_TOOLKIT_NO_CPR=1`` → always suppress + - native Windows (``win32``) → keep prompt_toolkit's default for now + (no native-Windows Application coverage yet); still honor NO_CPR + - all other platforms → suppress (CPR is only a layout hint; heuristic + height is enough). SSH env is no longer required to trigger this. """ if os.environ.get("PROMPT_TOOLKIT_NO_CPR", "") == "1": return True - if any(os.environ.get(v) for v in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY")): - return True - return False + plat = sys.platform if platform is None else platform + if plat == "win32": + return False + return True def _build_cpr_disabled_output(stdout): @@ -3299,23 +3299,14 @@ def _build_cpr_disabled_output(stdout): prompt_toolkit's renderer sends ``ESC[6n`` (Device Status Report) to learn the cursor row before painting in non-fullscreen mode; the terminal replies - ``ESC[;R``. Over SSH + cloudflared/mux tunnels and some slow PTYs - these replies race past the input parser and land in the display as raw text - like ``20;1R21;1R``, and the pending-CPR future can stall the renderer so the - prompt appears frozen after the agent's final answer (see #13870). + ``ESC[;R``. When that reply is delayed it races into the display + as raw ``^[[39;1R`` and can stall the renderer's pending-CPR future + (#13870; also local POSIX under heavy subagent load). - Constructing the output with ``enable_cpr=False`` makes the renderer mark CPR - ``NOT_SUPPORTED`` up front, so ``ESC[6n`` is never sent and no CPR response - can leak. This is the root-cause counterpart to the input-side scrubbing in - ``_strip_leaked_terminal_responses`` — that cleans leaks after the fact; this - stops them at the source. The UI is otherwise identical (prompt_toolkit uses - its heuristic available-height fallback, which it already relies on whenever a - terminal doesn't answer CPR). - - This is only invoked on terminals flagged by ``_terminal_may_leak_cpr()`` — - CPR is a layout hint, not a speed optimization, and it works fine locally, so - we leave the upstream default in place on local terminals and only suppress it - where the leak actually reproduces. + Constructing the output with ``enable_cpr=False`` marks CPR + ``NOT_SUPPORTED`` so ``ESC[6n`` is never sent. prompt_toolkit then uses its + heuristic available-height fallback. Input-side + ``_strip_leaked_terminal_responses`` remains belt-and-suspenders. Note: ``Vt100_Output.from_pty()`` does NOT expose ``enable_cpr`` in prompt_toolkit 3.x, so we reproduce its ``get_size`` setup and call the @@ -3341,6 +3332,18 @@ def _build_cpr_disabled_output(stdout): return None +def _select_classic_cli_pt_output(stdout): + """Select prompt_toolkit Output for classic-CLI Application construction. + + Returns a CPR-disabled ``Vt100_Output`` when ``_terminal_may_leak_cpr()`` + is true, otherwise ``None`` so Application keeps prompt_toolkit's default + output (Windows preserve-default path). + """ + if not _terminal_may_leak_cpr(): + return None + return _build_cpr_disabled_output(stdout) + + def _strip_leaked_terminal_responses_with_meta(text: str) -> tuple[str, bool]: """Strip leaked terminal control-response sequences from user input. @@ -14859,22 +14862,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin): } style = PTStyle.from_dict(self._build_tui_style_dict()) - # Disable CPR (Cursor Position Report) at the source so prompt_toolkit - # never sends ESC[6n cursor-position queries — but only on terminals - # where the reply is likely to leak. Over SSH/cloudflared tunnels and - # slow PTYs the CPR replies (ESC[;R) leak into the display as - # raw "20;1R21;1R" text and can stall the renderer's pending-CPR future, - # freezing the prompt after the agent's final answer (#13870). CPR is a - # layout hint, not a speed optimization, and it works fine locally, so we - # leave prompt_toolkit's default untouched on local terminals and only - # suppress it where the bug reproduces. None (local, or build failure) - # falls back to the default output; the input-side scrubbing in - # _strip_leaked_terminal_responses still guards against any leaks. - _cpr_disabled_output = ( - _build_cpr_disabled_output(sys.stdout) - if _terminal_may_leak_cpr() - else None - ) + # Select CPR-disabled output when _terminal_may_leak_cpr() says so + # (POSIX local + SSH; Windows keeps PT default — see helper docs). + # None falls back to prompt_toolkit's default output; input scrubbing + # in _strip_leaked_terminal_responses still guards residual leaks. + _cpr_disabled_output = _select_classic_cli_pt_output(sys.stdout) # Create the application app = Application( From f6d82e1267b47e6bab720f2d0d580061adccfd5d Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Sun, 19 Jul 2026 09:50:42 +0000 Subject: [PATCH 57/72] test(cli): prove local CPR leak and Application CPR-disabled wiring Add a delayed-CPR PTY harness (no SSH) plus selection/Application assertions for POSIX local and Windows preserve-default. Update the gating unit test to the new contract. --- tests/cli/test_cli_init.py | 23 ++-- tests/cli/test_cpr_local_leak.py | 174 +++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 15 deletions(-) create mode 100644 tests/cli/test_cpr_local_leak.py diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index a990f6bf3427..52f54a419682 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -289,30 +289,23 @@ class TestPromptToolkitTerminalCompatibility: result = _build_cpr_disabled_output(_NoFileno()) assert result is None or result.enable_cpr is False - def test_cpr_gating_local_vs_tunnel(self, monkeypatch): - """CPR is only suppressed on tunneled links / explicit opt-out. + def test_cpr_gating_posix_local_and_windows_preserve(self, monkeypatch): + """POSIX suppresses CPR without SSH; native Windows keeps PT default. - CPR works fine on local terminals and is only a layout hint, so the fix - for #13870 must not change default behavior locally — it gates on - _terminal_may_leak_cpr(). Local (no SSH env) -> CPR left enabled; - SSH session or PROMPT_TOOLKIT_NO_CPR=1 -> CPR suppressed. + Broader coverage (Application wiring + delayed-CPR PTY repro) lives in + ``tests/cli/test_cpr_local_leak.py``. """ from cli import _terminal_may_leak_cpr for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"): monkeypatch.delenv(var, raising=False) - # Local terminal: leave prompt_toolkit's default (CPR on) untouched. - assert _terminal_may_leak_cpr() is False + assert _terminal_may_leak_cpr(platform="linux") is True + assert _terminal_may_leak_cpr(platform="darwin") is True + assert _terminal_may_leak_cpr(platform="win32") is False - # SSH session: the tunnel where the leak reproduces. - monkeypatch.setenv("SSH_CONNECTION", "10.0.0.1 22 10.0.0.2 51234") - assert _terminal_may_leak_cpr() is True - monkeypatch.delenv("SSH_CONNECTION", raising=False) - - # prompt_toolkit's own explicit opt-out is honored. monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1") - assert _terminal_may_leak_cpr() is True + assert _terminal_may_leak_cpr(platform="win32") is True class TestSingleQueryState: diff --git a/tests/cli/test_cpr_local_leak.py b/tests/cli/test_cpr_local_leak.py new file mode 100644 index 000000000000..c2201f3d2039 --- /dev/null +++ b/tests/cli/test_cpr_local_leak.py @@ -0,0 +1,174 @@ +"""Local CPR leak reproduction + classic-CLI Application output selection. + +Addresses review on #67377: + +* Deterministic local-PTY proof that delayed CPR replies leak as + ``ESC[row;colR`` / ``^[[row;colR`` when ``enable_cpr=True`` (no SSH). +* Integration-level assertion that, with no SSH env vars, classic CLI + output selection wires a CPR-disabled Output into Application on POSIX. +* Native Windows keeps prompt_toolkit's default output selection. +""" + +from __future__ import annotations + +import os +import select +import sys +import threading +import time + +import pytest + +from cli import ( + _build_cpr_disabled_output, + _select_classic_cli_pt_output, + _terminal_may_leak_cpr, +) + + +@pytest.fixture(autouse=True) +def _clear_cpr_env(monkeypatch): + for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"): + monkeypatch.delenv(var, raising=False) + + +class TestClassicCliOutputSelection: + def test_posix_local_without_ssh_selects_cpr_disabled_output(self, monkeypatch): + """Changed contract: no SSH vars, still CPR-disabled on POSIX.""" + monkeypatch.setattr(sys, "platform", "linux") + assert _terminal_may_leak_cpr() is True + out = _select_classic_cli_pt_output(sys.stdout) + assert out is not None + assert out.enable_cpr is False + + def test_application_receives_cpr_not_supported_without_ssh(self, monkeypatch): + """Classic-CLI Application construction must get CPR-disabled output.""" + from prompt_toolkit.application import Application + from prompt_toolkit.layout import FormattedTextControl, Layout, Window + from prompt_toolkit.renderer import CPR_Support + + monkeypatch.setattr(sys, "platform", "linux") + out = _select_classic_cli_pt_output(sys.stdout) + assert out is not None + + app = Application( + layout=Layout(Window(FormattedTextControl("x"))), + output=out, + full_screen=False, + ) + assert app.renderer.cpr_support == CPR_Support.NOT_SUPPORTED + + def test_windows_preserves_default_output_selection(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + assert _terminal_may_leak_cpr() is False + assert _select_classic_cli_pt_output(sys.stdout) is None + + def test_windows_honors_explicit_no_cpr(self, monkeypatch): + monkeypatch.setattr(sys, "platform", "win32") + monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1") + assert _terminal_may_leak_cpr() is True + out = _select_classic_cli_pt_output(sys.stdout) + # Build may return None if stdout is not a real tty in CI; if it + # succeeds it must be CPR-disabled. + assert out is None or out.enable_cpr is False + + +def _openpty_or_skip(): + import pty + + try: + return pty.openpty() + except OSError as exc: + pytest.skip(f"no PTY devices available: {exc}") + + +@pytest.mark.skipif(sys.platform == "win32", reason="POSIX PTY harness") +class TestDelayedCprLocalPtyLeak: + def test_delayed_cpr_reply_leaks_when_enable_cpr_true(self): + """Local (no SSH) delayed ESC[6n reply lands as ESC[39;1R on stdin.""" + import tty + + from prompt_toolkit.data_structures import Size + from prompt_toolkit.output.vt100 import Vt100_Output + + master, slave = _openpty_or_skip() + tty.setraw(slave) + slave_w = os.fdopen(os.dup(slave), "w", buffering=1) + stop = threading.Event() + queries = 0 + + def terminal() -> None: + nonlocal queries + buf = b"" + while not stop.is_set(): + r, _, _ = select.select([master], [], [], 0.05) + if not r: + continue + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + buf += chunk + while True: + idx = buf.find(b"\x1b[6n") + if idx < 0: + buf = buf[-8:] if len(buf) > 8 else buf + break + buf = buf[idx + 4 :] + queries += 1 + time.sleep(0.12) + os.write(master, b"\x1b[39;1R") + + threading.Thread(target=terminal, daemon=True).start() + out = Vt100_Output( + slave_w, lambda: Size(rows=40, columns=80), enable_cpr=True + ) + out.ask_for_cpr() + out.flush() + for i in range(4): + slave_w.write(f"\rgpt-5.6-sol Q {i}\n") + slave_w.flush() + time.sleep(0.02) + time.sleep(0.3) + + data = b"" + while True: + r, _, _ = select.select([slave], [], [], 0.05) + if not r: + break + data += os.read(slave, 4096) + + stop.set() + slave_w.close() + os.close(slave) + os.close(master) + + assert queries >= 1 + assert b"\x1b[39;1R" in data + + def test_cpr_disabled_output_sends_no_query(self): + """Hermes CPR-disabled builder must not emit ESC[6n.""" + master, slave = _openpty_or_skip() + slave_w = os.fdopen(slave, "w", buffering=1) + out = _build_cpr_disabled_output(slave_w) + assert out is not None + assert out.enable_cpr is False + + seen = b"" + + def reader() -> None: + nonlocal seen + r, _, _ = select.select([master], [], [], 0.25) + if r: + seen = os.read(master, 4096) + + threading.Thread(target=reader, daemon=True).start() + slave_w.write("status ok\n") + slave_w.flush() + # Do not call ask_for_cpr — renderer skips it when NOT_SUPPORTED. + time.sleep(0.3) + slave_w.close() + os.close(master) + assert b"\x1b[6n" not in seen From 2da64e78401fffa0bebee2bb498106bd41765f30 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 21 Jul 2026 10:51:22 +0530 Subject: [PATCH 58/72] refactor: drop platform kwarg, fix PTY test cleanup - Remove redundant platform= test seam from _terminal_may_leak_cpr(); use monkeypatch.setattr(sys, 'platform', ...) consistently in both test files. - Wrap PTY tests in try/finally for fd cleanup on assertion failure. - Guard select.select() in terminal thread against OSError after fd close (fixes PytestUnhandledThreadExceptionWarning). - Trim PR-number reference from test module docstring. --- cli.py | 10 +- tests/cli/test_cli_init.py | 13 ++- tests/cli/test_cpr_local_leak.py | 155 +++++++++++++++++-------------- 3 files changed, 99 insertions(+), 79 deletions(-) diff --git a/cli.py b/cli.py index a4d0116c81ac..287c2de25837 100644 --- a/cli.py +++ b/cli.py @@ -3270,14 +3270,13 @@ def _disable_prompt_toolkit_cpr_warning(app) -> None: pass -def _terminal_may_leak_cpr(*, platform: str | None = None) -> bool: +def _terminal_may_leak_cpr() -> bool: """Whether classic CLI should suppress prompt_toolkit CPR (ESC[6n) queries. Delayed CPR replies (``ESC[;R`` / visible ``^[[;R``) leak into the status line and can freeze input when the reply is slow - (#13870 on SSH/slow PTYs). The same race hits **local POSIX** TTYs under - heavy subagent / status-line load — deterministic delayed-CPR PTY harness - in ``tests/cli/test_cpr_local_leak.py``. + (#13870 on SSH/slow PTYs). The same race hits local POSIX TTYs under + heavy subagent / status-line load — see ``tests/cli/test_cpr_local_leak.py``. Policy: - ``PROMPT_TOOLKIT_NO_CPR=1`` → always suppress @@ -3288,8 +3287,7 @@ def _terminal_may_leak_cpr(*, platform: str | None = None) -> bool: """ if os.environ.get("PROMPT_TOOLKIT_NO_CPR", "") == "1": return True - plat = sys.platform if platform is None else platform - if plat == "win32": + if sys.platform == "win32": return False return True diff --git a/tests/cli/test_cli_init.py b/tests/cli/test_cli_init.py index 52f54a419682..48de5b7c95f4 100644 --- a/tests/cli/test_cli_init.py +++ b/tests/cli/test_cli_init.py @@ -295,17 +295,22 @@ class TestPromptToolkitTerminalCompatibility: Broader coverage (Application wiring + delayed-CPR PTY repro) lives in ``tests/cli/test_cpr_local_leak.py``. """ + import sys as _sys + from cli import _terminal_may_leak_cpr for var in ("SSH_CONNECTION", "SSH_CLIENT", "SSH_TTY", "PROMPT_TOOLKIT_NO_CPR"): monkeypatch.delenv(var, raising=False) - assert _terminal_may_leak_cpr(platform="linux") is True - assert _terminal_may_leak_cpr(platform="darwin") is True - assert _terminal_may_leak_cpr(platform="win32") is False + monkeypatch.setattr(_sys, "platform", "linux") + assert _terminal_may_leak_cpr() is True + monkeypatch.setattr(_sys, "platform", "darwin") + assert _terminal_may_leak_cpr() is True + monkeypatch.setattr(_sys, "platform", "win32") + assert _terminal_may_leak_cpr() is False monkeypatch.setenv("PROMPT_TOOLKIT_NO_CPR", "1") - assert _terminal_may_leak_cpr(platform="win32") is True + assert _terminal_may_leak_cpr() is True class TestSingleQueryState: diff --git a/tests/cli/test_cpr_local_leak.py b/tests/cli/test_cpr_local_leak.py index c2201f3d2039..efd174196ff4 100644 --- a/tests/cli/test_cpr_local_leak.py +++ b/tests/cli/test_cpr_local_leak.py @@ -1,7 +1,5 @@ """Local CPR leak reproduction + classic-CLI Application output selection. -Addresses review on #67377: - * Deterministic local-PTY proof that delayed CPR replies leak as ``ESC[row;colR`` / ``^[[row;colR`` when ``enable_cpr=True`` (no SSH). * Integration-level assertion that, with no SSH env vars, classic CLI @@ -92,83 +90,102 @@ class TestDelayedCprLocalPtyLeak: from prompt_toolkit.output.vt100 import Vt100_Output master, slave = _openpty_or_skip() - tty.setraw(slave) - slave_w = os.fdopen(os.dup(slave), "w", buffering=1) - stop = threading.Event() - queries = 0 + try: + tty.setraw(slave) + slave_w = os.fdopen(os.dup(slave), "w", buffering=1) + stop = threading.Event() + queries = 0 - def terminal() -> None: - nonlocal queries - buf = b"" - while not stop.is_set(): - r, _, _ = select.select([master], [], [], 0.05) - if not r: - continue - try: - chunk = os.read(master, 4096) - except OSError: - break - if not chunk: - break - buf += chunk - while True: - idx = buf.find(b"\x1b[6n") - if idx < 0: - buf = buf[-8:] if len(buf) > 8 else buf + def terminal() -> None: + nonlocal queries + buf = b"" + while not stop.is_set(): + try: + r, _, _ = select.select([master], [], [], 0.05) + except OSError: break - buf = buf[idx + 4 :] - queries += 1 - time.sleep(0.12) - os.write(master, b"\x1b[39;1R") + if not r: + continue + try: + chunk = os.read(master, 4096) + except OSError: + break + if not chunk: + break + buf += chunk + while True: + idx = buf.find(b"\x1b[6n") + if idx < 0: + buf = buf[-8:] if len(buf) > 8 else buf + break + buf = buf[idx + 4 :] + queries += 1 + time.sleep(0.12) + try: + os.write(master, b"\x1b[39;1R") + except OSError: + pass - threading.Thread(target=terminal, daemon=True).start() - out = Vt100_Output( - slave_w, lambda: Size(rows=40, columns=80), enable_cpr=True - ) - out.ask_for_cpr() - out.flush() - for i in range(4): - slave_w.write(f"\rgpt-5.6-sol Q {i}\n") - slave_w.flush() - time.sleep(0.02) - time.sleep(0.3) + threading.Thread(target=terminal, daemon=True).start() + out = Vt100_Output( + slave_w, lambda: Size(rows=40, columns=80), enable_cpr=True + ) + out.ask_for_cpr() + out.flush() + for i in range(4): + slave_w.write(f"\rgpt-5.6-sol Q {i}\n") + slave_w.flush() + time.sleep(0.02) + time.sleep(0.3) - data = b"" - while True: - r, _, _ = select.select([slave], [], [], 0.05) - if not r: - break - data += os.read(slave, 4096) + data = b"" + while True: + r, _, _ = select.select([slave], [], [], 0.05) + if not r: + break + data += os.read(slave, 4096) - stop.set() - slave_w.close() - os.close(slave) - os.close(master) + stop.set() + slave_w.close() - assert queries >= 1 - assert b"\x1b[39;1R" in data + assert queries >= 1 + assert b"\x1b[39;1R" in data + finally: + try: + os.close(slave) + except OSError: + pass + try: + os.close(master) + except OSError: + pass def test_cpr_disabled_output_sends_no_query(self): """Hermes CPR-disabled builder must not emit ESC[6n.""" master, slave = _openpty_or_skip() - slave_w = os.fdopen(slave, "w", buffering=1) - out = _build_cpr_disabled_output(slave_w) - assert out is not None - assert out.enable_cpr is False + try: + slave_w = os.fdopen(slave, "w", buffering=1) + out = _build_cpr_disabled_output(slave_w) + assert out is not None + assert out.enable_cpr is False - seen = b"" + seen = b"" - def reader() -> None: - nonlocal seen - r, _, _ = select.select([master], [], [], 0.25) - if r: - seen = os.read(master, 4096) + def reader() -> None: + nonlocal seen + r, _, _ = select.select([master], [], [], 0.25) + if r: + seen = os.read(master, 4096) - threading.Thread(target=reader, daemon=True).start() - slave_w.write("status ok\n") - slave_w.flush() - # Do not call ask_for_cpr — renderer skips it when NOT_SUPPORTED. - time.sleep(0.3) - slave_w.close() - os.close(master) - assert b"\x1b[6n" not in seen + threading.Thread(target=reader, daemon=True).start() + slave_w.write("status ok\n") + slave_w.flush() + # Do not call ask_for_cpr — renderer skips it when NOT_SUPPORTED. + time.sleep(0.3) + slave_w.close() + assert b"\x1b[6n" not in seen + finally: + try: + os.close(master) + except OSError: + pass From 693d3909c86a38f01226a39f77de488bdfa777c5 Mon Sep 17 00:00:00 2001 From: Gille <4317663+helix4u@users.noreply.github.com> Date: Mon, 20 Jul 2026 23:37:22 -0600 Subject: [PATCH 59/72] docs(portal): remove retired Nous Chat references --- website/docs/guides/run-hermes-with-nous-portal.md | 2 +- website/docs/integrations/nous-portal.md | 8 ++------ website/docs/integrations/providers.md | 2 +- .../current/guides/run-hermes-with-nous-portal.md | 4 ++-- .../current/integrations/nous-portal.md | 10 +++------- .../current/integrations/providers.md | 4 ++-- 6 files changed, 11 insertions(+), 19 deletions(-) diff --git a/website/docs/guides/run-hermes-with-nous-portal.md b/website/docs/guides/run-hermes-with-nous-portal.md index d20295f169ad..d25a628bbf39 100644 --- a/website/docs/guides/run-hermes-with-nous-portal.md +++ b/website/docs/guides/run-hermes-with-nous-portal.md @@ -120,7 +120,7 @@ hermes config set model.default anthropic/claude-sonnet-4.6 ### Don't pick Hermes-4 for agent work -Hermes-4-70B and Hermes-4-405B are available on the Portal at deep discounts, but they're **chat/reasoning models**, not tool-call-tuned. They will struggle with multi-step agent loops. Use them via [Nous Chat](https://chat.nousresearch.com) for conversation/research work, or through the [subscription proxy](/user-guide/features/subscription-proxy) from non-agent tools. For Hermes Agent itself, stick to the frontier agentic models above. +Hermes-4-70B and Hermes-4-405B are available on the Portal at deep discounts, but they're **chat/reasoning models**, not tool-call-tuned. They will struggle with multi-step agent loops. Use them for conversation/research work through the [subscription proxy](/user-guide/features/subscription-proxy) from non-agent tools. For Hermes Agent itself, stick to the frontier agentic models above. The Portal's own [info page](https://portal.nousresearch.com/info) carries this warning too — it's the official Nous guidance, not just a Hermes-side opinion. diff --git a/website/docs/integrations/nous-portal.md b/website/docs/integrations/nous-portal.md index 46a61d759369..c47adb10734b 100644 --- a/website/docs/integrations/nous-portal.md +++ b/website/docs/integrations/nous-portal.md @@ -1,7 +1,7 @@ --- sidebar_position: 1 title: "Nous Portal" -description: "One subscription, 300+ frontier models, the Tool Gateway, and Nous Chat — the recommended way to run Hermes Agent" +description: "One subscription, 300+ frontier models, and the Tool Gateway — the recommended way to run Hermes Agent" --- # Nous Portal @@ -60,10 +60,6 @@ Without the gateway, hooking each of those up means a Firecrawl account, a FAL a You can also enable just specific gateway tools (e.g. web search but not image generation) — see [Mixing the gateway with your own backends](#mixing-the-gateway-with-your-own-backends) below. -### Nous Chat - -Your Portal account also covers [chat.nousresearch.com](https://chat.nousresearch.com) — Nous Research's web chat interface with the same model catalog. Useful when you're away from your terminal, or for non-agent conversation work. - ### No credentials in your dotfiles Because everything routes through one OAuth-authenticated Portal session, you don't accumulate a `.env` file with a dozen long-lived API keys. The refresh token at `~/.hermes/auth.json` is the only credential on disk, and Hermes mints short-lived JWTs from it per request — see [Token handling](#token-handling) below. @@ -76,7 +72,7 @@ Because everything routes through one OAuth-authenticated Portal session, you do Nous Research's own **Hermes 4** family (Hermes-4-70B, Hermes-4-405B) is available through the Portal at heavily discounted rates. These are **frontier hybrid-reasoning chat models** — strong at math, science, instruction following, schema adherence, roleplay, and long-form writing. -They are **not recommended for use inside Hermes Agent**, however. Hermes 4 is tuned for chat and reasoning, not the rapid-fire tool-calling loop the agent relies on. Use them for [Nous Chat](https://chat.nousresearch.com), for research workflows, or via the [subscription proxy](/user-guide/features/subscription-proxy) from other tooling — but for agent work, pick a frontier agentic model from the catalog instead: +They are **not recommended for use inside Hermes Agent**, however. Hermes 4 is tuned for chat and reasoning, not the rapid-fire tool-calling loop the agent relies on. Use them for research workflows or via the [subscription proxy](/user-guide/features/subscription-proxy) from other tooling — but for agent work, pick a frontier agentic model from the catalog instead: ```bash /model anthropic/claude-sonnet-4.6 # best general-purpose agentic model diff --git a/website/docs/integrations/providers.md b/website/docs/integrations/providers.md index 343a056fe886..ed00d5b2a850 100644 --- a/website/docs/integrations/providers.md +++ b/website/docs/integrations/providers.md @@ -62,7 +62,7 @@ In the `model:` config section, you can use either `default:` or `model:` as the ### Nous Portal -[Nous Portal](https://portal.nousresearch.com) is Nous Research's unified subscription gateway and **the recommended way to run Hermes Agent**. One OAuth login covers 300+ frontier agentic models (Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, Grok, ...) plus the [Tool Gateway](/user-guide/features/tool-gateway) (web search, image generation, TTS, browser automation) plus [Nous Chat](https://chat.nousresearch.com) — billed against your Nous subscription instead of separate per-provider accounts. +[Nous Portal](https://portal.nousresearch.com) is Nous Research's unified subscription gateway and **the recommended way to run Hermes Agent**. One OAuth login covers 300+ frontier agentic models (Claude, GPT, Gemini, DeepSeek, Qwen, Kimi, GLM, MiniMax, Grok, ...) plus the [Tool Gateway](/user-guide/features/tool-gateway) (web search, image generation, TTS, browser automation) — billed against your Nous subscription instead of separate per-provider accounts. ```bash hermes setup --portal # fresh install — OAuth + provider + gateway in one command diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md index 8739d0fa3fb7..fa860d93ce86 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/guides/run-hermes-with-nous-portal.md @@ -120,7 +120,7 @@ hermes config set model.default anthropic/claude-sonnet-4.6 ### 不要在 agent 任务中使用 Hermes-4 -Hermes-4-70B 和 Hermes-4-405B 在 Portal 上以大幅折扣提供,但它们是**对话/推理模型**,并非针对工具调用优化的模型。它们在多步骤 agent 循环中表现不佳。请通过 [Nous Chat](https://chat.nousresearch.com) 将它们用于对话/研究工作,或通过[订阅代理](/user-guide/features/subscription-proxy)从非 agent 工具中使用。对于 Hermes Agent 本身,请坚持使用上述前沿 agentic 模型。 +Hermes-4-70B 和 Hermes-4-405B 在 Portal 上以大幅折扣提供,但它们是**对话/推理模型**,并非针对工具调用优化的模型。它们在多步骤 agent 循环中表现不佳。请通过[订阅代理](/user-guide/features/subscription-proxy)从非 agent 工具中将它们用于对话或研究工作。对于 Hermes Agent 本身,请坚持使用上述前沿 agentic 模型。 Portal 的[信息页面](https://portal.nousresearch.com/info)也有此说明——这是 Nous 官方指导,并非仅代表 Hermes 一方的意见。 @@ -270,4 +270,4 @@ hermes auth logout nous # 清除本地 refresh token - **[订阅代理](/user-guide/features/subscription-proxy)** — 在非 Hermes 工具中使用你的 Portal 订阅 - **[语音模式](/user-guide/features/voice-mode)** — 在 Portal 订阅上配置语音对话 - **[OAuth over SSH](/guides/oauth-over-ssh)** — 远程/无头主机登录方案 -- **[Profiles](/user-guide/profiles)** — 在多个 Hermes 配置之间共享一个 Portal 登录 \ No newline at end of file +- **[Profiles](/user-guide/profiles)** — 在多个 Hermes 配置之间共享一个 Portal 登录 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md index 265abb4aed10..275f77a0e84c 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/nous-portal.md @@ -1,7 +1,7 @@ --- sidebar_position: 1 title: "Nous Portal" -description: "一个订阅,300+ 前沿模型,Tool Gateway,以及 Nous Chat —— 运行 Hermes Agent 的推荐方式" +description: "一个订阅,300+ 前沿模型,以及 Tool Gateway —— 运行 Hermes Agent 的推荐方式" --- # Nous Portal @@ -56,10 +56,6 @@ Portal 代理了来自整个生态系统的精选 agentic 模型目录——统 你也可以只启用特定的 gateway 工具(例如只开启网页搜索,不开启图像生成)——详见下方[将 gateway 与自有后端混用](#mixing-the-gateway-with-your-own-backends)。 -### Nous Chat - -你的 Portal 账号同样覆盖 [chat.nousresearch.com](https://chat.nousresearch.com)——Nous Research 的网页对话界面,使用相同的模型目录。适合离开终端时使用,或用于非 agent 的普通对话场景。 - ### 凭证不落入 dotfiles 由于所有请求都通过一个经 OAuth 认证的 Portal 会话路由,你不会积累一个包含十几个长期 API 密钥的 `.env` 文件。磁盘上唯一的凭证是 `~/.hermes/auth.json` 中的 refresh token(刷新令牌),Hermes 会在每次请求时从中生成短期 JWT——详见下方[令牌处理](#token-handling)。 @@ -72,7 +68,7 @@ Portal 代理了来自整个生态系统的精选 agentic 模型目录——统 Nous Research 自家的 **Hermes 4** 系列(Hermes-4-70B、Hermes-4-405B)通过 Portal 提供,享有大幅折扣。这些是**前沿混合推理对话模型**——在数学、科学、指令遵循、schema 遵从、角色扮演和长文写作方面表现出色。 -但**不建议在 Hermes Agent 内部使用它们**。Hermes 4 针对对话和推理进行了调优,而非 agent 所依赖的高频工具调用循环。请将它们用于 [Nous Chat](https://chat.nousresearch.com)、研究工作流,或通过[订阅代理](/user-guide/features/subscription-proxy)从其他工具调用——但在 agent 场景下,请从目录中选择前沿 agentic 模型: +但**不建议在 Hermes Agent 内部使用它们**。Hermes 4 针对对话和推理进行了调优,而非 agent 所依赖的高频工具调用循环。请将它们用于研究工作流,或通过[订阅代理](/user-guide/features/subscription-proxy)从其他工具调用——但在 agent 场景下,请从目录中选择前沿 agentic 模型: ```bash /model anthropic/claude-sonnet-4.6 # 最佳通用 agentic 模型 @@ -269,4 +265,4 @@ Portal 通过 OpenRouter 代理,因此 OpenRouter 支持的所有模型通常 - **[语音模式](/user-guide/features/voice-mode)** —— 使用 Portal 的 OpenAI TTS 进行语音对话 - **[AI 提供商](/integrations/providers)** —— 完整提供商目录,供对比参考 - **[OAuth over SSH](/guides/oauth-over-ssh)** —— 从远程主机或纯浏览器环境登录 -- **[Profiles](/user-guide/profiles)** —— 多个 Hermes 配置共享一个 Portal 登录 \ No newline at end of file +- **[Profiles](/user-guide/profiles)** —— 多个 Hermes 配置共享一个 Portal 登录 diff --git a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md index 68d7d5d07675..b6ea6e8dd88f 100644 --- a/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md +++ b/website/i18n/zh-Hans/docusaurus-plugin-content-docs/current/integrations/providers.md @@ -52,7 +52,7 @@ sidebar_position: 1 ### Nous Portal -[Nous Portal](https://portal.nousresearch.com) 是 Nous Research 的统一订阅网关,也是**运行 Hermes Agent 的推荐方式**。一次 OAuth 登录即可访问 300+ 前沿智能体模型(Claude、GPT、Gemini、DeepSeek、Qwen、Kimi、GLM、MiniMax、Grok 等),以及 [Tool Gateway](/user-guide/features/tool-gateway)(网页搜索、图像生成、TTS、浏览器自动化)和 [Nous Chat](https://chat.nousresearch.com)——费用从你的 Nous 订阅中扣除,无需单独管理各提供商账户。 +[Nous Portal](https://portal.nousresearch.com) 是 Nous Research 的统一订阅网关,也是**运行 Hermes Agent 的推荐方式**。一次 OAuth 登录即可访问 300+ 前沿智能体模型(Claude、GPT、Gemini、DeepSeek、Qwen、Kimi、GLM、MiniMax、Grok 等)以及 [Tool Gateway](/user-guide/features/tool-gateway)(网页搜索、图像生成、TTS、浏览器自动化)——费用从你的 Nous 订阅中扣除,无需单独管理各提供商账户。 ```bash hermes setup --portal # 全新安装——一条命令完成 OAuth + 提供商 + 网关配置 @@ -1414,4 +1414,4 @@ fallback_model: ## 另请参阅 - [配置](/user-guide/configuration) — 通用配置(目录结构、配置优先级、终端后端、记忆、压缩等) -- [环境变量](/reference/environment-variables) — 所有环境变量的完整参考 \ No newline at end of file +- [环境变量](/reference/environment-variables) — 所有环境变量的完整参考 From 21c7e49ad08c3a058d7c8681a30672a0af4e862d Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Mon, 20 Jul 2026 22:59:21 +0700 Subject: [PATCH 60/72] fix(web/ddgs): isolate DuckDuckGo search in a disposable process ThreadPoolExecutor timeouts cannot fire when primp holds the GIL in native code (#68096). Run each search in a child process the parent can terminate/kill, and honor tools.interrupt between polls. --- plugins/web/ddgs/_search_worker.py | 113 ++++++++++++ plugins/web/ddgs/provider.py | 278 +++++++++++++++++++++++++---- 2 files changed, 352 insertions(+), 39 deletions(-) create mode 100644 plugins/web/ddgs/_search_worker.py diff --git a/plugins/web/ddgs/_search_worker.py b/plugins/web/ddgs/_search_worker.py new file mode 100644 index 000000000000..0521284c53c1 --- /dev/null +++ b/plugins/web/ddgs/_search_worker.py @@ -0,0 +1,113 @@ +"""DDGS search child-process entrypoint (#68096). + +Invoked as ``python plugins/web/ddgs/_search_worker.py`` (script path from the +parent provider). Reads one JSON request from stdin, writes one JSON envelope +to stdout, then exits. + +Request:: + {"query": str, "safe_limit": int} + +Envelope:: + {"ok": true, "results": [...]} + {"ok": false, "error": str} + +Optional test hooks (only when ``HERMES_DDGS_ALLOW_TEST_HOOKS=1``):: + {"query": ..., "safe_limit": ..., "test_hook": "sleep"|"gil"|"success"|"error"|"empty"} +""" + +from __future__ import annotations + +import json +import os +import sys +import time + + +def _hold_gil(secs: int) -> None: + """Block in a foreign call that keeps the GIL (ctypes.PyDLL). + + Mirrors native ``primp`` holding the interpreter lock. ``PyDLL`` (unlike + ``CDLL``/``WinDLL``) does not release the GIL around the call. + """ + import ctypes + + if sys.platform == "win32": + lib = ctypes.PyDLL("kernel32") + lib.Sleep.argtypes = [ctypes.c_uint] + lib.Sleep(int(secs * 1000)) + return + + lib = ctypes.PyDLL(None) + try: + sleep = lib.sleep + except AttributeError: # pragma: no cover — macOS libSystem fallback + sleep = ctypes.PyDLL("/usr/lib/libSystem.B.dylib").sleep + sleep.argtypes = [ctypes.c_uint] + sleep(int(secs)) + + +def _run_test_hook(hook: str) -> dict: + if hook == "sleep": + time.sleep(30) + return {"ok": False, "error": "sleep hook returned unexpectedly"} + if hook == "gil": + _hold_gil(30) + return {"ok": False, "error": "gil hook returned unexpectedly"} + if hook == "success": + return { + "ok": True, + "results": [ + { + "title": "Hit", + "url": "https://example.com", + "description": "body", + "position": 1, + } + ], + } + if hook == "empty": + return {"ok": True, "results": []} + if hook == "error": + return {"ok": False, "error": "RuntimeError: boom"} + return {"ok": False, "error": f"unknown test_hook: {hook!r}"} + + +def _write_envelope(envelope: dict) -> None: + json.dump(envelope, sys.stdout) + sys.stdout.flush() + + +def main() -> int: + try: + request = json.load(sys.stdin) + except Exception as exc: # noqa: BLE001 + _write_envelope({"ok": False, "error": f"invalid request: {exc}"}) + return 2 + + hook = request.get("test_hook") + if hook: + if os.environ.get("HERMES_DDGS_ALLOW_TEST_HOOKS") != "1": + _write_envelope( + {"ok": False, "error": "test_hook refused (hooks not enabled)"} + ) + return 3 + envelope = _run_test_hook(str(hook)) + _write_envelope(envelope) + return 0 if envelope.get("ok") else 1 + + query = str(request.get("query") or "") + safe_limit = max(1, int(request.get("safe_limit") or 1)) + try: + # Import inside main so script startup stays light / patchable. + from plugins.web.ddgs.provider import _run_ddgs_search + + results = _run_ddgs_search(query, safe_limit) + _write_envelope({"ok": True, "results": results}) + return 0 + except Exception as exc: # noqa: BLE001 + _write_envelope({"ok": False, "error": f"{type(exc).__name__}: {exc}"}) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/web/ddgs/provider.py b/plugins/web/ddgs/provider.py index dcdeb0897ac9..28e4762bec7e 100644 --- a/plugins/web/ddgs/provider.py +++ b/plugins/web/ddgs/provider.py @@ -8,13 +8,24 @@ canonical implementation. The ``ddgs`` package is an optional dependency. ``is_available()`` reflects whether the package is importable; the plugin still registers either way so ``hermes tools`` can prompt the user to install it. + +Isolation note (#68096): ``ddgs``/``primp`` can block inside native code while +holding the Python GIL. A ``ThreadPoolExecutor`` + ``future.result(timeout=…)`` +cap (see #52118) cannot fire in that state — the waiter never reacquires the +GIL — so the whole Hermes process freezes through Ctrl+C/SIGTERM. Each search +therefore runs in a disposable child process the parent can terminate/kill. """ from __future__ import annotations -import concurrent.futures as _cf +import concurrent.futures as cf +import json import logging -from typing import Any, Dict +import os +import subprocess +import sys +import time +from typing import Any, Dict, Optional from agent.web_search_provider import WebSearchProvider @@ -23,18 +34,28 @@ logger = logging.getLogger(__name__) # Overall wall-clock cap for a single ddgs search. The DDGS constructor's # ``timeout`` only bounds individual HTTP requests; ddgs's multi-engine retry # loop has no overall cap, so a slow/rate-limited DuckDuckGo response can hang -# the (single, shared) agent loop indefinitely and block every platform -# (#36776). Enforce a hard cap here via a worker thread. +# the (single, shared) agent loop indefinitely (#36776). Enforce a hard cap +# here by killing a disposable worker process (#68096). _SEARCH_TIMEOUT_SECS = 30 +# How often the parent polls stdout / interrupt flag while waiting. +_POLL_INTERVAL_SECS = 0.1 + +# After terminate(), wait this long before escalating to kill(). +_TERMINATE_GRACE_SECS = 1.0 + + +class _SearchInterrupted(Exception): + """Raised when tools.interrupt.is_interrupted() trips during a search wait.""" + def _run_ddgs_search(query: str, safe_limit: int) -> list[dict[str, Any]]: """Run the blocking ddgs query and return normalized hits. - Module-level (not a closure) so tests can patch it directly without - spawning a real multi-second worker thread. ``DDGS(timeout=...)`` bounds + Module-level (not a closure) so the child worker can import it and so + tests can patch it for in-process unit tests. ``DDGS(timeout=…)`` bounds each individual HTTP request; the overall wall-clock cap is enforced by - the caller via a future timeout. + the parent via process timeout (#68096). """ from ddgs import DDGS # type: ignore @@ -55,6 +76,190 @@ def _run_ddgs_search(query: str, safe_limit: int) -> list[dict[str, Any]]: return results +# Optional test-only hook name forwarded to the child (see _search_worker.py). +# Production search() never sets this. +_test_hook: Optional[str] = None + +# Last worker Popen started by ``_run_ddgs_search_bounded`` (test reap checks). +_last_worker_proc: Optional[subprocess.Popen] = None + + +def _plugins_path_entry() -> str: + """Return the ``sys.path`` entry that makes ``import plugins`` work. + + Prefer the live ``plugins`` package location over counting ``dirname``s from + this file — that stays correct for source checkouts and site-packages. + """ + try: + import plugins as plugins_pkg + + pkg_file = getattr(plugins_pkg, "__file__", None) + if pkg_file: + return os.path.dirname(os.path.dirname(os.path.abspath(pkg_file))) + except Exception: # noqa: BLE001 — fall through to path-walk fallback + pass + return os.path.dirname( + os.path.dirname( + os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ) + ) + + +def _terminate_and_reap( + proc: Optional[subprocess.Popen], + *, + grace: float = _TERMINATE_GRACE_SECS, +) -> None: + """Terminate a worker, escalate to kill, and wait so no orphan remains. + + Does not close the parent's pipe ends — the caller must finish any + ``communicate()``/reader first. Closing stdout while another thread is + blocked in ``read()`` deadlocks on some platforms. + """ + if proc is None: + return + + def _wait_until_dead(seconds: float) -> bool: + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + if proc.poll() is not None: + return True + time.sleep(0.05) + return proc.poll() is not None + + try: + if proc.poll() is None: + proc.terminate() + _wait_until_dead(grace) + if proc.poll() is None: + proc.kill() + if not _wait_until_dead(grace): + logger.warning("DDGS worker pid=%s did not exit after kill", proc.pid) + except Exception as exc: # noqa: BLE001 — best-effort cleanup + logger.debug("DDGS worker reap error: %s", exc) + + +def _run_ddgs_search_bounded(query: str, safe_limit: int) -> list[dict[str, Any]]: + """Run ``_run_ddgs_search`` in a disposable process with a hard deadline. + + The parent never joins the child while it may be inside native code holding + *its* GIL — it only polls a communicator thread and, on timeout/interrupt, + terminates the child OS process. Raises ``TimeoutError``, + ``_SearchInterrupted``, or ``RuntimeError``. + """ + # Imported lazily so plugin import stays light for ``hermes tools`` probes. + from tools.interrupt import is_interrupted + + global _last_worker_proc + + request: dict[str, Any] = {"query": query, "safe_limit": safe_limit} + if _test_hook: + request["test_hook"] = _test_hook + + env = os.environ.copy() + if _test_hook: + env["HERMES_DDGS_ALLOW_TEST_HOOKS"] = "1" + + # Running the worker as a script puts ``plugins/web/ddgs/`` on ``sys.path[0]``, + # which breaks ``import plugins...``. Prepend the path entry that makes the + # live ``plugins`` package importable (source tree or site-packages). + child_pythonpath = env.get("PYTHONPATH", "") + path_entry = _plugins_path_entry() + if path_entry and path_entry not in child_pythonpath.split(os.pathsep): + env["PYTHONPATH"] = ( + path_entry + os.pathsep + child_pythonpath if child_pythonpath else path_entry + ) + + worker_path = os.path.join(os.path.dirname(os.path.abspath(__file__)), "_search_worker.py") + # Platform-only spawn knobs — stdin/stdout/stderr must stay as explicit + # keyword args on the Popen call so scripts/check_subprocess_stdin.py can + # see them (TUI gateway inherits stdin; #14036). + extra_kwargs: dict[str, Any] = {} + if sys.platform == "win32": + # New process group so terminate/kill reach the worker cleanly on Windows. + extra_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP + else: + # Own session so a hung primp/libcurl grandchild can be reaped with the worker. + extra_kwargs["start_new_session"] = True + + proc = subprocess.Popen( + [sys.executable, worker_path], + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + # DEVNULL avoids the classic deadlock where a chatty child fills the + # stderr pipe buffer while the parent only drains stdout. + stderr=subprocess.DEVNULL, + env=env, + text=True, + **extra_kwargs, + ) + _last_worker_proc = proc + + # ``communicate`` runs in a side thread so the parent can poll interrupt / + # deadline without blocking. Killing the child unblocks communicate. + pool = cf.ThreadPoolExecutor(max_workers=1) + fut = pool.submit(proc.communicate, json.dumps(request)) + timed_out = False + interrupted = False + raw = "" + try: + deadline = time.monotonic() + _SEARCH_TIMEOUT_SECS + while True: + if is_interrupted(): + interrupted = True + break + remaining = deadline - time.monotonic() + if remaining <= 0: + timed_out = True + break + try: + out, _err = fut.result(timeout=min(_POLL_INTERVAL_SECS, remaining)) + raw = out or "" + break + except cf.TimeoutError: + continue + finally: + _terminate_and_reap(proc) + # After kill, communicate should return promptly; don't block forever. + if not fut.done(): + try: + out, _err = fut.result(timeout=_TERMINATE_GRACE_SECS) + if not raw: + raw = out or "" + except Exception: # noqa: BLE001 + pass + pool.shutdown(wait=False, cancel_futures=True) + + if interrupted: + raise _SearchInterrupted("DuckDuckGo search interrupted") + if timed_out: + raise TimeoutError( + f"DuckDuckGo search timed out after {_SEARCH_TIMEOUT_SECS}s" + ) + + raw = raw.strip() + if not raw: + raise RuntimeError( + f"DDGS worker exited without a result (code={proc.poll()})" + ) + + try: + envelope = json.loads(raw) + except json.JSONDecodeError as exc: + raise RuntimeError( + f"DDGS worker returned invalid JSON: {raw[:200]!r}" + ) from exc + + if not isinstance(envelope, dict): + raise RuntimeError(f"DDGS worker returned an invalid envelope: {envelope!r}") + if envelope.get("ok"): + results = envelope.get("results") or [] + if not isinstance(results, list): + raise RuntimeError("DDGS worker returned non-list results") + return results + raise RuntimeError(str(envelope.get("error") or "DDGS worker failed")) + + class DDGSWebSearchProvider(WebSearchProvider): """DuckDuckGo HTML-scrape search provider. @@ -94,9 +299,9 @@ class DDGSWebSearchProvider(WebSearchProvider): def search(self, query: str, limit: int = 5) -> Dict[str, Any]: """Execute a DuckDuckGo search and return normalized results. - The synchronous ``ddgs`` call is run in a worker thread with a hard - wall-clock timeout (``_SEARCH_TIMEOUT_SECS``) so a hung search cannot - block the shared agent loop indefinitely (#36776). + The synchronous ``ddgs`` call runs in a disposable child process with + a hard wall-clock timeout (``_SEARCH_TIMEOUT_SECS``) so a hung native + ``primp`` call cannot freeze the Hermes process (#36776, #68096). """ try: import ddgs # type: ignore # noqa: F401 — availability probe @@ -110,40 +315,35 @@ class DDGSWebSearchProvider(WebSearchProvider): # in case the package ignores the hint. safe_limit = max(1, int(limit)) - # A fresh single-worker pool per call (rather than a module-level one) - # is intentional: on timeout the blocking ddgs call cannot be cancelled - # and keeps running, so a shared pool would serialise every later search - # behind that hung worker. A per-call pool isolates each search from a - # previously-hung one. - pool = _cf.ThreadPoolExecutor(max_workers=1) try: - future = pool.submit(_run_ddgs_search, query, safe_limit) - try: - web_results = future.result(timeout=_SEARCH_TIMEOUT_SECS) - except _cf.TimeoutError: - logger.warning( - "DDGS search timed out after %ds for query: %r", - _SEARCH_TIMEOUT_SECS, query, - ) - return { - "success": False, - "error": ( - f"DuckDuckGo search timed out after {_SEARCH_TIMEOUT_SECS}s — " - "DuckDuckGo may be rate-limiting or slow. Try again later " - "or switch to a different search provider." - ), - } + web_results = _run_ddgs_search_bounded(query, safe_limit) + except TimeoutError: + logger.warning( + "DDGS search timed out after %ds for query: %r", + _SEARCH_TIMEOUT_SECS, + query, + ) + return { + "success": False, + "error": ( + f"DuckDuckGo search timed out after {_SEARCH_TIMEOUT_SECS}s — " + "DuckDuckGo may be rate-limiting or slow. Try again later " + "or switch to a different search provider." + ), + } + except _SearchInterrupted: + logger.info("DDGS search interrupted for query: %r", query) + return { + "success": False, + "error": "DuckDuckGo search interrupted", + } except Exception as exc: # noqa: BLE001 — ddgs raises its own exceptions logger.warning("DDGS search error: %s", exc) return {"success": False, "error": f"DuckDuckGo search failed: {exc}"} - finally: - # Return immediately without joining the worker. On timeout the - # already-running ddgs call can't be cancelled (cancel_futures only - # affects not-yet-started work), so the worker runs to completion - # on its own; it writes nothing shared, so leaking it is safe. - pool.shutdown(wait=False, cancel_futures=True) - logger.info("DDGS search '%s': %d results (limit %d)", query, len(web_results), limit) + logger.info( + "DDGS search '%s': %d results (limit %d)", query, len(web_results), limit + ) return {"success": True, "data": {"web": web_results}} def get_setup_schema(self) -> Dict[str, Any]: From 77ee16b7471d58fad596f7f90fe2a50e803d60e7 Mon Sep 17 00:00:00 2001 From: HexLab98 Date: Mon, 20 Jul 2026 22:59:21 +0700 Subject: [PATCH 61/72] test(web/ddgs): cover GIL-hold timeout, interrupt, and worker reap Regression tests for #68096: native GIL-hold and sleep hooks must time out or interrupt promptly with no orphaned search workers. --- tests/tools/test_web_providers_ddgs.py | 191 +++++++++++++++++++------ 1 file changed, 150 insertions(+), 41 deletions(-) diff --git a/tests/tools/test_web_providers_ddgs.py b/tests/tools/test_web_providers_ddgs.py index 5166224bf0f8..459f3d835aa8 100644 --- a/tests/tools/test_web_providers_ddgs.py +++ b/tests/tools/test_web_providers_ddgs.py @@ -4,6 +4,7 @@ Covers: - DDGSWebSearchProvider.is_available() — reflects package importability - DDGSWebSearchProvider.search() — happy path, missing package, runtime error - Result normalization (title, url, description, position) +- Process-isolated timeout / interrupt / GIL-hold / reap (#68096) - _is_backend_available("ddgs") / _get_backend() integration - web_extract returns a search-only error when ddgs is active """ @@ -11,6 +12,7 @@ from __future__ import annotations import json import sys +import time import types import pytest @@ -52,6 +54,21 @@ def _install_fake_ddgs(monkeypatch, *, text_results=None, text_raises=None, text return fake +def _force_inprocess_search(monkeypatch, prov): + """Route bounded search through the in-process helper. + + Happy-path unit tests install a fake ``ddgs`` in the parent interpreter; + spawn workers would not see that fake. Isolation behavior is covered by + dedicated process tests below. + """ + monkeypatch.setattr( + prov, + "_run_ddgs_search_bounded", + lambda query, safe_limit: prov._run_ddgs_search(query, safe_limit), + raising=True, + ) + + # --------------------------------------------------------------------------- # DDGSWebSearchProvider unit tests # --------------------------------------------------------------------------- @@ -98,9 +115,10 @@ class TestDDGSProviderSearch: {"title": "B", "href": "https://b.example.com", "body": "desc B"}, {"title": "C", "href": "https://c.example.com", "body": "desc C"}, ]) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=5) + result = prov.DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is True web = result["data"]["web"] @@ -112,9 +130,10 @@ class TestDDGSProviderSearch: _install_fake_ddgs(monkeypatch, text_results=[ {"title": "A", "url": "https://a.example.com", "body": "desc A"}, ]) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=5) + result = prov.DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is True assert result["data"]["web"][0]["url"] == "https://a.example.com" @@ -124,9 +143,10 @@ class TestDDGSProviderSearch: {"title": f"R{i}", "href": f"https://r{i}.example.com", "body": ""} for i in range(10) ]) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=3) + result = prov.DDGSWebSearchProvider().search("q", limit=3) assert result["success"] is True assert len(result["data"]["web"]) == 3 @@ -151,54 +171,42 @@ class TestDDGSProviderSearch: def test_runtime_error_returns_failure(self, monkeypatch): _install_fake_ddgs(monkeypatch, text_raises=RuntimeError("rate limited 202")) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=5) + result = prov.DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is False assert "rate limited" in result["error"] or "failed" in result["error"].lower() def test_empty_results(self, monkeypatch): _install_fake_ddgs(monkeypatch, text_results=[]) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("nothing", limit=5) + result = prov.DDGSWebSearchProvider().search("nothing", limit=5) assert result["success"] is True assert result["data"]["web"] == [] + @pytest.mark.live_system_guard_bypass def test_hung_search_times_out_and_returns_failure(self, monkeypatch): - """#36776: a ddgs call that never returns must be bounded by the - wall-clock timeout and surface a failure instead of hanging the - shared agent loop. We patch the blocking helper to wait on an Event - (released in finally so no worker thread leaks past the test) and - shrink the timeout; search() must return success=False promptly.""" - import threading - import time - - # ddgs must import-probe True for search() to proceed. + """#36776 / #68096: a hung worker must be bounded by the wall-clock + timeout and reaped — even when the child never returns to Python.""" _install_fake_ddgs(monkeypatch) - monkeypatch.delitem(sys.modules, "plugins.web.ddgs.provider", raising=False) - import plugins.web.ddgs.provider as _prov + import plugins.web.ddgs.provider as prov - release = threading.Event() + monkeypatch.setattr(prov, "_test_hook", "sleep", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 0.4, raising=True) + monkeypatch.setattr(prov, "_TERMINATE_GRACE_SECS", 0.5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) - def _blocking_search(query, safe_limit): - release.wait(timeout=10) # bounded so the worker can never truly leak - return [] + start = time.monotonic() + result = prov.DDGSWebSearchProvider().search("hangs forever", limit=5) + elapsed = time.monotonic() - start - monkeypatch.setattr(_prov, "_run_ddgs_search", _blocking_search, raising=True) - monkeypatch.setattr(_prov, "_SEARCH_TIMEOUT_SECS", 0.3, raising=True) - - try: - start = time.monotonic() - result = _prov.DDGSWebSearchProvider().search("hangs forever", limit=5) - elapsed = time.monotonic() - start - - assert result["success"] is False - assert "timed out" in result["error"].lower() - # Returned well before the worker's 10s wait — proves the cap fired. - assert elapsed < 3.0, f"search did not return promptly ({elapsed:.1f}s)" - finally: - release.set() # let the orphaned worker finish immediately + assert result["success"] is False + assert "timed out" in result["error"].lower() + assert elapsed < 5.0, f"search did not return promptly ({elapsed:.1f}s)" + _assert_worker_reaped(prov) def test_fast_search_not_affected_by_timeout_wrapper(self, monkeypatch): """Happy-path guard: the timeout wrapper must not break a normal, @@ -207,14 +215,115 @@ class TestDDGSProviderSearch: monkeypatch, text_results=[{"title": "T", "href": "https://e.com", "body": "B"}], ) - from plugins.web.ddgs.provider import DDGSWebSearchProvider + import plugins.web.ddgs.provider as prov + _force_inprocess_search(monkeypatch, prov) - result = DDGSWebSearchProvider().search("q", limit=5) + result = prov.DDGSWebSearchProvider().search("q", limit=5) assert result["success"] is True assert result["data"]["web"][0]["url"] == "https://e.com" assert result["data"]["web"][0]["title"] == "T" +# --------------------------------------------------------------------------- +# Process isolation (#68096) +# --------------------------------------------------------------------------- + + +def _assert_worker_reaped(prov) -> None: + """Assert the last DDGS worker process has exited.""" + proc = prov._last_worker_proc + assert proc is not None, "expected a DDGS worker process to have been started" + assert proc.poll() is not None, ( + f"DDGS worker still alive (pid={proc.pid}, returncode={proc.returncode})" + ) + + +@pytest.mark.live_system_guard_bypass +class TestDDGSProcessIsolation: + def test_gil_holding_worker_times_out_and_is_reaped(self, monkeypatch): + """#68096: parent deadline still fires when the child holds its GIL.""" + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + monkeypatch.setattr(prov, "_test_hook", "gil", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 0.5, raising=True) + monkeypatch.setattr(prov, "_TERMINATE_GRACE_SECS", 0.5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + start = time.monotonic() + result = prov.DDGSWebSearchProvider().search("gil hold", limit=5) + elapsed = time.monotonic() - start + + assert result["success"] is False + assert "timed out" in result["error"].lower() + assert elapsed < 5.0, f"GIL-hold search did not time out promptly ({elapsed:.1f}s)" + _assert_worker_reaped(prov) + + def test_interrupt_terminates_worker_promptly(self, monkeypatch): + """TUI/gateway interrupt must kill the DDGS child before the deadline.""" + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + # Flip interrupt after the first poll so the wait loop observes it. + calls = {"n": 0} + + def _interrupt_after_poll(): + calls["n"] += 1 + return calls["n"] >= 2 + + monkeypatch.setattr(prov, "_test_hook", "sleep", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 30, raising=True) + monkeypatch.setattr(prov, "_TERMINATE_GRACE_SECS", 0.5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", _interrupt_after_poll) + + start = time.monotonic() + result = prov.DDGSWebSearchProvider().search("interrupt me", limit=5) + elapsed = time.monotonic() - start + + assert result["success"] is False + assert "interrupted" in result["error"].lower() + assert elapsed < 5.0, f"interrupt did not return promptly ({elapsed:.1f}s)" + _assert_worker_reaped(prov) + + def test_spawned_worker_success_envelope(self, monkeypatch): + """Real spawn path: success envelope round-trips through the pipe.""" + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + monkeypatch.setattr(prov, "_test_hook", "success", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = prov.DDGSWebSearchProvider().search("q", limit=5) + assert result["success"] is True + assert result["data"]["web"][0]["url"] == "https://example.com" + _assert_worker_reaped(prov) + + def test_spawned_worker_error_envelope(self, monkeypatch): + """Real spawn path: error envelope becomes success=False.""" + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + monkeypatch.setattr(prov, "_test_hook", "error", raising=True) + monkeypatch.setattr(prov, "_SEARCH_TIMEOUT_SECS", 5, raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = prov.DDGSWebSearchProvider().search("q", limit=5) + assert result["success"] is False + assert "boom" in result["error"] + _assert_worker_reaped(prov) + + def test_no_orphan_after_successful_search(self, monkeypatch): + _install_fake_ddgs(monkeypatch) + import plugins.web.ddgs.provider as prov + + monkeypatch.setattr(prov, "_test_hook", "empty", raising=True) + monkeypatch.setattr("tools.interrupt.is_interrupted", lambda: False) + + result = prov.DDGSWebSearchProvider().search("q", limit=5) + assert result["success"] is True + _assert_worker_reaped(prov) + # --------------------------------------------------------------------------- # Integration: _is_backend_available / _get_backend / check_web_api_key # --------------------------------------------------------------------------- From 646e71a9be070a8b8e05cf4fde7ddbad6ffa7fec Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:46:29 +0530 Subject: [PATCH 62/72] fix: sanitize subprocess env for DDGS worker os.environ.copy() passes all Hermes secrets (gateway tokens, API keys, dashboard session tokens) into the DDGS child process. Use _sanitize_subprocess_env() to strip Hermes-managed secrets before spawning the worker. --- plugins/web/ddgs/provider.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/plugins/web/ddgs/provider.py b/plugins/web/ddgs/provider.py index 28e4762bec7e..44a3aca6c8c3 100644 --- a/plugins/web/ddgs/provider.py +++ b/plugins/web/ddgs/provider.py @@ -156,7 +156,9 @@ def _run_ddgs_search_bounded(query: str, safe_limit: int) -> list[dict[str, Any] if _test_hook: request["test_hook"] = _test_hook - env = os.environ.copy() + from tools.environments.local import _sanitize_subprocess_env + + env = _sanitize_subprocess_env(dict(os.environ)) if _test_hook: env["HERMES_DDGS_ALLOW_TEST_HOOKS"] = "1" From 0ba889d49206bbaff5b613b4a3a89427cc068948 Mon Sep 17 00:00:00 2001 From: PRATHAMESH75 Date: Tue, 21 Jul 2026 01:10:43 +0530 Subject: [PATCH 63/72] fix(agent): pass persisted-prefix boundary when rotation flushes on cold resume (#68196) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The legacy rotation branch in agent/conversation_compression.py flushes the current turn to the OLD session before ending it (#47202) via _flush_messages_to_session_db(messages) with no conversation_history boundary. On the first turn after a cold Desktop resume, the restored transcript rows live in the message list as plain dicts that have not yet been stamped with _DB_PERSISTED_MARKER — the normal turn flush that stamps them runs after preflight compression. With no boundary, _flush_messages_to_session_db builds an empty history_ids set and treats every restored row as new, durably re-appending the whole transcript to the parent session. Repeated restart/resume + threshold compression keeps growing the parent transcript. Pass messages[:_persist_user_message_idx] (the already-durable prefix that turn_context anchors before preflight runs, guarded for int/bounds) as conversation_history so the flush skips the persisted rows by identity and writes only the current turn's new messages. Adds a regression test that pre-populates SQLite, cold-loads the transcript, appends one current user row, and forces rotating compression: it fails before this change (parent grows to 5 rows) and passes after (parent holds the two originals plus the single new turn). --- agent/conversation_compression.py | 23 +++- ...rotation_flush_persisted_boundary_68196.py | 118 ++++++++++++++++++ 2 files changed, 140 insertions(+), 1 deletion(-) create mode 100644 tests/agent/test_rotation_flush_persisted_boundary_68196.py diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 791c3ddb649b..dd7b75796b5c 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -1236,8 +1236,29 @@ def compress_context( # Flush any un-persisted current-turn messages to the OLD # session before ending it, so they survive in the preserved # parent transcript (#47202). (In-place skips this — see above.) + # + # Pass the already-durable prefix as conversation_history so + # the flush skips it by identity (#68196). Preflight + # compression runs BEFORE the normal turn flush has stamped + # the cold-resumed history dicts with _DB_PERSISTED_MARKER, so + # without a boundary _flush_messages_to_session_db treats every + # restored row as new and re-appends the whole transcript to + # the parent. turn_context anchors _persist_user_message_idx at + # the current-turn user message before preflight runs, so + # messages[:idx] is exactly the persisted prefix; only the + # current turn's new messages get written. + current_idx = getattr(agent, "_persist_user_message_idx", None) + persisted_history = ( + messages[:current_idx] + if isinstance(current_idx, int) + and 0 <= current_idx <= len(messages) + else None + ) try: - agent._flush_messages_to_session_db(messages) + agent._flush_messages_to_session_db( + messages, + conversation_history=persisted_history, + ) except Exception: pass # best-effort — don't block compression on a flush error # Propagate title to the new session with auto-numbering diff --git a/tests/agent/test_rotation_flush_persisted_boundary_68196.py b/tests/agent/test_rotation_flush_persisted_boundary_68196.py new file mode 100644 index 000000000000..c082be359149 --- /dev/null +++ b/tests/agent/test_rotation_flush_persisted_boundary_68196.py @@ -0,0 +1,118 @@ +"""Regression (#68196): rotating preflight compression must not re-append the +already-persisted transcript to the parent session on cold resume. + +On the first turn after a cold Desktop resume, the stored rows are handed to +``run_conversation()`` as ``conversation_history`` and live in the message list +as plain dicts that have NOT yet been stamped with ``_DB_PERSISTED_MARKER`` — +the normal turn flush (which stamps them) runs *after* preflight compression. + +The legacy rotation branch in ``agent/conversation_compression.py`` flushes the +current turn to the OLD session before ending it (#47202). It used to call +``agent._flush_messages_to_session_db(messages)`` with no history boundary, so +``_flush_messages_to_session_db`` saw an empty ``history_ids`` set and treated +every restored row as new — durably appending the whole transcript to the +parent a second time. The fix passes ``messages[:_persist_user_message_idx]`` +(the already-durable prefix ``turn_context`` anchors before preflight runs) as +``conversation_history`` so only the current turn's new messages are written. + +Without the fix the parent grows to 5 rows (the two originals + a duplicate of +both + the new turn). With it the parent holds exactly the two originals plus +the single new turn. +""" + +from __future__ import annotations + +import os +import time +from pathlib import Path +from unittest.mock import MagicMock, patch + +from hermes_state import SessionDB + + +def _build_agent_with_db(db: SessionDB, session_id: str): + """Build an AIAgent wired to ``db`` and pinned to ``session_id``. + + Mirrors the helper in ``test_compression_concurrent_fork.py``: stub the + compressor so it returns deterministic output without an LLM call, and pin + ``compression_in_place=False`` so the legacy rotation path is exercised + regardless of the global default. + """ + with patch.dict(os.environ, {"OPENROUTER_API_KEY": "test-key"}): + from run_agent import AIAgent + + agent = AIAgent( + api_key="test-key", + base_url="https://openrouter.ai/api/v1", + model="test/model", + quiet_mode=True, + session_db=db, + session_id=session_id, + skip_context_files=True, + skip_memory=True, + ) + + compressor = MagicMock() + + def _compress(*_a, **_kw): + time.sleep(0.01) + return [ + {"role": "user", "content": "[CONTEXT COMPACTION] summary"}, + {"role": "user", "content": "tail"}, + ] + + compressor.compress.side_effect = _compress + compressor.compression_count = 1 + compressor.last_prompt_tokens = 0 + compressor.last_completion_tokens = 0 + compressor._last_summary_error = None + compressor._last_compress_aborted = False + compressor._last_aux_model_failure_model = None + compressor._last_aux_model_failure_error = None + agent.context_compressor = compressor + agent.compression_in_place = False + return agent + + +def _contents(rows): + return [r.get("content") for r in rows] + + +def test_rotation_flush_does_not_duplicate_persisted_prefix(tmp_path: Path) -> None: + """Cold-resume + rotating preflight compression keeps the parent transcript + at (persisted prefix + one new turn) — no second copy of the durable rows.""" + db = SessionDB(db_path=tmp_path / "state.db") + + parent_sid = "COLD_RESUME_PARENT" + db.create_session(parent_sid, source="desktop") + + # Two durable rows already in the parent. + db.append_message(parent_sid, "user", "persisted question") + db.append_message(parent_sid, "assistant", "persisted answer") + + # Cold resume: the stored rows come back as plain dicts, unstamped, and the + # live turn appends one new user message on top. + loaded = db.get_messages_as_conversation(parent_sid) + assert _contents(loaded) == ["persisted question", "persisted answer"] + messages = [*loaded, {"role": "user", "content": "new turn"}] + + agent = _build_agent_with_db(db, parent_sid) + # turn_context anchors this at the current-turn user message before preflight + # compression runs; emulate that anchor. + agent._persist_user_message_idx = len(messages) - 1 + + agent._compress_context(messages, "sys", approx_tokens=120_000) + + # The flush at the rotation boundary lands on the OLD (parent) session, + # which is then ended. Read it back verbatim (include_inactive to be robust + # to the end_session bookkeeping). + parent_rows = db.get_messages_as_conversation(parent_sid, include_inactive=True) + contents = _contents(parent_rows) + + assert contents.count("persisted question") == 1, ( + "Rotation flush re-appended the already-persisted prefix to the parent " + f"(#68196). Parent transcript is {contents!r}; expected the two durable " + "rows plus only the new turn." + ) + assert contents.count("persisted answer") == 1 + assert contents == ["persisted question", "persisted answer", "new turn"] From 6c28558161fdd739f332a2d740b3dbb469cbb392 Mon Sep 17 00:00:00 2001 From: x7peeps Date: Mon, 20 Jul 2026 23:57:18 +0800 Subject: [PATCH 64/72] fix(desktop): prevent contentEditable composer input from visually collapsing to near-zero height Fix #68095 The composer input box (contentEditable div) randomly shrank to a tiny/pixelated size when typing character-by-character (paste worked fine). Root cause: during per-keystroke input, the normalizeComposerEditorDom cleanup could briefly leave the contentEditable with zero child nodes, and without intrinsic content the browser collapsed it visually despite the CSS min-height. Two-pronged fix: 1. Add min-h-[1.625rem] bracket syntax alongside the CSS variable min-height to ensure the minimum height is enforced even if the CSS variable resolution is delayed or overridden by browser defaults. 2. In normalizeComposerEditorDom, ensure the contentEditable always has at least one
child when empty, giving it intrinsic height that the browser cannot collapse. This is a belt-and-suspenders approach with the CSS min-height. Closes #68095 --- apps/desktop/src/app/chat/composer/index.tsx | 2 +- apps/desktop/src/app/chat/composer/rich-editor.ts | 9 +++++++++ 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/apps/desktop/src/app/chat/composer/index.tsx b/apps/desktop/src/app/chat/composer/index.tsx index ce0e43d7d1cc..5d8d4ab0aadd 100644 --- a/apps/desktop/src/app/chat/composer/index.tsx +++ b/apps/desktop/src/app/chat/composer/index.tsx @@ -734,7 +734,7 @@ export function ChatBar({ autoCapitalize="off" autoCorrect="off" className={cn( - 'min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', + 'min-h-[1.625rem] min-h-(--composer-input-min-height) max-h-(--composer-input-max-height) cursor-text overflow-y-auto whitespace-pre-wrap break-words [overflow-wrap:anywhere] bg-transparent pb-1 pr-1 pt-1 leading-normal text-foreground outline-none disabled:cursor-not-allowed', 'empty:before:content-[attr(data-placeholder)] empty:before:text-muted-foreground/60', '**:data-ref-text:cursor-default', stacked && 'pl-3', diff --git a/apps/desktop/src/app/chat/composer/rich-editor.ts b/apps/desktop/src/app/chat/composer/rich-editor.ts index 2587202c96a2..71491b87496d 100644 --- a/apps/desktop/src/app/chat/composer/rich-editor.ts +++ b/apps/desktop/src/app/chat/composer/rich-editor.ts @@ -360,4 +360,13 @@ export function normalizeComposerEditorDom(editor: HTMLElement) { editor.removeChild(last) } } + + // ContentEditable elements with no children can visually collapse to + // near-zero height in some browsers (especially Chromium), causing the + // composer to appear as a tiny dot/pixel. Ensure there's always at least + // one
so the element maintains intrinsic height. The CSS min-height + // is a belt; the
is suspenders — together they prevent the shrink. + if (editor.childNodes.length === 0) { + editor.appendChild(document.createElement('br')) + } } From 3a9b9d65d505646212c4c875bab19b96ae14b2e6 Mon Sep 17 00:00:00 2001 From: x7peeps Date: Tue, 21 Jul 2026 04:13:06 +0800 Subject: [PATCH 65/72] fix(agent): circuit-break AttributeError from commit-splice and detect code skew MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fix #68178 The git-install auto-updater rewrites source while the desktop backend is live. Because agent/conversation_loop.py is imported lazily on the first API call, a process can end up running two different commits spliced together — one commit's AIAgent against another commit's conversation_loop. When the interface differs, every turn fails permanently with an AttributeError, and the loop retries indefinitely, burning provider API calls (576 failures, 149 wasted API calls observed). Three-prong fix: 1. Circuit-break AttributeError on agent objects: the outer-loop error classifier now detects AttributeError targeting agent/run_agent modules and breaks immediately instead of continuing the retry loop. 2. Code skew detection for desktop/serve backend: run_agent.py now snapshots the checkout revision at import time and exposes a cheap per-iteration check that the conversation loop uses to refuse new work with a clear 'restart required' message before the lazy import can crash. 3. Informative error message: when code skew is detected, the user gets a clear explanation of the mismatch (boot revision vs current revision) and actionable guidance to restart the application. --- agent/conversation_loop.py | 71 +++++++++++++++++++++++-- run_agent.py | 99 +++++++++++++++++++++++++++++++++++ tests/test_agent_code_skew.py | 72 +++++++++++++++++++++++++ 3 files changed, 237 insertions(+), 5 deletions(-) create mode 100644 tests/test_agent_code_skew.py diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index 501e49b54d55..ed7c911b4515 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -91,6 +91,11 @@ INTERRUPT_WAITING_FOR_MODEL_PREFIX = "Operation interrupted: waiting for model r # itself, so every exception passes through them, which would make # _hit_local always True and misclassify transient API/network errors as # non-retryable local bugs. (#66267) +# +# AttributeError is handled separately in the outer except block — it is +# ALWAYS a local programming bug when it targets agent attributes (especially +# missing methods introduced by a commit splice after an auto-update rewrites +# source underneath a live process). See the dedicated guard below. (#68178) _LOCAL_PROCESSING_MODULES = frozenset({ "agent_runtime_helpers", "message_content", @@ -719,6 +724,39 @@ def run_conversation( ) while (api_call_count < agent.max_iterations and agent.iteration_budget.remaining > 0) or agent._budget_grace_call: + # ── Code skew guard (#68178) ─────────────────────────────── + # Check whether the source tree has been updated underneath this + # long-lived process. If so, a lazy import can resolve newly-added + # symbols against the stale in-memory AIAgent class, producing an + # AttributeError that would otherwise retry indefinitely. + # Perform the check on every iteration (it is cheap once confirmed). + _skew_warning = getattr(agent, "_check_code_skew_before_turn", lambda: None)() + if _skew_warning: + logger.warning("Code skew detected at API call #%d: %s", api_call_count + 1, _skew_warning) + _turn_exit_reason = "code_skew_detected" + final_response = ( + f"I apologize, but the agent has detected that its source code " + f"has been updated while running. To avoid compatibility issues, " + f"please restart the application. ({_skew_warning})" + ) + messages.append({"role": "assistant", "content": final_response}) + return finalize_turn( + agent, + final_response=final_response, + api_call_count=api_call_count, + interrupted=False, + failed=True, + messages=messages, + conversation_history=conversation_history, + effective_task_id=effective_task_id, + turn_id=turn_id, + user_message=user_message, + original_user_message=original_user_message, + _should_review_memory=_should_review_memory, + _turn_exit_reason=_turn_exit_reason, + _pending_verification_response=_pending_verification_response, + _pending_verification_response_previewed=_pending_verification_response_previewed, + ) # Reset per-turn checkpoint dedup so each iteration can take one snapshot agent._checkpoint_mgr.new_turn() @@ -5706,7 +5744,21 @@ def run_conversation( _is_local_processing_error = _hit_local and not _hit_api - if _is_local_processing_error: + # AttributeError on the agent object is ALWAYS a local bug — + # it means the live process is running spliced commits (the + # method does not exist on the in-memory AIAgent class but + # conversation_loop.py references it). Circuit-break + # immediately to avoid burning provider API calls. (#68178) + _is_agent_attribute_error = ( + isinstance(e, AttributeError) + and ("run_agent" in tb_module_names or "agent" in tb_module_names) + ) + + if _is_agent_attribute_error: + error_msg = ( + f"Fatal local code error in API call #{api_call_count}: {str(e)}" + ) + elif _is_local_processing_error: error_msg = ( f"Error during local message processing after " f"OpenAI-compatible API call #{api_call_count}: {str(e)}" @@ -5760,13 +5812,22 @@ def run_conversation( # role-alternation invariants. # If we're near the limit, break to avoid infinite loops. - # Local processing errors are deterministic — stop immediately - # rather than retrying until the budget is exhausted. + # Local processing errors and agent AttributeError (commit-splice + # symptom) are deterministic — stop immediately rather than + # retrying until the budget is exhausted. (#68178) if ( - _is_local_processing_error + _is_agent_attribute_error + or _is_local_processing_error or api_call_count >= agent.max_iterations - 1 ): - if _is_local_processing_error: + if _is_agent_attribute_error: + _turn_exit_reason = f"code_skew_attribute_error({error_msg[:80]})" + final_response = ( + f"I apologize, but the agent process has detected a code " + f"mismatch (running stale code after an update). " + f"Please restart the application. Error: {error_msg}" + ) + elif _is_local_processing_error: _turn_exit_reason = f"local_processing_error({error_msg[:80]})" final_response = f"I apologize, but I encountered an error while processing the model response: {error_msg}" else: diff --git a/run_agent.py b/run_agent.py index 6c13f737c861..d649addce7ea 100644 --- a/run_agent.py +++ b/run_agent.py @@ -65,6 +65,81 @@ from types import SimpleNamespace from hermes_constants import get_hermes_home +# --------------------------------------------------------------------------- +# Code-skew detection for the desktop/serve backend (#68178). +# +# The agent core is imported once at startup. If an auto-update (``git pull`` +# / ``hermes update``) rewrites the source tree underneath a running process, +# any lazy import that resolves a newly-added symbol from a freshly-updated +# file will load new code against a stale in-memory ``AIAgent`` class — +# producing an ``AttributeError`` that the conversation loop would otherwise +# retry indefinitely, burning provider API calls. +# +# We snapshot the checkout revision at module import time and expose a cheap +# check that the outer loop can use to refuse new work with a clear message. +# --------------------------------------------------------------------------- +_agent_boot_fingerprint: str | None = None + + +def _record_agent_boot_fingerprint() -> None: + """Snapshot the checkout revision when ``run_agent`` is first imported. + + Idempotent — subsequent calls are no-ops. Safe on non-git installs + (falls back to ``None`` and the skew check becomes a no-op). + """ + global _agent_boot_fingerprint + if _agent_boot_fingerprint is not None: + return + try: + from hermes_cli.main import _read_git_revision_fingerprint + + _agent_boot_fingerprint = _read_git_revision_fingerprint( + Path(__file__).resolve().parent + ) + except Exception: + _agent_boot_fingerprint = None + + +_record_agent_boot_fingerprint() + +# Cached result of the first confirmed skew detection. Once skew is found +# it is irreversible without external intervention (git reset/checkout), so +# we avoid repeated disk I/O on every turn. +_agent_code_skew_confirmed: bool = False +_agent_code_skew_labels: tuple[str, str] | None = None + + +def _detect_agent_code_skew() -> tuple[str, str] | None: + """Check whether the checkout revision has drifted since this process + started. Returns ``(boot_rev, disk_rev)`` short labels if skew is + detected, else ``None``. Once confirmed, the result is cached. + + See #68178. + """ + global _agent_code_skew_confirmed, _agent_code_skew_labels + if _agent_code_skew_confirmed: + return _agent_code_skew_labels + if _agent_boot_fingerprint is None: + return None + try: + from hermes_cli.main import _read_git_revision_fingerprint + + current = _read_git_revision_fingerprint(Path(__file__).resolve().parent) + except Exception: + return None + if current is None or current == _agent_boot_fingerprint: + return None + # Skew confirmed — cache permanently for this process. + def _short(fp: str) -> str: + sha = fp.rsplit(":", 1)[-1] + if sha and sha != "unresolved" and len(sha) > 10: + return sha[:10] + return sha or fp + _agent_code_skew_confirmed = True + _agent_code_skew_labels = (_short(_agent_boot_fingerprint), _short(current)) + return _agent_code_skew_labels + + def _launch_cwd_for_session(source: str) -> Optional[str]: """Working directory to stamp on a new session row, or None. @@ -6418,6 +6493,30 @@ class AIAgent: result = self.run_conversation(message, stream_callback=stream_callback) return result["final_response"] + def _check_code_skew_before_turn(self) -> str | None: + """Return a warning string if the source tree has been updated + underneath this process (code skew), else ``None``. + + Long-lived desktop/serve backend processes can have their source + rewritten by an auto-update while still running. If a lazy import + (e.g. ``agent/conversation_loop.py``) resolves newly-added symbols + against the stale in-memory ``AIAgent`` class, it produces an + ``AttributeError`` that would otherwise retry indefinitely. + + When skew is detected, the caller should refuse new work with a + clear message. See #68178. + """ + skew = _detect_agent_code_skew() + if skew is None: + return None + boot_rev, disk_rev = skew + return ( + f"Code skew detected: this process was loaded at revision {boot_rev} " + f"but the source tree is now at {disk_rev}. A lazy import could resolve " + f"new symbols against the stale in-memory class (AttributeError). " + f"Please restart the application to apply the update safely." + ) + def _run_codex_app_server_turn( self, *, diff --git a/tests/test_agent_code_skew.py b/tests/test_agent_code_skew.py new file mode 100644 index 000000000000..be61a86e996d --- /dev/null +++ b/tests/test_agent_code_skew.py @@ -0,0 +1,72 @@ +"""Tests for agent-side code-skew detection (desktop/serve backend). + +Companion to ``tests/test_code_skew.py`` (gateway): these prove the same +protection exists for the long-lived ``hermes serve`` / desktop backend +process, which imports ``run_agent`` directly rather than going through the +gateway. See #68178. +""" + +import pytest + + +class TestAgentCodeSkewCaching: + def test_boot_fingerprint_recorded_at_import(self): + """``run_agent`` records its boot fingerprint on first import.""" + import run_agent + + # Should not be None on a git install. + assert run_agent._agent_boot_fingerprint is not None + + def test_detect_no_skew_when_unchanged(self): + """When the fingerprint hasn't changed, skew is None.""" + import run_agent + + assert run_agent._detect_agent_code_skew() is None + + def test_cached_skew_is_returned_immediately(self, monkeypatch): + """Once confirmed, the result is cached and returned without I/O.""" + import run_agent + + monkeypatch.setattr(run_agent, "_agent_code_skew_confirmed", True) + monkeypatch.setattr(run_agent, "_agent_code_skew_labels", ("abc1234567", "def4567890")) + + skew = run_agent._detect_agent_code_skew() + assert skew == ("abc1234567", "def4567890") + + def test_none_boot_fingerprint_means_no_skew(self, monkeypatch): + """If boot fingerprint could not be read, skew detection is a no-op.""" + import run_agent + + monkeypatch.setattr(run_agent, "_agent_boot_fingerprint", None) + monkeypatch.setattr(run_agent, "_agent_code_skew_confirmed", False) + monkeypatch.setattr(run_agent, "_agent_code_skew_labels", None) + + assert run_agent._detect_agent_code_skew() is None + + +class TestCheckCodeSkewBeforeTurn: + def test_returns_none_without_skew(self): + """When no skew exists, the method returns None.""" + import run_agent + + # Create a minimal fake agent with the method. + class FakeAgent: + pass + + fake = FakeAgent() + # The method lives on AIAgent, not a module function. Test by + # verifying the underlying function returns None when no skew. + result = run_agent._detect_agent_code_skew() + assert result is None + + def test_returns_warning_when_skew_confirmed(self, monkeypatch): + """When skew is confirmed, the method returns a descriptive warning.""" + import run_agent + + monkeypatch.setattr(run_agent, "_agent_code_skew_confirmed", True) + monkeypatch.setattr(run_agent, "_agent_code_skew_labels", ("abc1234567", "def4567890")) + + # The method is on AIAgent, so we need to instantiate or call via class. + # Instead, test the underlying function directly. + skew = run_agent._detect_agent_code_skew() + assert skew == ("abc1234567", "def4567890") From 1ba0e873ff42703dcec654af23119932f869b327 Mon Sep 17 00:00:00 2001 From: Imgaojp <6065749+Imgaojp@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:47:53 +0530 Subject: [PATCH 66/72] fix(telegram): preserve fatal recovery handoff Release the current polling-recovery task's ownership before invoking the fatal-error handler. The runner bounds adapter cleanup in a child task; disconnect() cancels the tracked polling-recovery task, so retaining the current notifier in _polling_error_task would cancel the fatal callback before the runner can finish its reconnect-queue or shutdown decision. The new _handoff_polling_fatal_error() helper clears _polling_error_task only when it is the current notifier. Other recovery tasks remain tracked and are still cancelled and awaited during teardown. Covers both network retry exhaustion and polling-conflict exhaustion. Replaces the misleading "Restarting gateway" message with "Escalating to gateway recovery". Fixes #68406. --- plugins/platforms/telegram/adapter.py | 21 +++++++++-- tests/gateway/test_telegram_conflict.py | 27 +++++++++++++- .../test_telegram_network_reconnect.py | 37 ++++++++++++++++++- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index d91f2a799828..4bfb685b09bc 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2357,11 +2357,11 @@ class TelegramAdapter(BasePlatformAdapter): if attempt > MAX_NETWORK_RETRIES: message = ( "Telegram polling could not reconnect after %d network error retries. " - "Restarting gateway." % MAX_NETWORK_RETRIES + "Escalating to gateway recovery." % MAX_NETWORK_RETRIES ) logger.error("[%s] %s Last error: %s", self.name, message, _redact_telegram_error_text(error)) self._set_fatal_error("telegram_network_error", message, retryable=True) - await self._notify_fatal_error() + await self._handoff_polling_fatal_error() return delay = min(BASE_DELAY * (2 ** (attempt - 1)), MAX_DELAY) @@ -2982,7 +2982,22 @@ class TelegramAdapter(BasePlatformAdapter): self.name, stop_error, exc_info=True, ) if not _already_fatal: - await self._notify_fatal_error() + await self._handoff_polling_fatal_error() + + async def _handoff_polling_fatal_error(self) -> None: + """Notify the runner without letting child teardown cancel this owner. + + The runner bounds adapter cleanup in a child task. ``disconnect()`` + cancels the tracked polling-recovery task, so retaining the current + notifier in ``_polling_error_task`` would cancel the fatal callback + before the runner can finish its reconnect or shutdown decision. + Release only the current owner; unrelated recovery tasks remain under + teardown control. + """ + current_task = asyncio.current_task() + if self._polling_error_task is current_task: + self._polling_error_task = None + await self._notify_fatal_error() async def _create_dm_topic( self, diff --git a/tests/gateway/test_telegram_conflict.py b/tests/gateway/test_telegram_conflict.py index e00a0f1d33c6..f85034b2f902 100644 --- a/tests/gateway/test_telegram_conflict.py +++ b/tests/gateway/test_telegram_conflict.py @@ -293,6 +293,32 @@ async def test_polling_conflict_becomes_fatal_after_retries(monkeypatch): await _cancel_heartbeat(adapter) +@pytest.mark.asyncio +async def test_conflict_exhaustion_hands_off_before_child_disconnect(): + """The conflict recovery owner must survive its fatal callback handoff.""" + adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***")) + adapter._polling_conflict_count = 5 # MAX_CONFLICT_RETRIES + disconnect_tasks = [] + + async def fatal_handler(failed_adapter): + disconnect_task = asyncio.create_task(failed_adapter.disconnect()) + disconnect_tasks.append(disconnect_task) + await asyncio.wait({disconnect_task}) + + adapter.set_fatal_error_handler(fatal_handler) + + conflict = type("Conflict", (Exception,), {}) + recovery_task = asyncio.create_task( + adapter._handle_polling_conflict(conflict("getUpdates conflict")) + ) + adapter._polling_error_task = recovery_task + result = await asyncio.gather(recovery_task, return_exceptions=True) + await asyncio.gather(*disconnect_tasks, return_exceptions=True) + + assert result == [None] + assert adapter._polling_error_task is None + + @pytest.mark.asyncio async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(monkeypatch): adapter = TelegramAdapter(PlatformConfig(enabled=True, token="***")) @@ -730,4 +756,3 @@ async def test_conflict_callback_disarms_before_scheduling(monkeypatch): for _ in range(10): await asyncio.sleep(0) await _cancel_heartbeat(adapter) - diff --git a/tests/gateway/test_telegram_network_reconnect.py b/tests/gateway/test_telegram_network_reconnect.py index 4ffa4a99dd88..c2406e0a88e0 100644 --- a/tests/gateway/test_telegram_network_reconnect.py +++ b/tests/gateway/test_telegram_network_reconnect.py @@ -14,7 +14,7 @@ from unittest.mock import AsyncMock, MagicMock, patch import pytest -from gateway.config import PlatformConfig +from gateway.config import GatewayConfig, Platform, PlatformConfig def _ensure_telegram_mock(): @@ -37,6 +37,7 @@ _ensure_telegram_mock() from plugins.platforms.telegram import adapter as tg_adapter # noqa: E402 from plugins.platforms.telegram.adapter import TelegramAdapter # noqa: E402 +from gateway.run import GatewayRunner # noqa: E402 @pytest.fixture(autouse=True) @@ -224,6 +225,40 @@ async def test_reconnect_triggers_fatal_after_max_retries(): fatal_handler.assert_called_once() +@pytest.mark.asyncio +async def test_retry_exhaustion_queues_reconnect_before_child_disconnect(tmp_path): + """Fatal teardown must not cancel the gateway's reconnect handoff. + + The gateway runs ``disconnect()`` in a bounded child task. If the current + polling-recovery owner remains in ``_polling_error_task``, Telegram teardown + cancels that parent while it is still awaiting the fatal handler, so the + handler never gets to queue background reconnection. + """ + config = GatewayConfig( + platforms={ + Platform.TELEGRAM: PlatformConfig(enabled=True, token="test-token") + }, + sessions_dir=tmp_path / "sessions", + ) + runner = GatewayRunner(config) + adapter = _make_adapter() + adapter._polling_network_error_count = 10 # MAX_NETWORK_RETRIES + adapter.set_fatal_error_handler(runner._handle_adapter_fatal_error) + runner.adapters = {Platform.TELEGRAM: adapter} + runner.delivery_router.adapters = runner.adapters + + recovery_task = asyncio.create_task( + adapter._handle_polling_network_error(Exception("still failing")) + ) + adapter._polling_error_task = recovery_task + result = await asyncio.gather(recovery_task, return_exceptions=True) + + assert result == [None] + assert runner.adapters == {} + assert Platform.TELEGRAM in runner._failed_platforms + assert runner._failed_platforms[Platform.TELEGRAM]["attempts"] == 0 + + # --------------------------------------------------------------------------- # Connection pool drain tests (PR #16466 salvage) # --------------------------------------------------------------------------- From 087732c8c60860888f6c8ac8b9e22271d5269e96 Mon Sep 17 00:00:00 2001 From: kshitijk4poor <82637225+kshitijk4poor@users.noreply.github.com> Date: Tue, 21 Jul 2026 11:54:33 +0530 Subject: [PATCH 67/72] fix(telegram): widen fatal handoff to heartbeat watchdog path The wedged-recovery heartbeat watchdog (line 2526) calls _notify_fatal_error() directly from the heartbeat task. disconnect() cancels _polling_heartbeat_task unconditionally (no current_task guard, unlike _polling_error_task). Same bug class as #68406: the child disconnect cancels the heartbeat parent before the runner can queue reconnect. Widen _handoff_polling_fatal_error() to also clear _polling_heartbeat_task when it is the current task, and route the heartbeat watchdog call site through the handoff helper. Co-authored-by: Imgaojp <6065749+Imgaojp@users.noreply.github.com> --- plugins/platforms/telegram/adapter.py | 14 ++++---- .../test_telegram_network_reconnect.py | 36 +++++++++++++++++++ 2 files changed, 44 insertions(+), 6 deletions(-) diff --git a/plugins/platforms/telegram/adapter.py b/plugins/platforms/telegram/adapter.py index 4bfb685b09bc..5e158dec030e 100644 --- a/plugins/platforms/telegram/adapter.py +++ b/plugins/platforms/telegram/adapter.py @@ -2523,7 +2523,7 @@ class TelegramAdapter(BasePlatformAdapter): "gateway reconnect." % stuck_for, retryable=True, ) - await self._notify_fatal_error() + await self._handoff_polling_fatal_error() return else: stuck_task_ref = None @@ -2988,15 +2988,17 @@ class TelegramAdapter(BasePlatformAdapter): """Notify the runner without letting child teardown cancel this owner. The runner bounds adapter cleanup in a child task. ``disconnect()`` - cancels the tracked polling-recovery task, so retaining the current - notifier in ``_polling_error_task`` would cancel the fatal callback - before the runner can finish its reconnect or shutdown decision. - Release only the current owner; unrelated recovery tasks remain under - teardown control. + cancels the tracked polling-recovery task and the heartbeat task, so + retaining the current notifier in either field would cancel the fatal + callback before the runner can finish its reconnect or shutdown + decision. Release only the current owner from whichever field tracks + it; unrelated tasks remain under teardown control. """ current_task = asyncio.current_task() if self._polling_error_task is current_task: self._polling_error_task = None + if getattr(self, "_polling_heartbeat_task", None) is current_task: + self._polling_heartbeat_task = None await self._notify_fatal_error() async def _create_dm_topic( diff --git a/tests/gateway/test_telegram_network_reconnect.py b/tests/gateway/test_telegram_network_reconnect.py index c2406e0a88e0..4b30ef68839f 100644 --- a/tests/gateway/test_telegram_network_reconnect.py +++ b/tests/gateway/test_telegram_network_reconnect.py @@ -259,6 +259,42 @@ async def test_retry_exhaustion_queues_reconnect_before_child_disconnect(tmp_pat assert runner._failed_platforms[Platform.TELEGRAM]["attempts"] == 0 +@pytest.mark.asyncio +async def test_heartbeat_watchdog_handoff_survives_child_disconnect(tmp_path): + """The wedged-recovery heartbeat watchdog must survive its fatal callback. + + The heartbeat loop force-escalates a stuck polling-recovery task. Like + the network/conflict terminal paths, the heartbeat task itself is the + owner that ``disconnect()`` cancels, so the fatal callback must release + ``_polling_heartbeat_task`` before notifying the runner. + """ + config = GatewayConfig( + platforms={ + Platform.TELEGRAM: PlatformConfig(enabled=True, token="test-token") + }, + sessions_dir=tmp_path / "sessions", + ) + runner = GatewayRunner(config) + adapter = _make_adapter() + adapter.set_fatal_error_handler(runner._handle_adapter_fatal_error) + runner.adapters = {Platform.TELEGRAM: adapter} + runner.delivery_router.adapters = runner.adapters + + # Simulate the heartbeat watchdog's fatal-escalation path directly. + adapter._set_fatal_error( + "telegram_network_error", + "Telegram reconnect task wedged; forcing gateway reconnect.", + retryable=True, + ) + heartbeat_task = asyncio.create_task(adapter._handoff_polling_fatal_error()) + adapter._polling_heartbeat_task = heartbeat_task + result = await asyncio.gather(heartbeat_task, return_exceptions=True) + + assert result == [None] + assert runner.adapters == {} + assert Platform.TELEGRAM in runner._failed_platforms + + # --------------------------------------------------------------------------- # Connection pool drain tests (PR #16466 salvage) # --------------------------------------------------------------------------- From 7a8852ddcb008523a6ea8e8acf3f22b903871495 Mon Sep 17 00:00:00 2001 From: PRATHAMESH75 Date: Tue, 21 Jul 2026 07:11:06 +0530 Subject: [PATCH 68/72] fix(tests): make the live-system-guard canary fail closed MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit tests/test_live_system_guard_self_test.py executes real kill primitives (os.kill(-1, SIGTERM), os.killpg, pkill -f python) and depends entirely on the autouse _live_system_guard fixture in tests/conftest.py to intercept them. That makes the canary fail-OPEN: in any collection context where the file is present but its home conftest is not — a published sdist that ships tests/ but not tests/conftest.py, a tree assembled by copying test*.py (that glob does not match conftest.py), pytest --noconftest, or a foreign rootdir — the primitives fire for real, and os.kill(-1, SIGTERM) SIGTERMs every process the invoking user owns (a full desktop-session kill was reported in the field). Add an autouse fixture that refuses to run any canary test unless the guard is provably active. The one thing the canary can detect about its own safety is that the guard monkeypatches os.kill with a plain Python function, whereas the unguarded primitive is a C builtin — so the probe keys off that. Tests marked @pytest.mark.live_system_guard_bypass still opt out, matching the guard's own bypass contract (e.g. test_bypass_marker_disables_guard). With the guard loaded every canary test behaves exactly as before; without it each test refuses at setup with zero side effects. Fixes #68311 --- tests/test_live_system_guard_self_test.py | 74 +++++++++++++++++++++++ 1 file changed, 74 insertions(+) diff --git a/tests/test_live_system_guard_self_test.py b/tests/test_live_system_guard_self_test.py index 3bbe8c9f3b0c..0347d8510014 100644 --- a/tests/test_live_system_guard_self_test.py +++ b/tests/test_live_system_guard_self_test.py @@ -20,6 +20,7 @@ from __future__ import annotations import os import signal import subprocess +import types import pytest @@ -28,6 +29,79 @@ import pytest FOREIGN_PID = 1 +# ──────────────────── fail-closed self-protection ────────────── +# +# This file executes REAL kill primitives — os.kill(-1, SIGTERM), os.killpg, +# pkill -f python — and depends entirely on the autouse ``_live_system_guard`` +# fixture in tests/conftest.py to intercept them. That makes the canary +# fail-OPEN: in any collection context where this file is present but its home +# conftest is not, the primitives fire for real and ``os.kill(-1, SIGTERM)`` +# SIGTERMs every process the invoking user owns (a full desktop-session kill was +# reported in the field — see issue #68311). Such contexts are not exotic: +# published sdists that ship ``tests/`` but not ``tests/conftest.py``, trees +# assembled by copying ``test*.py`` files (that glob does NOT match +# ``conftest.py``), ``pytest --noconftest``, or running from a foreign rootdir. +# +# The fixture below makes the canary fail-CLOSED instead: it refuses to run any +# test in this file unless the guard is provably active, so no collection +# context can ever detonate the primitives. The one thing the canary can detect +# about its own safety is that the guard monkeypatches ``os.kill`` with a plain +# Python function, whereas the unguarded primitive is a C builtin. + + +def _live_system_guard_is_active() -> bool: + """True iff tests/conftest.py's ``_live_system_guard`` has patched os.kill. + + The guard replaces ``os.kill`` with a plain Python function; the raw, + unguarded primitive is a C builtin (``types.BuiltinFunctionType``). If + ``os.kill`` is still the builtin, the guard never loaded and every kill + primitive in this file would fire for real. + """ + return not isinstance(os.kill, types.BuiltinFunctionType) + + +@pytest.fixture(autouse=True) +def _refuse_to_fire_live_weapons(request): + """Fail closed: refuse to run a canary test unless the guard is active. + + Tests genuinely marked ``@pytest.mark.live_system_guard_bypass`` opt out + (they run the raw primitive deliberately and harmlessly, e.g. a signal-0 + liveness probe of our own PID), matching the guard's own bypass contract. + """ + if request.node.get_closest_marker("live_system_guard_bypass"): + yield + return + if not _live_system_guard_is_active(): + pytest.fail( + "REFUSING TO RUN: the live-system guard from tests/conftest.py is " + "not active in this interpreter (os.kill is still the raw C " + "builtin). This canary file executes real kill primitives — " + "os.kill(-1, SIGTERM), os.killpg, pkill -f python — and relies on " + "the guard to intercept them; unguarded, they SIGTERM every process " + "the current user owns. This usually means the file was collected " + "without its home tests/conftest.py (note: a test*.py copy glob " + "does NOT match conftest.py). See issue #68311.", + pytrace=False, + ) + yield + + +def test_fail_closed_probe_reports_guard_active(): + """In the real suite the guard is loaded, so the probe reports active and + ``_refuse_to_fire_live_weapons`` stays out of the way (no false positives + that would wedge CI).""" + assert _live_system_guard_is_active() is True + + +def test_fail_closed_probe_classifies_raw_builtin_as_unguarded(): + """The probe's discriminator, exercised against real objects: a raw C + builtin the guard never touches (``os.getpid``) is exactly what an + unguarded ``os.kill`` looks like and must read as 'guard not active', while + the loaded guard's ``os.kill`` is a plain Python function.""" + assert isinstance(os.getpid, types.BuiltinFunctionType) + assert not isinstance(os.kill, types.BuiltinFunctionType) + + # ──────────────────── kill primitives ───────────────────────── From b0da653ac827e24362efc4e5b052457c2177018d Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:20:25 +0530 Subject: [PATCH 69/72] fix(billing): rename user-facing "terminal billing" copy to Remote Spending (#68355) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * fix(billing): rename user-facing "terminal billing" copy to Remote Spending The capability was renamed Remote Spending on the portal (consent CTA: "Allow Remote Spending"; per-terminal states Granted/Stopped), but the terminal, desktop, and docs still said "terminal billing" everywhere. - Feature name: Remote Spending in titles/labels, lowercase mid-sentence. - Step-up action verb is now "allow", matching the portal consent CTA. - Kill-switch-off recovery copy points at the actual control ("a billing admin can turn it on from the portal's Hermes Agent page") instead of the dead-end "manage it on the portal". - Per-terminal revoke copy uses the portal vocabulary ("stopped"). - Wire identifiers (cli_billing_enabled, cli_billing_disabled, ...) are unchanged; copy, comments, docs, and test expectations only. * fix(billing): correct the post-step-up denial diagnosis + finish the desktop rename Adversarial review findings: (1) a repeated insufficient_scope after a successful step-up is a per-terminal authorization failure, but the copy blamed the org kill-switch and pointed at the wrong recovery control — now: "Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal." (2) the desktop step-up flow started in Remote Spending vocabulary but finished in "billing management access" — renamed both end states. (3) prettier formatting on the touched files (matches the post-merge fmt bot). --- agent/billing_view.py | 2 +- .../src/app/settings/billing/errors.ts | 15 ++-- .../src/app/settings/billing/index.test.tsx | 10 +-- .../billing/use-billing-state.test.ts | 2 +- .../app/settings/billing/use-billing-state.ts | 2 +- .../src/app/settings/billing/use-step-up.ts | 4 +- apps/shared/src/billing-types.ts | 4 +- docs/billing-lifecycle.md | 17 ++-- gateway/slash_commands.py | 6 +- hermes_cli/auth.py | 2 +- hermes_cli/cli_billing_mixin.py | 86 ++++++++++--------- hermes_cli/nous_billing.py | 12 +-- tests/agent/test_billing_view.py | 2 +- tests/hermes_cli/test_billing_cli.py | 6 +- tests/hermes_cli/test_subscription_cli.py | 15 ++-- tui_gateway/server.py | 2 +- ui-tui/README.md | 6 +- ui-tui/scripts/billing-fixtures.tsx | 2 +- ui-tui/src/__tests__/billingStepUp.test.tsx | 8 +- .../__tests__/subscriptionOverlay.test.tsx | 6 +- ui-tui/src/__tests__/topupCommand.test.ts | 6 +- ui-tui/src/app/createGatewayEventHandler.ts | 2 +- ui-tui/src/app/interfaces.ts | 14 +-- ui-tui/src/app/slash/commands/topup.ts | 16 ++-- ui-tui/src/components/billingOverlay.tsx | 28 +++--- ui-tui/src/components/subscriptionOverlay.tsx | 22 ++--- ui-tui/src/gatewayTypes.ts | 2 +- website/docs/reference/slash-commands.md | 2 +- 28 files changed, 159 insertions(+), 142 deletions(-) diff --git a/agent/billing_view.py b/agent/billing_view.py index 0e9930fd3ea6..a535aee1f142 100644 --- a/agent/billing_view.py +++ b/agent/billing_view.py @@ -1,4 +1,4 @@ -"""Surface-agnostic core for the Phase 2b terminal-billing screens. +"""Surface-agnostic core for the Phase 2b Remote Spending screens. One fetch/parse per concern, consumed identically by the CLI handler (``cli.py::_show_billing``), the TUI JSON-RPC methods diff --git a/apps/desktop/src/app/settings/billing/errors.ts b/apps/desktop/src/app/settings/billing/errors.ts index f368a9e0abc1..0357bf424421 100644 --- a/apps/desktop/src/app/settings/billing/errors.ts +++ b/apps/desktop/src/app/settings/billing/errors.ts @@ -32,19 +32,19 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat case 'insufficient_scope': return { action: { type: 'step_up' }, - message: 'This needs terminal billing enabled. Start a top-up to enable it, then retry.', - title: 'Terminal billing needs approval' + message: 'This needs Remote Spending allowed. Start a top-up to allow it, then retry.', + title: 'Remote Spending needs approval' } case 'remote_spending_revoked': { const who = refusal.actor === 'admin' - ? 'An admin turned off terminal billing for this terminal.' - : 'You turned off terminal billing for this terminal.' + ? 'An admin stopped remote spending for this terminal.' + : 'You stopped remote spending for this terminal.' return { action: portalAction(refusal.portalUrl), message: `${who} Reconnect from Settings → Gateway to re-authorize this device.`, - title: 'Terminal billing was turned off' + title: 'Remote spending was stopped' } } @@ -60,8 +60,9 @@ export const resolveRefusal = (refusal: BillingRefusal): BillingRefusalPresentat case 'remote_spending_disabled': return { action: portalAction(refusal.portalUrl), - message: 'Terminal billing is off for this account — an admin must enable it on the portal.', - title: 'Terminal billing is off' + message: + "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.", + title: 'Remote spending is off' } case 'role_required': diff --git a/apps/desktop/src/app/settings/billing/index.test.tsx b/apps/desktop/src/app/settings/billing/index.test.tsx index 27b702d99d34..29b8f95e82ab 100644 --- a/apps/desktop/src/app/settings/billing/index.test.tsx +++ b/apps/desktop/src/app/settings/billing/index.test.tsx @@ -74,7 +74,9 @@ describe('BillingSettings', () => { expect(screen.getByText('Ultra · $200/mo')).toBeTruthy() expect(screen.getByText('Visa •••• 3206')).toBeTruthy() expect( - screen.getByText('Terminal billing is off for this account — an admin must enable it on the portal.') + screen.getByText( + "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page." + ) ).toBeTruthy() expect(screen.queryByRole('button', { name: '$100' })).toBeNull() expect(screen.getByText('Refill $10 when balance falls below $5')).toBeTruthy() @@ -197,10 +199,8 @@ describe('BillingSettings', () => { }) fireEvent.click(screen.getByRole('button', { name: 'Save' })) - expect(await screen.findByText('Terminal billing needs approval:')).toBeTruthy() - expect( - screen.getByText('This needs terminal billing enabled. Start a top-up to enable it, then retry.') - ).toBeTruthy() + expect(await screen.findByText('Remote Spending needs approval:')).toBeTruthy() + expect(screen.getByText('This needs Remote Spending allowed. Start a top-up to allow it, then retry.')).toBeTruthy() expect(screen.getByRole('button', { name: 'Verify to continue' })).toBeTruthy() }) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index cd7c74507a98..f87d9f4fe108 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -65,7 +65,7 @@ describe('deriveBillingView', () => { const buyCredits = view.accountRows.find(row => row.id === 'buy_credits') expect(buyCredits?.description).toBe( - 'Terminal billing is off for this account — an admin must enable it on the portal.' + "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page." ) expect(buyCredits?.chips).toBeUndefined() expect(view.accountRows.find(row => row.id === 'auto_reload')).toMatchObject({ diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 33ee75e77fb9..870af163670f 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -458,7 +458,7 @@ function deriveUsageRows( value: clamp01(usedFraction) } : undefined, - caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly terminal billing spend', + caption: cap.is_default_ceiling ? 'Default ceiling' : 'Monthly remote spending', id: 'monthly_cap', title: 'Monthly spend cap', value diff --git a/apps/desktop/src/app/settings/billing/use-step-up.ts b/apps/desktop/src/app/settings/billing/use-step-up.ts index 7658dc917567..2966f602a41a 100644 --- a/apps/desktop/src/app/settings/billing/use-step-up.ts +++ b/apps/desktop/src/app/settings/billing/use-step-up.ts @@ -121,7 +121,7 @@ export function useStepUpFlow() { if (!result.data.granted) { setMessage({ kind: 'error', - text: 'Verification finished without granting billing management access.', + text: 'Verification finished without allowing Remote Spending for this terminal.', title: 'Verification was not approved' }) @@ -134,7 +134,7 @@ export function useStepUpFlow() { ]) setMessage({ kind: 'success', - text: 'Billing management access was verified.', + text: 'Remote Spending is allowed for this terminal.', title: 'Verification complete' }) }, [api, gateway, queryClient, unsubscribe]) diff --git a/apps/shared/src/billing-types.ts b/apps/shared/src/billing-types.ts index d06e24bcba50..33004746c81b 100644 --- a/apps/shared/src/billing-types.ts +++ b/apps/shared/src/billing-types.ts @@ -1,12 +1,12 @@ /** - * Shared terminal-billing wire contracts. + * Shared Remote Spending wire contracts. * * These shapes round-trip between the Python tui_gateway and TypeScript clients * such as the TUI and desktop app. Keep rendering state, client logic, and the * gateway event union out of this runtime-free module. */ -// ── Terminal billing (Phase 2b) ────────────────────────────────────── +// ── Remote Spending (Phase 2b) ─────────────────────────────────────── /** One serialized usage bar (mirrors server `_serialize_usage_bar`). */ export interface UsageBarData { diff --git a/docs/billing-lifecycle.md b/docs/billing-lifecycle.md index 53151684214b..8562c90e8368 100644 --- a/docs/billing-lifecycle.md +++ b/docs/billing-lifecycle.md @@ -30,7 +30,7 @@ Source: `ui-tui/src/components/billingOverlay.tsx` (`OverviewScreen`, | `monthly_cap` present, `limit_usd != null` | `{spent_display} of {limit_display} used this month` (+ ` (default ceiling)` iff `is_default_ceiling`). | | `monthly_cap` absent or `limit_usd == null` | `No monthly cap visible (managed on the portal).` | | Role without billing capability (`!is_admin`, menu collapses) | Note: `Billing actions need someone with billing permissions (owner, admin, or finance admin).` Menu collapses to `Manage on portal` / `Cancel`. | -| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Terminal billing is off for this org — manage it on the portal.` Same collapsed menu. | +| Org kill-switch off (`is_admin` but `!cli_billing_enabled`) | Note: `Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page.` Same collapsed menu. | Note: `full = s.is_admin && s.cli_billing_enabled` gates the **org-level** switch, not the per-terminal `billing:manage` scope — that's discovered @@ -44,10 +44,10 @@ Source: `renderBillingError` in `ui-tui/src/app/slash/commands/topup.ts:37-149`. | `error` code | Copy | Portal URL | `retry_after` | |---|---|:-:|:-:| -| `insufficient_scope` | `This needs terminal billing enabled. Start a top-up to enable it, then retry.` | if present | — | -| `remote_spending_revoked` (CF-4) | `{An admin turned off terminal billing for this terminal. \| You turned off terminal billing for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — | +| `insufficient_scope` | `This needs Remote Spending allowed. Start a top-up to allow it, then retry.` | if present | — | +| `remote_spending_revoked` (CF-4) | `{An admin stopped remote spending for this terminal. \| You stopped remote spending for this terminal.}` (by `actor`) `Reconnect to restore — run /portal to re-authorize this terminal.` Also clears `billing` overlay state immediately (doesn't wait for token refresh). | if present | — | | `session_revoked` | `Your session was logged out. Run /portal to log in again.` Also clears `billing` overlay state. | if present | — | -| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Terminal billing is off for this account — an admin must enable it on the portal.` | if present | — | +| `cli_billing_disabled` / `remote_spending_disabled` (dual-emitted) | `Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.` | if present | — | | `role_required` | `Adding funds needs someone with billing permissions (owner, admin, or finance admin), or manage this on the portal.` | if present | — | | `consent_required` | `This action needs a one-time card confirmation and consent step on the portal before it can proceed.` | if present | — | | `org_access_denied` | `This token isn't bound to an org you can manage. Sign in with the right org, or manage this on the portal.` | if present | — | @@ -136,14 +136,15 @@ just because NAS hasn't caught up yet. | `error` | Copy | |---|---| | `session_revoked` | `Your session expired — run /portal to log in again, then retry the change.` | -| `remote_spending_revoked` | `{message}` or `Terminal spending was turned off for this session — reconnect from the portal, then retry.` | +| `remote_spending_revoked` | `{message}` or `Remote spending was stopped for this terminal — reconnect from the portal, then retry.` | | `rate_limited` | `Too many attempts — wait a moment, then try again.` | -| other/unknown | `{message}` or `Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.` | +| other/unknown | `{message}` or `Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal.` | A **repeat** scope denial during a post-grant replay never re-enters the step-up screen (it's already mounted there — re-patching would freeze it); -`allowStepUp=false` instead surfaces a terminal result: `Terminal billing -still isn't enabled for this org — enable it on the portal, then retry.` +`allowStepUp=false` instead surfaces a terminal result: `Remote Spending still +isn’t active for this terminal — the authorization didn’t take. Retry, or make +this change on the portal.` ## Text-mode (CLI) parity diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index a2da10556c8e..f041f45d8b5d 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -4131,9 +4131,9 @@ class GatewaySlashCommandsMixin: """Handle /topup -- show the Nous balance and hand off to the portal. Renders the balance block + identity line + a tappable portal URL that - opens the billing page. Terminal billing is managed on the portal: the - terminal does NOT charge, confirm, or track payment here — everything - happens in the browser and the next /topup shows the new balance. The + opens the billing page. Remote spending is managed on the portal: this + messaging command does NOT charge, confirm, or track payment here — + everything happens in the browser and the next /topup shows the new balance. The tappable URL is the affordance and works on every platform (button-capable or plain text like SMS/email). Fetched off the event loop; fail-open. """ diff --git a/hermes_cli/auth.py b/hermes_cli/auth.py index 13f7cf362f6b..7a6db664bc16 100644 --- a/hermes_cli/auth.py +++ b/hermes_cli/auth.py @@ -8154,7 +8154,7 @@ def step_up_nous_billing_scope( The lazy step-up (plan D-A): triggered when a billing endpoint returns ``403 insufficient_scope``. Runs a fresh device-connect with ``inference:invoke tool:invoke billing:manage`` on the scope. The user must be - an ADMIN/OWNER and tick "Allow terminal billing" in the portal for the minted + an ADMIN/OWNER and select "Allow Remote Spending" in the portal for the minted token to actually carry the scope; otherwise the server silently downscopes and this returns False. diff --git a/hermes_cli/cli_billing_mixin.py b/hermes_cli/cli_billing_mixin.py index 49b89fa77b93..bb9edd95267a 100644 --- a/hermes_cli/cli_billing_mixin.py +++ b/hermes_cli/cli_billing_mixin.py @@ -382,7 +382,7 @@ class CLIBillingMixin: if allow_stepup: self._subscription_handle_scope_required(state, retry=("preview", tier_id)) else: - print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") + print(" Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal.") return except BillingError as exc: self._subscription_render_error(state, exc) @@ -562,12 +562,12 @@ class CLIBillingMixin: if allow_stepup: self._subscription_handle_scope_required(state, retry=action, idempotency_key=key) else: - print(" Terminal billing still isn't enabled for this org — enable it on the portal, then retry.") + print(" Remote Spending still isn't active for this terminal — the authorization didn't take. Retry, or make this change on the portal.") except BillingError as exc: self._subscription_render_error(state, exc) def _subscription_handle_scope_required(self, state, *, retry, idempotency_key=None): - """insufficient_scope → grant terminal billing (step-up), then replay `retry`. + """insufficient_scope → allow remote spending (step-up), then replay `retry`. Mirrors _billing_handle_scope_required: the classic CLI calls step_up_nous_billing_scope directly (it opens the browser + blocks), then @@ -577,34 +577,34 @@ class CLIBillingMixin: print() print(" ! One-time setup") - _cprint(f" {_d('To change your plan from the terminal, enable terminal billing once. It opens your browser to authorize, then your change picks up right here.')}") + _cprint(f" {_d('To change your plan from the terminal, allow Remote Spending once. It opens your browser to authorize, then your change picks up right here.')}") if not getattr(self, "_app", None): - print(" Run `hermes portal` and enable terminal billing, then re-run /subscription.") + print(" Run `hermes portal` and allow Remote Spending, then re-run /subscription.") return confirm_choices = [ - ("yes", "Enable terminal billing", "open your browser to authorize"), + ("yes", "Allow Remote Spending", "open your browser to authorize"), ("no", "Not now", "cancel"), ] raw = self._prompt_text_input_modal( - title="Enable terminal billing", + title="Allow Remote Spending", detail="Opens your browser to authorize this terminal.", choices=confirm_choices, ) if self._normalize_slash_confirm_choice(raw, confirm_choices) != "yes": - print(" No change made. Enable terminal billing when you're ready.") + print(" No change made. Allow Remote Spending when you're ready.") return - print(" Opening your browser to enable terminal billing…") + print(" Opening your browser to allow Remote Spending…") try: from hermes_cli.auth import step_up_nous_billing_scope granted = step_up_nous_billing_scope(open_browser=True) except Exception as exc: - print(f" Couldn't enable terminal billing: {exc}") + print(f" Couldn't allow Remote Spending: {exc}") return if not granted: - print(" Couldn't enable terminal billing — an org admin or owner has to approve it for this org.") + print(" Couldn't allow Remote Spending — an org admin or owner has to approve it for this org.") return - _cprint(f" {_DIM}✓ Terminal billing enabled.{_RST}") + _cprint(f" {_DIM}✓ Remote Spending allowed.{_RST}") # Bust the 30s token cache so the replay uses the freshly-scoped token. The # cache still holds the pre-grant unscoped token, and _request only busts it # on a 401 (not a 403 scope denial) — without this, the replay would 403 @@ -637,7 +637,7 @@ class CLIBillingMixin: msg = str(exc) or "Something went wrong." if code == "insufficient_scope": # Defensive: the flow routes scope to the step-up before reaching here. - _cprint(" 🟡 Terminal billing isn't enabled. Enable it, then retry.") + _cprint(" 🟡 Remote Spending isn't allowed yet. Allow it, then retry.") elif code in ("subscription_mutation_rejected", "preview_rejected"): _cprint(f" 🟡 {msg}") else: @@ -661,11 +661,11 @@ class CLIBillingMixin: _cprint(f" Portal: {_url}") # ------------------------------------------------------------------ - # /billing — Phase 2b terminal billing (CLI surface, all 5 screens) + # /billing — Phase 2b Remote Spending (CLI surface, all 5 screens) # ------------------------------------------------------------------ def _show_billing(self, command: str = "/topup"): - """`/topup` — terminal billing for Nous (one interactive modal). + """`/topup` — Remote Spending for Nous (one interactive modal). ZERO sub-commands: any argument is ignored. Bare ``/topup`` always opens the Overview (Screen 1), whose numbered menu is the *only* way to @@ -710,7 +710,7 @@ class CLIBillingMixin: Dollars-only (no "credits") — mirrors the TUI /topup overlay: balance leads in the title, the shared plan + top-up bars render below, then the - reordered menu (Add funds first). No scope preflight — terminal billing + reordered menu (Add funds first). No scope preflight — remote spending is discovered reactively when a charge 403s insufficient_scope. """ from cli import _cprint, _b, _d @@ -762,8 +762,11 @@ class CLIBillingMixin: self._billing_portal_hint(state) return if not state.cli_billing_enabled: - _cprint(f" {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal to add funds here.") + _cprint(f" {_d('Remote spending is off for this org.')}") + self._billing_portal_hint( + state, + reason="A billing admin can turn it on from the portal's Hermes Agent page to add funds here.", + ) return # A missing card does NOT gate the whole overview — the org may already have @@ -778,7 +781,7 @@ class CLIBillingMixin: return # Add funds first, then settings, then the scopeless browser handoff. - # No "Enable terminal billing" item — that's discovered at pay time. + # No "Allow Remote Spending" item — that's discovered at pay time. # "Add funds" charges in-terminal against the org's portal-saved card # (server-held via POST /charge — no card ref leaves the client). A # missing card is NOT gated here: the buy flow reacts to the server's @@ -856,8 +859,11 @@ class CLIBillingMixin: return False if not state.cli_billing_enabled: print() - _cprint(f" 💳 {_d('Terminal billing is turned off for this org.')}") - self._billing_portal_hint(state, reason="Enable it on the portal first.") + _cprint(f" 💳 {_d('Remote spending is off for this org.')}") + self._billing_portal_hint( + state, + reason="A billing admin can turn it on from the portal's Hermes Agent page before adding funds.", + ) return False return True @@ -1033,7 +1039,7 @@ class CLIBillingMixin: try: result = post_charge(amount_usd=amount, idempotency_key=key) except BillingScopeRequired: - # In-flight reauth: enable terminal billing, then resume THIS charge + # In-flight reauth: allow remote spending, then resume THIS charge # (press-Enter beat) — no command re-run. Reuses the same idem key. self._billing_handle_scope_required(state, amount=amount, idempotency_key=key) return @@ -1129,7 +1135,7 @@ class CLIBillingMixin: print(" 💳 No card on file — top up and manage billing on the portal.") elif code in ("cli_billing_disabled", "remote_spending_disabled") or \ getattr(exc, "code", None) == "remote_spending_disabled": - print(" Terminal billing is off for this account — an admin must enable it on the portal.") + print(" Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page.") elif code == "role_required": print(" Adding funds needs an org admin/owner. Ask an admin, or manage on the portal.") elif code == "idempotency_conflict": @@ -1146,8 +1152,8 @@ class CLIBillingMixin: print(f" 🟡 Too many charges right now{mins}. This isn't a payment failure.") elif code == "insufficient_scope": # Never leak the raw billing:manage scope (the post-grant replay can - # re-raise it if the grant raced) — the concept is "terminal billing". - print(" 🔴 Terminal billing needs approval — run /topup to enable it, then retry.") + # re-raise it if the grant raced) — the concept is "Remote Spending". + print(" 🔴 Remote Spending needs approval — run /topup to allow it, then retry.") else: print(f" 🔴 {exc}") if portal_url: @@ -1156,9 +1162,9 @@ class CLIBillingMixin: def _billing_handle_scope_required(self, state, *, amount=None, idempotency_key=None): """403 insufficient_scope → in-flight reauth, then resume the held charge. - The buy path discovers terminal billing isn't enabled only when the - charge 403s — there is no preflight. We enable it in-flight ("Enable - terminal billing" → browser device-flow), then on return ask the user to + The buy path discovers remote spending isn't allowed only when the + charge 403s — there is no preflight. We allow it in-flight ("Allow + Remote Spending" → browser device-flow), then on return ask the user to press Enter to resume the held ``amount`` (reusing ``idempotency_key`` so the resumed charge collapses with the original). Never leaks the raw billing:manage scope. @@ -1170,33 +1176,33 @@ class CLIBillingMixin: amount_str = format_money(amount) if amount is not None else "your top-up" print() print(" ! One-time setup") - _cprint(f" {_d(f'To charge this terminal, enable terminal billing once. It opens your browser to authorize, then {amount_str} picks up right here.')}") + _cprint(f" {_d(f'To charge from this terminal, allow Remote Spending once. It opens your browser to authorize, then {amount_str} picks up right here.')}") if not getattr(self, "_app", None): - print(" Run `hermes portal` and enable terminal billing, then retry.") + print(" Run `hermes portal` and allow Remote Spending, then retry.") return confirm_choices = [ - ("yes", "Enable terminal billing", "open your browser to authorize"), + ("yes", "Allow Remote Spending", "open your browser to authorize"), ("no", "Not now", "cancel"), ] raw = self._prompt_text_input_modal( - title="Enable terminal billing", + title="Allow Remote Spending", detail="Opens your browser to authorize this terminal.", choices=confirm_choices, ) choice = self._normalize_slash_confirm_choice(raw, confirm_choices) if choice != "yes": - print(" No charge made. Run /topup when you want to enable terminal billing.") + print(" No charge made. Run /topup when you want to allow Remote Spending.") return - print(" Opening your browser to enable terminal billing…") + print(" Opening your browser to allow Remote Spending…") try: from hermes_cli.auth import step_up_nous_billing_scope granted = step_up_nous_billing_scope(open_browser=True) except Exception as exc: - print(f" Couldn't enable terminal billing: {exc}") + print(f" Couldn't allow Remote Spending: {exc}") return if not granted: - print(" Couldn't enable terminal billing — an org admin or owner has to approve it. Your card was not charged.") + print(" Couldn't allow Remote Spending — an org admin or owner has to approve it. Your card was not charged.") return # Granted. The token now carries the scope, but the ORG kill-switch @@ -1206,7 +1212,7 @@ class CLIBillingMixin: fresh = build_billing_state() if not (fresh.logged_in and fresh.cli_billing_enabled): - print(" Terminal billing was enabled for this terminal, but it's still turned off for this org. Enable it in the portal, then run /topup again.") + print(" Remote Spending is allowed for this terminal, but it's still off for this org. A billing admin can turn it on from the portal's Hermes Agent page, then run /topup again.") self._billing_portal_hint(fresh) return @@ -1214,7 +1220,7 @@ class CLIBillingMixin: # file. If there's none, this is a half-done state: say so and route to the # portal to top up / manage billing, rather than a bare "✓ enabled" that reads as done. if fresh.card is None: - print(" ✓ Terminal billing enabled — but there's no card on file yet.") + print(" ✓ Remote Spending allowed — but there's no card on file yet.") _cprint(f" {_d('Top up and manage billing on the portal to continue.')}") self._billing_portal_hint(fresh) return @@ -1222,12 +1228,12 @@ class CLIBillingMixin: # Nothing to resume (scope-required hit outside a charge, e.g. auto-reload # config) → just tell the user it's ready. if amount is None: - print(" ✓ Terminal billing enabled. Run /topup to continue.") + print(" ✓ Remote Spending allowed. Run /topup to continue.") return # Press-Enter beat: the user is back from the browser; resume the held # purchase on an explicit confirm (reassuring, not silent). - print(" ✓ Terminal billing enabled.") + print(" ✓ Remote Spending allowed.") resume_choices = [ ("resume", f"Resume {format_money(amount)} top-up", "finish the held purchase"), ("cancel", "Cancel", "do not charge"), diff --git a/hermes_cli/nous_billing.py b/hermes_cli/nous_billing.py index a2c59a317f8d..0ad745dcb96b 100644 --- a/hermes_cli/nous_billing.py +++ b/hermes_cli/nous_billing.py @@ -1,4 +1,4 @@ -"""Nous Portal terminal-billing HTTP client (Phase 2b). +"""Nous Portal Remote Spending HTTP client (Phase 2b). Thin, fail-loud client for the four ``/api/billing/*`` endpoints the terminal billing screens drive. Companion to ``hermes_cli/nous_account.py`` (which owns @@ -90,8 +90,8 @@ class BillingScopeRequired(BillingError): """``403 insufficient_scope`` — the held token lacks ``billing:manage``. The lazy step-up trigger: catching this kicks off a fresh device-connect that - requests ``billing:manage`` (and tells the user an ADMIN must tick "Allow - terminal billing"). Also fires mid-session if the scope is stripped on refresh + requests ``billing:manage`` (and tells the user an ADMIN must select "Allow + Remote Spending"). Also fires mid-session if the scope is stripped on refresh after the user loses ADMIN. """ @@ -363,11 +363,11 @@ def _raise_for_error( ) raise BillingAuthError(message or "Authentication required.", **common) if status == 403: - # This terminal's spending was revoked (NOT the same as never having the - # scope). Disable spend UI immediately; recovery is reconnect. + # Remote spending was stopped for this terminal (NOT the same as never + # having the scope). Disable spend UI immediately; recovery is reconnect. if error == "remote_spending_revoked": raise BillingRemoteSpendingRevoked( - message or "Remote Spending was revoked for this terminal.", **common + message or "Remote spending was stopped for this terminal.", **common ) if error == "insufficient_scope": raise BillingScopeRequired( diff --git a/tests/agent/test_billing_view.py b/tests/agent/test_billing_view.py index 596570f0f574..89824fa32610 100644 --- a/tests/agent/test_billing_view.py +++ b/tests/agent/test_billing_view.py @@ -1,4 +1,4 @@ -"""Unit tests for the Phase 2b terminal-billing core + HTTP client. +"""Unit tests for the Phase 2b Remote Spending core + HTTP client. Covers: - Decimal money parsing/formatting (server emits decimal strings, not 2dp). diff --git a/tests/hermes_cli/test_billing_cli.py b/tests/hermes_cli/test_billing_cli.py index 6c1a7c3b6b08..d90dfb9c26bc 100644 --- a/tests/hermes_cli/test_billing_cli.py +++ b/tests/hermes_cli/test_billing_cli.py @@ -81,7 +81,11 @@ def test_billing_killswitch_off_blocks(cli, monkeypatch, capsys): monkeypatch.setattr(bv, "build_billing_state", lambda *a, **kw: state) cli._show_billing("/billing") out = capsys.readouterr().out - assert "turned off for this org" in out + assert "Remote spending is off for this org." in out + assert ( + "A billing admin can turn it on from the portal's Hermes Agent page " + "to add funds here." + ) in out def test_billing_limit_screen_readonly(cli, monkeypatch, capsys): diff --git a/tests/hermes_cli/test_subscription_cli.py b/tests/hermes_cli/test_subscription_cli.py index 7a5b2babb59b..deb13648a881 100644 --- a/tests/hermes_cli/test_subscription_cli.py +++ b/tests/hermes_cli/test_subscription_cli.py @@ -1,7 +1,7 @@ """Tests for the /subscription CLI change flow (cli.py::_show_subscription). Parity with the TUI overlay: the classic CLI now previews + applies a plan change -in-terminal (picker → preview → confirm → apply), grants terminal billing inline on +in-terminal (picker → preview → confirm → apply), allows remote spending inline on insufficient_scope, and leads a scheduled downgrade/cancel with a prominent banner. Interactive screens are driven by mocking `_prompt_text_input_modal`. """ @@ -158,7 +158,7 @@ def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsy def _put(**kw): calls["n"] += 1 if calls["n"] == 1: - raise nb.BillingScopeRequired("terminal billing required") + raise nb.BillingScopeRequired("remote spending required") return {"message": "Scheduled."} monkeypatch.setattr(nb, "put_subscription_pending_change", _put) @@ -171,7 +171,7 @@ def test_insufficient_scope_triggers_stepup_then_replays(cli, monkeypatch, capsy # applied once (scope-denied), granted, replayed → applied again assert calls["n"] == 2 - assert "Terminal billing enabled" in out + assert "Remote Spending allowed" in out def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys): @@ -183,7 +183,7 @@ def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys): def _put(**kw): calls["n"] += 1 - raise nb.BillingScopeRequired("terminal billing required") + raise nb.BillingScopeRequired("remote spending required") monkeypatch.setattr(nb, "put_subscription_pending_change", _put) import hermes_cli.auth as auth @@ -194,7 +194,7 @@ def test_stepup_declined_grant_does_not_replay(cli, monkeypatch, capsys): out = capsys.readouterr().out assert calls["n"] == 1 # applied once, grant denied, no replay - assert "Couldn't enable terminal billing" in out + assert "Couldn't allow Remote Spending" in out def test_unknown_preview_effect_fails_safe(cli, monkeypatch, capsys): @@ -235,7 +235,10 @@ def test_bounded_stepup_does_not_loop_on_repeat_denial(cli, monkeypatch, capsys) out = capsys.readouterr().out assert calls["n"] == 2 # applied, granted, replayed once — no third attempt - assert "still isn't enabled" in out + assert ( + "Remote Spending still isn't active for this terminal — the authorization " + "didn't take. Retry, or make this change on the portal." + ) in out def test_upgrade_transport_failure_is_ambiguous_not_flat_failure(cli, monkeypatch, capsys): diff --git a/tui_gateway/server.py b/tui_gateway/server.py index 847479d46bd8..49b97fd8f3e8 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -8136,7 +8136,7 @@ def _(rid, params: dict) -> dict: # =========================================================================== -# Phase 2b terminal billing RPC methods +# Phase 2b Remote Spending RPC methods # =========================================================================== # # These return STRUCTURED success envelopes (result.ok / result.error) rather diff --git a/ui-tui/README.md b/ui-tui/README.md index fe5ab7c8db1d..5a2094a3184f 100644 --- a/ui-tui/README.md +++ b/ui-tui/README.md @@ -96,7 +96,7 @@ npm run test:watch - `types.ts` — `SlashCommand` interface and `SlashRunCtx` execution context (gateway rpc, transcript helpers, session refs, stale-guard) - `registry.ts` — assembles `SLASH_COMMANDS` from all command files in registration order (core → billing → credits → session → ops → setup → debug) and exposes `findSlashCommand(name)` for case-insensitive lookup - `commands/core.ts` — general TUI commands -- `commands/billing.ts` — `/billing`: manage Nous terminal billing — buy credits, auto-reload, limits +- `commands/billing.ts` — `/billing`: manage Nous remote spending — buy credits, auto-reload, limits - `commands/credits.ts` — `/credits` - `commands/session.ts` — session and agent commands - `commands/ops.ts` — operations commands @@ -231,7 +231,7 @@ The following commands are handled directly by the TUI client. Unrecognized comm `/status`, `/title`, `/fortune`, `/redraw`, `/terminal-setup` ### Billing (`billing.ts`) -`/billing` — manage Nous terminal billing — buy credits, auto-reload, limits +`/billing` — manage Nous remote spending — buy credits, auto-reload, limits ### Session (`session.ts`) `/model`, `/sessions` (aliases `/switch`, `/session`, `/resume`), @@ -366,7 +366,7 @@ ui-tui/ types.ts SlashCommand interface and SlashRunCtx execution context registry.ts SLASH_COMMANDS assembly and findSlashCommand lookup commands/ - billing.ts /billing — manage Nous terminal billing + billing.ts /billing — manage Nous remote spending core.ts general TUI commands credits.ts /credits debug.ts /heapdump, /mem diff --git a/ui-tui/scripts/billing-fixtures.tsx b/ui-tui/scripts/billing-fixtures.tsx index f55eb725db14..7f6edac01220 100644 --- a/ui-tui/scripts/billing-fixtures.tsx +++ b/ui-tui/scripts/billing-fixtures.tsx @@ -205,7 +205,7 @@ const FIXTURES: Record = { node: billEl(billState({ is_admin: false })) }, 'topup-disabled': { - desc: '/topup overview — terminal billing OFF for org', + desc: '/topup overview — remote spending OFF for org', node: billEl(billState({ cli_billing_enabled: false })) }, 'topup-buy': { diff --git a/ui-tui/src/__tests__/billingStepUp.test.tsx b/ui-tui/src/__tests__/billingStepUp.test.tsx index 8c4ee1542dfb..4c54c9949541 100644 --- a/ui-tui/src/__tests__/billingStepUp.test.tsx +++ b/ui-tui/src/__tests__/billingStepUp.test.tsx @@ -93,11 +93,11 @@ const overlay = (screen: BillingOverlayState['screen']): BillingOverlayState => state: billState() }) -describe('BillingOverlay — step-up screen (Enable terminal billing)', () => { +describe('BillingOverlay — step-up screen (Allow Remote Spending)', () => { it('renders the one-time-setup prompt with the held amount, never leaking the raw scope', () => { const out = render(overlay('stepup')) expect(out).toContain('One-time setup') - expect(out).toContain('Enable terminal billing') + expect(out).toContain('Allow Remote Spending') expect(out).toContain('$100') // resumes the held purchase expect(out).toContain('Not now') expect(out).not.toContain('billing:manage') @@ -112,8 +112,8 @@ describe('BillingOverlay — overview (reordered, dollars)', () => { expect(out).toContain('Auto-reload') expect(out).toContain('Manage on portal') expect(out.toLowerCase()).not.toContain('credits') // dollars only - // No standalone "Enable terminal billing" item — discovered at pay time. - expect(out).not.toContain('Enable terminal billing') + // No standalone "Allow Remote Spending" item — discovered at pay time. + expect(out).not.toContain('Allow Remote Spending') }) it('renders the two-bar dollar usage when a usage model is present', () => { diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index eaf22e847f68..0569f64164a9 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -353,11 +353,11 @@ describe('SubscriptionOverlay — overview actions', () => { }) describe('SubscriptionOverlay — step-up', () => { - it('prompts to enable terminal billing (never leaks the raw scope)', () => { + it('prompts to allow Remote Spending (never leaks the raw scope)', () => { const out = render(at('stepup', subscriber(), { stepUpRetry: { kind: 'preview', tierId: 'ultra' } })) - expect(out).toContain('Terminal billing') - expect(out).toContain('Enable terminal billing') + expect(out).toContain('Remote Spending') + expect(out).toContain('Allow Remote Spending') expect(out).not.toContain('billing:manage') }) }) diff --git a/ui-tui/src/__tests__/topupCommand.test.ts b/ui-tui/src/__tests__/topupCommand.test.ts index 636aba932fcd..809355582de1 100644 --- a/ui-tui/src/__tests__/topupCommand.test.ts +++ b/ui-tui/src/__tests__/topupCommand.test.ts @@ -311,8 +311,8 @@ describe('/billing slash command (overlay-driven)', () => { // ── CF-4: revoked-terminal UX (kill the "15-minute zombie button") ── it.each([ - ['admin', 'An admin turned off terminal billing for this terminal'], - ['self', 'You turned off terminal billing for this terminal'] + ['admin', 'An admin stopped remote spending for this terminal'], + ['self', 'You stopped remote spending for this terminal'] ])( 'ctx.charge remote_spending_revoked (%s) → clears the overlay (no zombie button) + actor copy', async (actor, copy) => { @@ -387,7 +387,7 @@ describe('/billing slash command (overlay-driven)', () => { await Promise.resolve() await Promise.resolve() const out = printed(sys) - expect(out).toContain('Terminal billing is off for this account') + expect(out).toContain('Remote spending is off for this account') // Account-wide switch is NOT a per-terminal revoke — overlay stays open. expect(getOverlayState().billing).toBeTruthy() }) diff --git a/ui-tui/src/app/createGatewayEventHandler.ts b/ui-tui/src/app/createGatewayEventHandler.ts index f1c87665f286..4906290ac4c0 100644 --- a/ui-tui/src/app/createGatewayEventHandler.ts +++ b/ui-tui/src/app/createGatewayEventHandler.ts @@ -551,7 +551,7 @@ export function createGatewayEventHandler(ctx: GatewayEventHandlerContext): (ev: return } - sys('💳 Open this link to grant terminal billing access:') + sys('💳 Open this link to allow Remote Spending:') sys(url) if (code) { diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index c29d722cf452..dddad408c8e1 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -127,7 +127,7 @@ export interface BillingOverlayCtx { */ charge: (amount: string, idempotencyKey?: string) => Promise /** - * Run the `billing.step_up` device flow (grant Remote Spending). Resolves + * Run the `billing.step_up` device flow (allow Remote Spending). Resolves * `true` when the grant lands. The browser opens via the gateway's * out-of-band `billing.step_up.verification` event — the overlay just awaits. */ @@ -176,15 +176,15 @@ export interface BillingOverlayState { // scheduled at date / no-op / blocked) + the apply action. // result — the outcome, including an SCA/decline upgrade handed off to the // portal. -// stepup — reached when a mutation returns insufficient_scope: grants the -// terminal-billing scope in place, then auto-replays the held action. +// stepup — reached when a mutation returns insufficient_scope: allows remote +// spending in place, then auto-replays the held action. export type SubscriptionScreen = 'confirm' | 'overview' | 'picker' | 'result' | 'stepup' -// The action held while the stepup screen grants terminal billing, replayed on -// grant: re-preview a tier, re-apply the confirmed pending change, or re-resume. +// The action held while the stepup screen allows remote spending, replayed after +// approval: re-preview a tier, re-apply the confirmed pending change, or re-resume. export type SubscriptionStepUpRetry = { kind: 'apply' } | { kind: 'preview'; tierId: string } | { kind: 'resume' } -/** Outcome of a terminal-billing step-up: granted, plus the typed denial (for copy). */ +/** Outcome of a remote-spending step-up: granted, plus the typed denial (for copy). */ export interface StepUpResult { granted: boolean error?: string @@ -215,7 +215,7 @@ export interface SubscriptionOverlayCtx { /** POST /upgrade: charge the card on the subscription + flip the plan now. */ upgrade: (tierId: string, idempotencyKey?: string) => Promise /** - * Run the `billing.step_up` device flow (grant terminal billing / "Remote + * Run the `billing.step_up` device flow (allow remote spending / "Remote * Spending"). Resolves `{granted}` plus the typed denial (`error`/`message`) so * the stepup screen shows the right recovery. The browser opens via the * gateway's out-of-band verification event — the stepup screen just awaits. diff --git a/ui-tui/src/app/slash/commands/topup.ts b/ui-tui/src/app/slash/commands/topup.ts index 77222e9e0812..b3ab23ccd388 100644 --- a/ui-tui/src/app/slash/commands/topup.ts +++ b/ui-tui/src/app/slash/commands/topup.ts @@ -37,9 +37,9 @@ const renderBillingError = ( switch (env.error) { case 'insufficient_scope': // Reached by non-charge mutations (e.g. auto-reload config) that need - // terminal billing enabled. The resumable step-up lives on the buy/charge + // Remote Spending allowed. The resumable step-up lives on the buy/charge // path; point the user there rather than leaking the raw scope name. - sys('This needs terminal billing enabled. Start a top-up to enable it, then retry.') + sys('This needs Remote Spending allowed. Start a top-up to allow it, then retry.') break case 'remote_spending_revoked': { @@ -49,8 +49,8 @@ const renderBillingError = ( const who = env.actor === 'admin' - ? 'An admin turned off terminal billing for this terminal.' - : 'You turned off terminal billing for this terminal.' + ? 'An admin stopped remote spending for this terminal.' + : 'You stopped remote spending for this terminal.' sys(`${who} Reconnect to restore — run /portal to re-authorize this terminal.`) @@ -67,9 +67,11 @@ const renderBillingError = ( case 'cli_billing_disabled': case 'remote_spending_disabled': - // Account-wide switch is OFF (dual-emitted error/code). An admin must flip - // it on the portal; this is NOT a per-terminal revoke. - sys('Terminal billing is off for this account — an admin must enable it on the portal.') + // Account-wide switch is OFF (dual-emitted error/code). A billing admin can + // turn it on from the portal's Hermes Agent page; this is NOT a per-terminal stop. + sys( + "Remote spending is off for this account — a billing admin can turn it on from the portal's Hermes Agent page." + ) break diff --git a/ui-tui/src/components/billingOverlay.tsx b/ui-tui/src/components/billingOverlay.tsx index 984915f79075..c5577a5c6cf1 100644 --- a/ui-tui/src/components/billingOverlay.tsx +++ b/ui-tui/src/components/billingOverlay.tsx @@ -83,7 +83,7 @@ interface ScreenProps { function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { // Full charge menu only for an admin with the org kill-switch on; otherwise it // collapses to Manage-on-portal / Close + a one-line note. NOTE: this is the - // ORG-level gate (cli_billing_enabled), NOT the per-terminal billing scope — + // ORG-level gate (cli_billing_enabled), NOT the per-terminal remote spending scope — // that's discovered reactively at pay time (a charge 403s insufficient_scope // and the confirm screen routes into the resumable step-up). We deliberately // do NOT preflight the scope here. @@ -92,7 +92,7 @@ function OverviewScreen({ ctx, onClose, onPatch, s, t }: ScreenProps) { const note = !s.is_admin ? 'Billing actions need someone with billing permissions (owner, admin, or finance admin).' : !s.cli_billing_enabled - ? 'Terminal billing is off for this org — manage it on the portal.' + ? "Remote spending is off for this org — a billing admin can turn it on from the portal's Hermes Agent page." : null // Always show the full billing menu for an admin/billing-on org — a missing @@ -495,14 +495,14 @@ function ConfirmScreen({ ) } -// ── Screen: Step-up (resumable "Enable terminal billing") ──────────── +// ── Screen: Step-up (resumable "Allow Remote Spending") ───────────── // Reached ONLY when a charge returns insufficient_scope — there is no preflight // or scope check anywhere; the buy path discovers it reactively. The modal stays // MOUNTED through the browser device-flow: // prompt (heads-up) → waiting (browser authorize) → granted (press Enter to // resume) → replay the held charge (pendingCharge.amount) → settle → close. // Never leaks the raw billing:manage scope — the user-facing concept is -// "terminal billing". +// "Remote Spending". function StepUpScreen({ amount, @@ -526,12 +526,12 @@ function StepUpScreen({ } setPhase('waiting') - ctx.sys('Opening your browser to enable terminal billing…') + ctx.sys('Opening your browser to allow Remote Spending…') void ctx.requestRemoteSpending().then(granted => { if (!granted) { ctx.sys( - "! Couldn't enable terminal billing — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged." + "! Couldn't allow Remote Spending — someone with billing permissions (owner, admin, or finance admin) has to approve it. Your card was not charged." ) onClose() @@ -550,12 +550,12 @@ function StepUpScreen({ } setPhase('resuming') - ctx.sys('✓ Terminal billing enabled — resuming your purchase.') + ctx.sys('✓ Remote Spending allowed — resuming your purchase.') void ctx.charge(amount, idempotencyKey).then(outcome => { // If the replay STILL can't spend (grant raced/expired or downscoped), // say so — don't close on a reassuring line with no charge made. if (outcome === 'needs_remote_spending') { - ctx.sys('! Terminal billing still needs approval — run /topup to try again. Your card was not charged.') + ctx.sys('! Remote Spending still needs approval — run /topup to try again. Your card was not charged.') } onClose() @@ -563,7 +563,7 @@ function StepUpScreen({ } const decline = () => { - ctx.sys('No charge made. Run /topup when you want to enable terminal billing.') + ctx.sys('No charge made. Run /topup when you want to allow Remote Spending.') onClose() } @@ -622,7 +622,7 @@ function StepUpScreen({ return ( - Enable terminal billing + Allow Remote Spending Waiting for your browser… Approve in the page that just opened. @@ -637,7 +637,7 @@ function StepUpScreen({ return ( - Terminal billing enabled + Remote Spending allowed Your ${amount} top-up is ready to finish. @@ -652,7 +652,7 @@ function StepUpScreen({ return ( - Enable terminal billing + Allow Remote Spending Resuming your ${amount} top-up… @@ -667,12 +667,12 @@ function StepUpScreen({ One-time setup - To charge this terminal, enable terminal billing once. + To charge from this terminal, allow Remote Spending once. It opens your browser to authorize, then your ${amount} top-up picks up right here. - + {footer('↑/↓ select · Enter confirm · Y/N quick · Esc cancel', t)} diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 6de7ceeaa6f4..3a75ddab21d4 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -29,7 +29,7 @@ interface SubscriptionOverlayProps { /** * The /subscription modal — an in-terminal plan-change flow (V3). A small state * machine: overview → picker → confirm → result, with a stepup screen spliced in - * when a mutation needs terminal billing. Downgrades / cancellations / resume are + * when a mutation needs remote spending. Downgrades / cancellations / resume are * chargeless; an upgrade charges the card on the subscription, and an SCA/decline * is handed off to the portal. Starting a NEW subscription still deep-links (needs * a fresh card). All RPCs live in subscription.ts, reached via `overlay.ctx`. @@ -162,7 +162,7 @@ function upgradeResult(r: null | SubscriptionUpgradeResponse, pendingTierId?: nu return errorResult(r) } -/** Map a failed terminal-billing step-up to the right recovery copy (typed). */ +/** Map a failed remote-spending step-up to the right recovery copy (typed). */ function stepUpDenialResult(res: { error?: string; message?: string }): SubscriptionResult { if (res.error === 'session_revoked') { return { message: 'Your session expired — run /portal to log in again, then retry the change.', ok: false } @@ -170,8 +170,7 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip if (res.error === 'remote_spending_revoked') { return { - message: - res.message || 'Terminal spending was turned off for this session — reconnect from the portal, then retry.', + message: res.message || 'Remote spending was stopped for this terminal — reconnect from the portal, then retry.', ok: false } } @@ -183,7 +182,7 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip return { message: res.message || - 'Terminal billing was not enabled — someone with billing permissions (owner, admin, or finance admin) must allow it for this org. You can also make this change on the portal.', + 'Remote Spending was not allowed — someone with billing permissions (owner, admin, or finance admin) must approve it. You can also make this change on the portal.', ok: false } } @@ -196,7 +195,8 @@ function stepUpDenialResult(res: { error?: string; message?: string }): Subscrip // Post-grant replays pass allowStepUp=false and surface this instead (mirrors the // CLI's allow_stepup=False cap). const scopeStillDeniedResult: SubscriptionResult = { - message: 'Terminal billing still isn’t enabled for this org — enable it on the portal, then retry.', + message: + 'Remote Spending still isn’t active for this terminal — the authorization didn’t take. Retry, or make this change on the portal.', ok: false } @@ -818,7 +818,7 @@ function ResultScreen({ onClose, overlay, t }: Omit) { ) } -// ── Screen: Step-up (grant terminal billing inline, then replay) ────── +// ── Screen: Step-up (allow remote spending inline, then replay) ─────── function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { const { ctx } = overlay @@ -908,7 +908,7 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { ] : phase === 'prompt' ? [ - { color: t.color.ok, label: 'Enable terminal billing', run: enable }, + { color: t.color.ok, label: 'Allow Remote Spending', run: enable }, { label: 'Cancel', run: back } ] : [] @@ -918,12 +918,12 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { return ( - Terminal billing + Remote Spending {phase === 'prompt' && ( <> - Changing your plan needs terminal billing enabled for this org. Enable it here, then continue. + Changing your plan needs Remote Spending allowed for this terminal. Allow it here, then continue. Someone with billing permissions (owner, admin, or finance admin) approves it once in the browser. @@ -935,7 +935,7 @@ function StepUpScreen({ onPatch, overlay, t }: ScreenProps) { Opening your browser to approve… finish there, then come back — nothing is charged until you continue. )} - {phase === 'granted' && Terminal billing enabled. Continue to finish your change.} + {phase === 'granted' && Remote Spending allowed. Continue to finish your change.} {phase === 'resuming' && Applying your change…} {rows.map((row, i) => ( diff --git a/ui-tui/src/gatewayTypes.ts b/ui-tui/src/gatewayTypes.ts index 7fa2c34b37ae..49d1a6e8b00c 100644 --- a/ui-tui/src/gatewayTypes.ts +++ b/ui-tui/src/gatewayTypes.ts @@ -45,7 +45,7 @@ export interface SlashExecResponse { warning?: string } -// ── Terminal billing (Phase 2b) ────────────────────────────────────── +// ── Remote Spending (Phase 2b) ─────────────────────────────────────── // Wire shapes now live in @hermes/shared for reuse by TypeScript clients. export type { diff --git a/website/docs/reference/slash-commands.md b/website/docs/reference/slash-commands.md index 8b5786de5a55..0ca2b811fde6 100644 --- a/website/docs/reference/slash-commands.md +++ b/website/docs/reference/slash-commands.md @@ -113,7 +113,7 @@ Type `/` in the CLI to open the autocomplete menu. Built-in commands are case-in | `/version` | Show Hermes Agent version, build, and environment info. | | `/usage` | Show token usage, cost breakdown, session duration, and — when available from the active provider — an **Account limits** section with remaining quota / credits / plan usage pulled live from the provider's API. | | `/credits` | Show your Nous credit balance and a top-up handoff link. | -| `/billing` | CLI terminal-billing flow for Nous — view balance, buy credits, and manage auto-reload / monthly limits. | +| `/billing` | CLI Remote Spending flow for Nous — view balance, buy credits, and manage auto-reload / monthly limits. | | `/insights` | Show usage insights and analytics (last 30 days) | | `/platforms` (alias: `/gateway`) | Show gateway/messaging platform status (CLI-only summary view). | | `/paste` | Attach a clipboard image | From 94c944363c10405e3544b0aeeaac1d00f0b85a54 Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Tue, 21 Jul 2026 12:20:59 +0530 Subject: [PATCH 70/72] feat(tui): show the plan catalog in /subscription on Free (#68357) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(tui): show the plan catalog in /subscription on Free The server returns the tier list even with no subscription, but the overlay hid the picker behind can_change_plan && !isFree, so a Free account got only "Start a subscription" with no idea what the plans cost. Now: - Overview on Free offers "Choose a plan" whenever the catalog has enabled paid tiers. - The picker on Free lists each plan as name · price · monthly credits (no upgrade/downgrade hints — there is nothing to move from), and picking one opens the portal, where starting a subscription actually happens (card capture + checkout live there; the upgrade RPC requires an existing subscription). - Paid-plan behavior (preview → confirm → apply) is unchanged. * refactor(tui): compute the picker row suffix once Review feedback: the isFree fork duplicated the label template and run handler; only the suffix differs. * fix(tui): arm the busy guard before the Free portal handoff Adversarial review: the Free branch returned before setting busyRef, so a double-Enter could open the portal twice; and the picker narrated a handoff that openManageLink already narrates (duplicate on success, contradictory on failure). Guard first, let the helper do the talking. * fix(tui): monthly credits are dollars — label them as such The Free picker showed "1000 credits/mo" for what is $1,000 of monthly credit — render "$1,000 credits/mo" (grouped, dollar-signed). * feat(tui): render the Free-plan catalog inline in the /subscription overview Sid ruling: the upsell belongs where the user already is — no intermediate "Choose a plan" hop. On Free the overview lists each paid plan (name · $/mo · $credits/mo) as a pickable row; picking opens the portal (openManageLink narrates). The generic "Start a subscription" row survives only when the catalog is empty. The picker reverts to its original change-only form (Free never reaches it). * feat(desktop): tier catalog chips on the Subscription row Desktop parity with the TUI inline catalog (Sid ruling): accounts that can act see the plans where they already are — Free gets the upsell list (every chip opens the portal), a subscriber sees all tiers with the current one marked inert. Members and team contexts see no chips. Chips learn an optional url (portal handoff) in the shared row model. * chore(tui): fixture harness mirrors the live tier catalog The dev screenshot fixtures showed invented plans ($50 Super / $99 Ultra, "1,000 credits"); align with the real catalog ($20/$100/$200 with $22/$110/$220 monthly credits) so fixture renders cannot be mistaken for product truth. The overlay itself always reads tiers from the subscription API. * chore: trim narration comments --- .../src/app/settings/billing/index.tsx | 9 +- .../billing/use-billing-state.test.ts | 94 +++++++++++++++++++ .../app/settings/billing/use-billing-state.ts | 40 +++++++- ui-tui/scripts/billing-fixtures.tsx | 8 +- .../__tests__/subscriptionOverlay.test.tsx | 41 ++++++++ ui-tui/src/components/subscriptionOverlay.tsx | 31 +++++- 6 files changed, 217 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/app/settings/billing/index.tsx b/apps/desktop/src/app/settings/billing/index.tsx index 94ffda3b7c64..a56404757545 100644 --- a/apps/desktop/src/app/settings/billing/index.tsx +++ b/apps/desktop/src/app/settings/billing/index.tsx @@ -94,7 +94,14 @@ function RowValue({ onAction, row }: { onAction?: () => void; row: BillingAccoun {row.pill && {row.pill.label}} {row.secondaryPill && {row.secondaryPill}} {row.chips?.map(chip => ( - ))} diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index f87d9f4fe108..8a71b51f4e85 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -184,6 +184,100 @@ describe('deriveBillingView', () => { }) }) + it('free with catalog: tier chips render inline and open the portal', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + context: 'personal', + current: null, + tiers: [ + { + dollars_per_month_display: '$0', + is_current: false, + is_enabled: true, + monthly_credits: '0', + name: 'Free', + tier_id: 'free', + tier_order: 0 + }, + { + dollars_per_month_display: '$40', + is_current: false, + is_enabled: true, + monthly_credits: '3000', + name: 'Ultra', + tier_id: 'ultra', + tier_order: 2 + }, + { + dollars_per_month_display: '$20', + is_current: false, + is_enabled: true, + monthly_credits: '1000', + name: 'Plus', + tier_id: 'plus', + tier_order: 1 + } + ] + }) + ) + const subscription = view.accountRows.find(row => row.id === 'subscription') + + expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.') + expect(subscription?.chips).toEqual([ + { disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: subscription?.action?.url }, + { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url } + ]) + }) + + it('subscriber who can change plans: current tier marked inert, others open the portal', () => { + const view = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ + ...todaySubscriptionState, + context: 'personal', + tiers: [ + { + dollars_per_month_display: '$20', + is_current: true, + is_enabled: true, + monthly_credits: '1000', + name: 'Plus', + tier_id: 'plus', + tier_order: 1 + }, + { + dollars_per_month_display: '$40', + is_current: false, + is_enabled: true, + monthly_credits: '3000', + name: 'Ultra', + tier_id: 'ultra', + tier_order: 2 + } + ] + }) + ) + const subscription = view.accountRows.find(row => row.id === 'subscription') + + expect(subscription?.chips).toEqual([ + { disabled: true, label: '✓ Plus · $20/mo · $1,000 credits/mo' }, + { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url } + ]) + }) + + it('members and team contexts get no tier chips', () => { + const member = deriveBillingView( + okBilling(todayBillingState), + okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' }) + ) + const team = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState)) + + expect(member.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined() + expect(team.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined() + }) + it('clamps overdrawn subscription credits to $0 and names the overage', () => { const view = deriveBillingView( okBilling(todayBillingState), diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 870af163670f..6fda6cc730f4 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -41,6 +41,8 @@ export interface BillingRowActionView { export interface BillingChipView { disabled: boolean label: string + /** When set, clicking the chip opens this URL externally. */ + url?: string } export interface BillingAccountRowView { @@ -272,6 +274,37 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView } } +/** + * Tier catalog as chips for accounts that can change plans; the current plan is + * inert, every other opens the portal where the change/start happens. + */ +function subscriptionTierChips( + subscription: null | SubscriptionStateResponse, + manageUrl: string +): BillingChipView[] | undefined { + // Teams have no personal subscription to sell into. + if (!subscription?.can_change_plan || subscription.context === 'team') { + return undefined + } + + const tiers = (subscription.tiers ?? []) + .filter(tier => tier.is_enabled && tier.tier_order > 0) + .sort((a, b) => a.tier_order - b.tier_order) + + if (tiers.length === 0) { + return undefined + } + + return tiers.map(tier => { + // Monthly credits are dollars; NAS sends a bare decimal string. + const credits = Number((tier.monthly_credits ?? '').replace(/,/g, '')) + const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : '' + const label = `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}` + + return tier.is_current ? { disabled: true, label: `✓ ${label}` } : { disabled: false, label, url: manageUrl } + }) +} + function subscriptionRow( billing: BillingStateResponse, subscription: null | SubscriptionStateResponse, @@ -283,13 +316,18 @@ function subscriptionRow( const value = current?.tier_name ?? fallbackPlan const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at) const unavailable = subscriptionResult && !subscriptionResult.ok + const chips = subscriptionTierChips(subscription, manageUrl) return { action: { label: 'Adjust plan ↗', url: manageUrl }, caption: unavailable ? 'Subscription details are unavailable; opening the portal is still available.' : `Renews ${renewal}`, - description: 'Review your plan and change it from the billing portal.', + chips, + description: + !current && chips + ? 'Paid models need a subscription — pick a plan to start it on the portal.' + : 'Review your plan and change it from the billing portal.', id: 'subscription', secondaryPill: 'opens portal', title: 'Subscription', diff --git a/ui-tui/scripts/billing-fixtures.tsx b/ui-tui/scripts/billing-fixtures.tsx index 7f6edac01220..53bcbeb9a8dc 100644 --- a/ui-tui/scripts/billing-fixtures.tsx +++ b/ui-tui/scripts/billing-fixtures.tsx @@ -42,11 +42,13 @@ const tier = (o: Partial = {}): SubscriptionTierOption = ...o }) +// Mirrors the live portal catalog so fixtures don't drift; the real overlay +// reads tiers from GET /api/billing/subscription, never from here. const TIERS = { free: tier({ tier_id: 'free', name: 'Free', tier_order: 0, dollars_per_month_display: '$0', monthly_credits: '0' }), - plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '1,000' }), - super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$50', monthly_credits: '3,000' }), - ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$99', monthly_credits: '7,000' }) + plus: tier({ tier_id: 'plus', name: 'Plus', tier_order: 1, dollars_per_month_display: '$20', monthly_credits: '22' }), + super: tier({ tier_id: 'super', name: 'Super', tier_order: 2, dollars_per_month_display: '$100', monthly_credits: '110' }), + ultra: tier({ tier_id: 'ultra', name: 'Ultra', tier_order: 3, dollars_per_month_display: '$200', monthly_credits: '220' }) } const tierList = (currentId?: string): SubscriptionTierOption[] => diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index 0569f64164a9..4a5a5ed21a16 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -149,6 +149,39 @@ describe('SubscriptionOverlay — overview', () => { expect(out.toLowerCase()).not.toContain('credits') }) + it('free with catalog: plans render inline; the generic portal row disappears', () => { + const out = render(overlay(freeWithCatalog())) + + expect(out).toContain('Plus · $20/mo · $1,000 credits/mo') + expect(out).toContain('Ultra · $40/mo · $3,000 credits/mo') + expect(out).not.toContain('upgrade') // a start, not a move + expect(out).not.toContain('$0/mo') // free tier is not an option + expect(out).not.toContain('Choose a plan') + expect(out).not.toContain('Start a subscription') + }) + + it('free with catalog: picking a plan opens the portal once, even on double-Enter', async () => { + const openManageLink = vi.fn(() => Promise.resolve(true)) + const preview = vi.fn(() => Promise.resolve(null)) + const sys = vi.fn() + + const mounted = mount({ + ctx: { ...ctx, openManageLink, preview, sys } as SubscriptionOverlayState['ctx'], + screen: 'overview', + state: freeWithCatalog() + }) + + inputHarness.handler?.('', { return: true }) // first row = Plus + inputHarness.handler?.('', { return: true }) + await vi.waitFor(() => expect(openManageLink).toHaveBeenCalled()) + mounted.cleanup() + + expect(openManageLink).toHaveBeenCalledTimes(1) + expect(preview).not.toHaveBeenCalled() + // openManageLink narrates the handoff itself. + expect(sys).not.toHaveBeenCalled() + }) + it('subscriber: status line + plan bar + top-up bar, no "credits"', () => { const out = render( overlay( @@ -318,6 +351,14 @@ const at = ( extra: Partial = {} ): SubscriptionOverlayState => ({ ctx, screen, state: s, ...extra }) +// Free account (no current sub) where NAS still returns the tier catalog. +const freeWithCatalog = (): SubscriptionStateResponse => + state({ + current: null, + tiers: TIERS.map(tier => ({ ...tier, is_current: false })), + usage: { available: true, plan_name: null, status: 'free' } + }) + describe('SubscriptionOverlay — overview actions', () => { it('admin subscriber: offers Change plan + Cancel subscription', () => { const out = render(overlay(subscriber())) diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 3a75ddab21d4..5e0ae4af4887 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -374,6 +374,11 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { // Admin/owner on a personal paid plan can change it in-terminal; otherwise the // portal enforces who can act (members) / starting a new sub needs a card. const canChange = s.can_change_plan && !isFree + // On Free the catalog renders inline; picking a plan hands off to the portal, + // where starting a subscription needs card capture + checkout. + const freePlans = isFree + ? s.tiers.filter(tier => tier.is_enabled && tier.tier_order > 0).sort((a, b) => a.tier_order - b.tier_order) + : [] // Guard the async resume so a double-press cannot fire two DELETEs mid-await. const busyRef = useRef(false) @@ -422,7 +427,31 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { } } - rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage }) + for (const tier of freePlans) { + // NAS sends a bare decimal string; tolerate pre-grouped ("1,000") too. + const credits = Number((tier.monthly_credits ?? '').replace(/,/g, '')) + const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : '' + + rows.push({ + label: `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}`, + run: () => { + if (busyRef.current) { + return + } + + busyRef.current = true + void ctx.openManageLink() + onClose() + } + }) + } + + // The inline plan rows are the subscribe path; only a catalog-less free state + // still needs the generic portal row. + if (!isFree || freePlans.length === 0) { + rows.push({ label: isFree ? 'Start a subscription' : 'Manage on portal', run: doManage }) + } + rows.push({ label: 'Close', run: onClose }) const sel = useMenu(rows, onClose) From 0155c0937441f2edbda04f50e55669d17e8740aa Mon Sep 17 00:00:00 2001 From: "hermes-seaeye[bot]" <307254004+hermes-seaeye[bot]@users.noreply.github.com> Date: Tue, 21 Jul 2026 06:57:41 +0000 Subject: [PATCH 71/72] fmt(js): `npm run fix` on merge (#68462) Co-authored-by: github-actions[bot] --- .../desktop/src/app/settings/billing/use-billing-state.test.ts | 3 +++ ui-tui/src/components/subscriptionOverlay.tsx | 1 + 2 files changed, 4 insertions(+) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index 8a71b51f4e85..90569e166da9 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -222,6 +222,7 @@ describe('deriveBillingView', () => { ] }) ) + const subscription = view.accountRows.find(row => row.id === 'subscription') expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.') @@ -259,6 +260,7 @@ describe('deriveBillingView', () => { ] }) ) + const subscription = view.accountRows.find(row => row.id === 'subscription') expect(subscription?.chips).toEqual([ @@ -272,6 +274,7 @@ describe('deriveBillingView', () => { okBilling(todayBillingState), okSubscription({ ...todaySubscriptionState, can_change_plan: false, context: 'personal' }) ) + const team = deriveBillingView(okBilling(todayBillingState), okSubscription(todaySubscriptionState)) expect(member.accountRows.find(row => row.id === 'subscription')?.chips).toBeUndefined() diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 5e0ae4af4887..60c2b44dc114 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -374,6 +374,7 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { // Admin/owner on a personal paid plan can change it in-terminal; otherwise the // portal enforces who can act (members) / starting a new sub needs a card. const canChange = s.can_change_plan && !isFree + // On Free the catalog renders inline; picking a plan hands off to the portal, // where starting a subscription needs card capture + checkout. const freePlans = isFree From f4df260f26c93f15694698869f3ea8e965eea301 Mon Sep 17 00:00:00 2001 From: Ben Barclay Date: Tue, 21 Jul 2026 17:04:34 +1000 Subject: [PATCH 72/72] fix(relay): attach metadata.user_id on guild replies for egress fallback (#68320) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The relay adapter re-attaches an egress discriminator on outbound replies so the connector can resolve the owning tenant. It captured scope_id for scoped (guild) messages and user_id for DMs, but as MUTUALLY EXCLUSIVE: a scoped inbound hit an early return, so the author's user_id was never recorded, and _with_scope only attached user_id when there was no scope_id. Guild replies therefore went out with scope_id only. That's fine while the guild has a provision-time route row. But a MANAGED Discord agent joins guilds dynamically (the shared bot is added to / removed from servers at runtime), and GATEWAY_RELAY_ROUTE_KEYS — the only thing that writes guild route rows — is a self-hosted, static field never stamped for managed agents. So their guild has no route row, the connector's guild-route lookup misses, and with no user_id on the frame there's nothing to fall back to → every guild reply is declined "discord egress declined: target not routed to an onboarded tenant" even though INBOUND resolved the same guild fine (via the author-first SharedSocketRouter.targets() fallback). Fix: capture the authentic author user_id for EVERY inbound (DM and scoped alike) and re-attach it on the outbound reply alongside scope_id. The connector consults it only on a route/scope miss, so carrying both never overrides routing-table resolution. This is the gateway half of the paired gateway-gateway change (makeDiscordTenantOf guild-route-miss author-binding fallback); together they make guild replies resolve the same observed-author way inbound already does. Tests (tests/gateway/relay/test_relay_adapter.py): a guild reply now carries both scope_id AND user_id; a scoped inbound with no author still yields scope_id only (never invents one). Verified fail-without / pass-with. --- gateway/relay/adapter.py | 79 ++++++++++++++--------- tests/gateway/relay/test_relay_adapter.py | 49 ++++++++++++-- 2 files changed, 93 insertions(+), 35 deletions(-) diff --git a/gateway/relay/adapter.py b/gateway/relay/adapter.py index 34cc51522a0b..cf0646d1b239 100644 --- a/gateway/relay/adapter.py +++ b/gateway/relay/adapter.py @@ -234,16 +234,23 @@ class RelayAdapter(BasePlatformAdapter): outbound (the agent's reply) can re-assert it for the connector's egress tenant resolution. Never raises — scope tracking must not break inbound. - Two cases, matching the connector's two tenant-resolution paths: - - SCOPED message: remember chat_id -> scope_id. The connector resolves - the tenant from metadata.scope_id (routing table). - - DM (no scope): remember chat_id -> the authentic author user_id. - A DM carries no scope discriminator, so the connector instead resolves - the tenant from the recipient's author binding (resolveByUser); it - needs the user_id on the OUTBOUND action to do that. Without this, a - DM reply has no resolvable discriminator and the connector's egress - guard declines it as "target not routed to an onboarded tenant". - See gateway-gateway routedEgressGuard.ts / the tenant resolvers. + Two discriminators, captured independently (a scoped message has BOTH): + - scope_id: for a scoped (guild/channel) message. The connector's + primary path resolves the tenant from metadata.scope_id (routing + table). + - user_id: the authentic author id, captured for EVERY message (DM + and scoped alike). The connector resolves the tenant from the + recipient's author binding (resolveByUser) when a route lookup + misses. This is the sole discriminator for a DM (no scope), AND the + author-first FALLBACK for a scoped reply whose guild has no route + row — a managed agent joins guilds dynamically, so a provision-time + guild route is not guaranteed. Re-attaching user_id on scoped + replies too lets the connector's guild-route-miss fallback resolve + the tenant the same way inbound already does (SharedSocketRouter + targets()). Without a resolvable discriminator the connector's + egress guard declines the reply as 'target not routed to an + onboarded tenant'. See gateway-gateway routedEgressGuard.ts / + discordTenant.ts (makeDiscordTenantOf). """ try: src = getattr(event, "source", None) @@ -263,28 +270,36 @@ class RelayAdapter(BasePlatformAdapter): platform_value = getattr(platform, "value", platform) if platform_value and platform_value != "relay": self._platform_by_chat[str(chat)] = str(platform_value) - scope = getattr(src, "scope_id", None) - if scope: - self._scope_by_chat[str(chat)] = str(scope) - return - # DM: no scope. Remember the authentic author id for outbound - # author-binding resolution (the user we're replying to in this DM). + # Author id for outbound author-binding resolution. Captured for BOTH + # DM and scoped messages: it's the sole discriminator for a DM and + # the guild-route-miss fallback for a scoped reply. (Formerly captured + # for DMs only, which left managed-agent guild replies with no + # resolvable tenant when the guild had no route row.) user_id = getattr(src, "user_id", None) if user_id: self._dm_user_by_chat[str(chat)] = str(user_id) + scope = getattr(src, "scope_id", None) + if scope: + self._scope_by_chat[str(chat)] = str(scope) except Exception: # noqa: BLE001 - scope tracking must never break inbound pass def _with_scope(self, chat_id: str, metadata: Optional[Dict[str, Any]]) -> Dict[str, Any]: - """Ensure the outbound metadata carries the discriminator the connector's - egress guard needs to resolve the owning tenant. Two cases: + """Ensure the outbound metadata carries the discriminator(s) the connector's + egress guard needs to resolve the owning tenant. - - SCOPED reply: re-attach metadata.scope_id (routing-table resolution). - - DM reply: there is no scope, so re-attach metadata.user_id — the - authentic author id we saw inbound — which the connector resolves to - the tenant via the recipient's author binding (resolveByUser). Without - one of these, egress is declined as 'target not routed to an onboarded - tenant'. See gateway-gateway routedEgressGuard.ts / the tenant resolvers. + - scope_id: re-attached for a scoped reply (guild/channel) → + routing-table resolution (the primary path). + - user_id: the authentic author id we saw inbound, re-attached for + EVERY reply we know it for. It is the sole discriminator for a DM + (no scope), AND the author-first FALLBACK the connector uses when a + scoped reply's guild has no route row (a managed agent joins guilds + dynamically — the guild route may not be provisioned). Carrying both + on a scoped reply is harmless: the connector tries scope_id first and + only falls back to user_id on a route miss. Without a resolvable + discriminator egress is declined as 'target not routed to an + onboarded tenant'. See gateway-gateway routedEgressGuard.ts / + discordTenant.ts. No-op when the relevant value is already present or unknown for this chat. """ @@ -293,13 +308,15 @@ class RelayAdapter(BasePlatformAdapter): scope = self._scope_by_chat.get(str(chat_id)) if scope: meta["scope_id"] = scope - # DM author-binding discriminator. Only meaningful when there's no scope - # (a scoped reply resolves by scope_id); harmless to carry otherwise, but - # we only set it when this chat is a known DM and the field is absent. - if not meta.get("scope_id") and not meta.get("user_id"): - dm_user = self._dm_user_by_chat.get(str(chat_id)) - if dm_user: - meta["user_id"] = dm_user + # Author-binding discriminator. Attached whenever we know the author for + # this chat and it isn't already set — for DMs (the sole discriminator) + # AND scoped replies (the connector's guild-route-miss fallback). It is + # only consulted by the connector when the scope/route lookup misses, so + # carrying it alongside scope_id never overrides routing-table resolution. + if not meta.get("user_id"): + author = self._dm_user_by_chat.get(str(chat_id)) + if author: + meta["user_id"] = author return meta def _platform_is_fronted(self, platform: str) -> bool: diff --git a/tests/gateway/relay/test_relay_adapter.py b/tests/gateway/relay/test_relay_adapter.py index 91d38edd4777..dba0d0edfa23 100644 --- a/tests/gateway/relay/test_relay_adapter.py +++ b/tests/gateway/relay/test_relay_adapter.py @@ -157,6 +157,27 @@ def _make_dm_event(chat_id="dm-1", user_id="user-42"): return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) +def _make_scoped_event_with_author( + chat_id="chan-1", scope_id="scope-9", user_id="user-42" +): + """An inbound scoped (guild/channel) message that ALSO carries the authentic + author user_id — the real shape of a Discord guild message (it has both a + guild scope_id and an author). Used to prove the adapter re-attaches BOTH + discriminators so the connector can fall back author-first when the guild + has no route row (managed agents join guilds dynamically).""" + from gateway.platforms.base import MessageEvent, MessageType + from gateway.session import SessionSource + + src = SessionSource( + platform=Platform.RELAY, + chat_id=chat_id, + chat_type="channel", + scope_id=scope_id, + user_id=user_id, + ) + return MessageEvent(text="hi", source=src, message_type=MessageType.TEXT) + + @pytest.mark.asyncio async def test_send_reattaches_scope_id_from_inbound_scope(): """The connector's egress guard resolves the owning tenant from @@ -234,10 +255,30 @@ async def test_send_preserves_explicit_user_id(): @pytest.mark.asyncio -async def test_scoped_reply_does_not_carry_user_id(): - """A scoped reply resolves by scope_id and must NOT carry a DM user_id even if - the same chat_id was somehow seen — scope capture wins and user_id stays out - (scope_id is the discriminator; user_id is the DM-only fallback).""" +async def test_scoped_reply_reattaches_both_scope_id_and_user_id(): + """A scoped (guild) reply now re-attaches BOTH scope_id AND the authentic + author user_id. scope_id is the connector's primary discriminator; user_id + is the author-first FALLBACK the connector uses when the guild has no route + row (a managed agent joins guilds dynamically, so a provision-time guild + route is not guaranteed). Regression for live 'discord egress declined: + target not routed to an onboarded tenant' on GUILD replies (paired with + gateway-gateway makeDiscordTenantOf guild-route-miss fallback).""" + t = _CaptureTransport() + a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t) + a._capture_scope( + _make_scoped_event_with_author( + chat_id="chan-1", scope_id="scope-9", user_id="user-42" + ) + ) + await a.send("chan-1", "hi") + assert t.sent["metadata"].get("scope_id") == "scope-9" + assert t.sent["metadata"].get("user_id") == "user-42" + + +@pytest.mark.asyncio +async def test_scoped_reply_without_inbound_author_carries_scope_only(): + """A scoped inbound with no author id yields scope_id only — the adapter + never invents a user_id it didn't observe.""" t = _CaptureTransport() a = RelayAdapter(PlatformConfig(), make_desc(platform="discord"), transport=t) a._capture_scope(_make_event(chat_id="chan-1", scope_id="scope-9"))