Compare commits

..
Author SHA1 Message Date
Ben 747aff9896 Merge remote-tracking branch 'origin/main' into feat/desktop-worktree-sessions
# Conflicts:
#	apps/desktop/src/app/chat/sidebar/index.tsx
#	apps/desktop/src/app/desktop-controller.tsx
#	apps/desktop/src/app/session/hooks/use-session-actions.ts
#	tui_gateway/server.py
2026-06-10 15:37:19 +10:00
Ben 89d26bc430 feat(desktop): new session in a git worktree from the sidebar
Adds a per-workspace git-fork icon beside the existing "+" in the desktop
sidebar's workspace-group header. The "+" keeps current behaviour (new session
in the workspace cwd); the fork icon creates the session inside a fresh git
worktree of that repo — mirroring `hermes --worktree --tui`. The fork icon only
renders for workspaces that are real git repos (memoized per-path probe via a
new `git.is_repo` gateway method).

Backend:
- hermes_state.py: add worktree_path/worktree_branch/worktree_repo_root columns
  to the sessions table (auto-reconciled, no manual migration) + a
  set_session_worktree() setter.
- tui_gateway/server.py: `git.is_repo` RPC; _create_session_worktree() reuses
  cli._setup_worktree, repoints session cwd into the worktree, and persists the
  DB row EAGERLY (worktree sessions are explicit, so unlike blank drafts the row
  is created up front) stamped with the worktree mapping. Wired into
  session.create behind a `worktree` param; returns worktree info in the response.
- hermes_cli/web_server.py: on archive (PATCH /api/sessions/:id), remove the
  session's worktree via cli._cleanup_worktree, which keeps the existing
  unpushed-commits guard — a branch with commits not on any remote is preserved,
  not destroyed; the response reports worktree_preserved.

Frontend (apps/desktop):
- use-workspace-git.ts: memoized per-path git.is_repo probe (module-level
  nanostore cache, one probe per distinct path).
- sidebar: isGitRepo flag on workspace groups; fork button (Codicon
  `repo-forked`, MIT/CC-BY) gated on isGitRepo; onNewSessionWorktree threaded
  through, respecting the all-profiles-view gating.
-  one-shot flag consumed by the session-create path to add
  worktree:true; cleared on plain new-chat drafts.

Lifecycle: worktrees persist indefinitely and are reclaimed only on archive
(guarded). Tests cover the DB setter, git.is_repo (incl. fresh repo with no
commits), worktree create + eager-row persistence, and archive cleanup for both
the clean-removal and unpushed-preserve cases, against a real temp git repo.
2026-06-05 12:09:42 +10:00
128 changed files with 1406 additions and 6165 deletions
-42
View File
@@ -63,45 +63,3 @@ data/
# Compose/profile runtime state (bind-mounted; avoid ownership/secret issues)
hermes-config/
runtime/
# ---------- Not needed inside the Docker image ----------
# Desktop app source (Tauri/Electron); never installed in the container
apps/
# Test suite — not shipped in production images
tests/
# Documentation site (Docusaurus) and supplementary docs
website/
docs/
# Assets only used by the GitHub README
assets/
infographic/
# Plugin-level docs (hermes-achievements ships docs/ but the runtime doesn't read them)
plugins/hermes-achievements/docs/
# Nix / Homebrew / AUR packaging metadata — irrelevant to Docker
nix/
flake.nix
flake.lock
packaging/
# Design and planning documents
plans/
.plans/
# ACP registry manifest (icon + agent.json) — not consumed at runtime
acp_registry/
# Repo-level dotfiles that are git-only or dev-tooling config
.env.example
.envrc
.gitattributes
.hadolint.yaml
.mailmap
# Top-level LICENSE (not matched by *.md); not needed inside the container
LICENSE
-6
View File
@@ -114,12 +114,6 @@ docs/superpowers/*
# treat it as a local edit and autostash it on every run (#38529).
.hermes-bootstrap-complete
# Interrupted-update breadcrumb + recovery lock written next to the shared venv
# by `hermes update` / launch-time self-heal. Runtime state, never a code change
# — ignore so `git status` stays clean and update's autostash skips them.
.update-incomplete
.update-incomplete.lock
# Tool Search live-test harness output — non-deterministic model transcripts,
# regenerated by scripts/tool_search_livetest.py. Never an artifact of the repo.
scripts/out/
+9 -20
View File
@@ -25,7 +25,7 @@ ENV PLAYWRIGHT_BROWSERS_PATH=/opt/hermes/.playwright
# hermes process, the dashboard, and per-profile gateways.
RUN apt-get update && \
apt-get install -y --no-install-recommends \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc g++ make cmake python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
ca-certificates curl iputils-ping python3 python-is-python3 ripgrep ffmpeg gcc python3-dev python3-venv libffi-dev libolm-dev procps git openssh-client docker-cli xz-utils && \
rm -rf /var/lib/apt/lists/*
# ---------- s6-overlay install ----------
@@ -146,9 +146,9 @@ RUN npm install --prefer-offline --no-audit && \
#
# `uv sync --frozen --no-install-project --extra all --extra messaging`
# installs the deps reachable through the composite `[all]` extra
# (handpicked set intended for the production image — excludes `[dev]`),
# plus gateway messaging adapters that should work in the published image
# without a first-boot lazy install. We do NOT use `--all-extras`:
# (handpicked set intended for the production image), plus gateway
# messaging adapters that should work in the published image without a
# first-boot lazy install. We do NOT use `--all-extras`:
# that would pull in `[rl]` (atroposlib + tinker + torch + wandb from
# git), `[yc-bench]` (another git dep), and `[termux-all]` (Android
# redundancy), none of which belong in the published container.
@@ -164,30 +164,19 @@ RUN npm install --prefer-offline --no-audit && \
# image update and recall/retain then fails with
# `ModuleNotFoundError: No module named 'hindsight_client'` (#38128).
#
# The Matrix gateway's deps ([matrix] extra) are baked in because
# python-olm (transitive via mautrix[encryption]) builds from source on
# Python/image combinations without usable wheels. The Docker image is
# Linux-only, so keeping the native libolm/build-toolchain packages here
# avoids the cross-platform failures that kept [matrix] out of [all]
# while still making Matrix work in the published container. Fixes #30399.
#
# The editable link is created after the source copy below.
COPY pyproject.toml uv.lock ./
RUN touch ./README.md
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight --extra matrix
# ---------- Frontend build (cached independently from Python source) ----------
# Copy only the frontend source trees first so that Python-only changes don't
# invalidate the (relatively slow) web + ui-tui build layer.
COPY web/ web/
COPY ui-tui/ ui-tui/
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
RUN uv sync --frozen --no-install-project --extra all --extra messaging --extra anthropic --extra bedrock --extra azure-identity --extra hindsight
# ---------- Source code ----------
# .dockerignore excludes node_modules, so the installs above survive.
COPY --chown=hermes:hermes . .
# Build browser dashboard and terminal UI assets.
RUN cd web && npm run build && \
cd ../ui-tui && npm run build
# ---------- Permissions ----------
# Make install dir world-readable so any HERMES_UID can read it at runtime.
# The venv needs to be traversable too.
+5 -10
View File
@@ -102,7 +102,7 @@ OpenAI = _OpenAIProxy() # module-level name, resolves lazily on call/isinstance
from agent.credential_pool import load_pool
from hermes_cli.config import get_hermes_home
from hermes_constants import OPENROUTER_BASE_URL
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
from utils import base_url_host_matches, base_url_hostname, normalize_proxy_env_vars
logger = logging.getLogger(__name__)
@@ -4300,15 +4300,13 @@ def get_auxiliary_extra_body() -> dict:
return _nous_extra_body() if auxiliary_is_nous else {}
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> dict:
def auxiliary_max_tokens_param(value: int) -> dict:
"""Return the correct max tokens kwarg for the auxiliary client's provider.
OpenRouter and local models use 'max_tokens'. Direct OpenAI with newer
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
models (gpt-4o, o-series, gpt-5+) requires 'max_completion_tokens'.
The Codex adapter translates max_tokens internally, so we use max_tokens
for it as well. Pass ``model`` so third-party OpenAI-compatible endpoints
fronting the newer families are also recognised URL-only detection
misses the case where a custom base URL serves e.g. ``gpt-5.4``.
for it as well.
"""
custom_base = _current_custom_base_url()
or_key = os.getenv("OPENROUTER_API_KEY")
@@ -4318,9 +4316,6 @@ def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> di
and _read_nous_auth() is None
and base_url_hostname(custom_base) in {"api.openai.com", "api.githubcopilot.com"}):
return {"max_completion_tokens": value}
# ...and for any caller serving a newer OpenAI-family model by name.
if model_forces_max_completion_tokens(model):
return {"max_completion_tokens": value}
return {"max_tokens": value}
+15 -2
View File
@@ -25,6 +25,7 @@ import json
import logging
import os
import re
import tempfile
import threading
from datetime import datetime, timedelta, timezone
from pathlib import Path
@@ -32,7 +33,6 @@ from typing import Any, Callable, Dict, List, NamedTuple, Optional, Set
from hermes_constants import get_hermes_home
from tools import skill_usage
from utils import atomic_json_write
logger = logging.getLogger(__name__)
@@ -97,7 +97,20 @@ def load_state() -> Dict[str, Any]:
def save_state(data: Dict[str, Any]) -> None:
path = _state_file()
try:
atomic_json_write(path, data, indent=2, sort_keys=True)
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=".curator_state_", suffix=".tmp")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(data, f, indent=2, sort_keys=True, ensure_ascii=False)
f.flush()
os.fsync(f.fileno())
os.replace(tmp, path)
except BaseException:
try:
os.unlink(tmp)
except OSError:
pass
raise
except Exception as e:
logger.debug("Failed to save curator state: %s", e, exc_info=True)
-28
View File
@@ -966,34 +966,6 @@ def _classify_400(
should_fallback=False,
)
# Request-validation errors (unsupported / unknown parameter) MUST be
# checked BEFORE context_overflow. A GPT-5 model rejecting max_tokens
# returns:
# "Unsupported parameter: 'max_tokens' is not supported with this model.
# Use 'max_completion_tokens' instead."
# That string contains the literal substring "max_tokens", which is one of
# the _CONTEXT_OVERFLOW_PATTERNS — so without this guard the 400 is
# misclassified as context_overflow, routed into the compression loop,
# re-sent with the same bad parameter, and ends in "Cannot compress
# further". These errors are deterministic (every retry gets the identical
# rejection), so classify as a non-retryable format_error and fall back.
#
# NOTE: we deliberately do NOT key off the generic ``invalid_request_error``
# code here — OpenAI stamps that same code on genuine context-overflow 400s,
# so matching it would mis-route real overflows away from compression. The
# unambiguous signals are the explicit "unsupported/unknown parameter"
# message text and the specific parameter-level error codes.
if (
any(p in error_msg for p in _REQUEST_VALIDATION_PATTERNS
if p != "invalid_request_error")
or error_code_lower in {"unknown_parameter", "unsupported_parameter"}
):
return result_fn(
FailoverReason.format_error,
retryable=False,
should_fallback=True,
)
# Context overflow from 400
if any(p in error_msg for p in _CONTEXT_OVERFLOW_PATTERNS):
return result_fn(
-11
View File
@@ -1838,17 +1838,6 @@ def get_model_context_length(
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
if ctx:
# MiniMax M3: models.dev reports 512K but actual context is 1M.
# Prefer hardcoded catalog over stale probe value.
if _model_name_suggests_minimax_m3(model):
catalog = DEFAULT_CONTEXT_LENGTHS.get("minimax-m3")
if catalog and ctx < catalog:
logger.info(
"Rejecting models.dev context=%s for %r "
"(MiniMax-M3 underreport); using hardcoded default %s",
ctx, model, f"{catalog:,}",
)
ctx = catalog
return ctx
# 6. OpenRouter live API metadata — provider-unaware fallback.
-3
View File
@@ -13,7 +13,6 @@ DEFAULT_PRICING = {"input": 0.0, "output": 0.0}
_ZERO = Decimal("0")
_ONE_MILLION = Decimal("1000000")
_NOUS_DEFAULT_BASE_URL = "https://inference-api.nousresearch.com/v1"
CostStatus = Literal["actual", "estimated", "included", "unknown"]
CostSource = Literal[
@@ -571,8 +570,6 @@ def resolve_billing_route(
return BillingRoute(provider="openai-codex", model=model, base_url=base_url or "", billing_mode="subscription_included")
if provider_name == "openrouter" or base_url_host_matches(base_url or "", "openrouter.ai"):
return BillingRoute(provider="openrouter", model=model, base_url=base_url or "", billing_mode="official_models_api")
if provider_name == "nous" or base_url_host_matches(base_url or "", "inference-api.nousresearch.com"):
return BillingRoute(provider="nous", model=model, base_url=base_url or _NOUS_DEFAULT_BASE_URL, billing_mode="official_models_api")
if provider_name == "anthropic":
return BillingRoute(provider="anthropic", model=model.split("/")[-1], base_url=base_url or "", billing_mode="official_docs_snapshot")
if provider_name == "openai":
+2 -11
View File
@@ -40,15 +40,6 @@ const path = require('node:path')
const https = require('node:https')
const { spawn } = require('node:child_process')
const IS_WINDOWS = process.platform === 'win32'
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
const STAMP_COMMIT_RE = /^[0-9a-f]{7,40}$/i
// Stages flagged needs_user_input=true in the manifest are skipped by the
@@ -293,7 +284,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
const ps = process.platform === 'win32' ? resolveWindowsPowerShell() : 'pwsh'
const fullArgs = ['-NoProfile', '-ExecutionPolicy', 'Bypass', '-File', scriptPath, ...args]
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
const child = spawn(ps, fullArgs, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
@@ -301,7 +292,7 @@ function spawnPowerShell(scriptPath, args, { emit, stageName, abortSignal, herme
// choice rather than re-computing the default.
HERMES_HOME: hermesHome || process.env.HERMES_HOME || ''
}
}))
})
let stdout = ''
let stderr = ''
+15 -22
View File
@@ -107,13 +107,6 @@ const IS_WINDOWS = process.platform === 'win32'
const IS_WSL = isWslEnvironment()
const APP_ROOT = app.getAppPath()
function hiddenWindowsChildOptions(options = {}) {
if (!IS_WINDOWS || Object.prototype.hasOwnProperty.call(options, 'windowsHide')) {
return options
}
return { ...options, windowsHide: true }
}
// Remote displays (SSH X11 forwarding, VNC, RDP) make Chromium's GPU
// compositor flicker — accelerated layers can't be presented cleanly over the
// wire, so the window flashes during scroll/streaming/animation. Local
@@ -1113,7 +1106,7 @@ function findSystemPython() {
const out = execFileSync(
'reg',
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
)
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
@@ -1149,10 +1142,10 @@ function findSystemPython() {
if (pyExe) {
for (const version of SUPPORTED_VERSIONS) {
try {
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
encoding: 'utf8',
stdio: ['ignore', 'pipe', 'ignore']
}))
})
const candidate = out.trim()
if (candidate && fileExists(candidate)) return candidate
} catch {
@@ -1287,11 +1280,11 @@ function resolveUpdateRoot() {
function runGit(args, options = {}) {
return new Promise((resolve, reject) => {
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
cwd: options.cwd,
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
stdio: ['ignore', 'pipe', 'pipe']
}))
})
let stdout = ''
let stderr = ''
@@ -1501,7 +1494,7 @@ function forceKillProcessTree(pid) {
if (!IS_WINDOWS) return
if (!Number.isInteger(pid) || pid <= 0) return
try {
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
} catch {
// Already gone, or no permission — best effort; the unlock wait below is
// the real gate.
@@ -1687,11 +1680,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
return new Promise(resolve => {
let child
try {
child = spawn(command, args, hiddenWindowsChildOptions({
child = spawn(command, args, {
cwd,
env: { ...process.env, ...(env || {}) },
stdio: ['ignore', 'pipe', 'pipe']
}))
})
} catch (err) {
resolve({ code: 1, error: err.message })
return
@@ -2678,7 +2671,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
'--raw',
url
]
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
const chunks = []
let bytes = 0
@@ -4498,7 +4491,7 @@ async function spawnPoolBackend(profile, entry) {
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
const child = spawn(backend.command, backend.args, {
cwd: hermesCwd,
env: {
...process.env,
@@ -4516,7 +4509,7 @@ async function spawnPoolBackend(profile, entry) {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
})
entry.process = child
entry.port = port
entry.token = token
@@ -4698,7 +4691,7 @@ async function startHermes() {
await advanceBootProgress('backend.spawn', `Starting Hermes backend via ${backend.label}`, 84)
rememberLog(`Starting Hermes backend via ${backend.label}`)
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
hermesProcess = spawn(backend.command, backend.args, {
cwd: hermesCwd,
env: {
...process.env,
@@ -4721,7 +4714,7 @@ async function startHermes() {
},
shell: backend.shell,
stdio: ['ignore', 'pipe', 'pipe']
}))
})
hermesProcess.stdout.on('data', rememberLog)
hermesProcess.stderr.on('data', rememberLog)
@@ -5993,11 +5986,11 @@ async function getUninstallSummary() {
resolve(value)
}
try {
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
cwd: agentRoot,
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
stdio: ['ignore', 'pipe', 'ignore']
}))
})
child.stdout.on('data', chunk => {
stdout += chunk.toString()
})
@@ -1,54 +0,0 @@
'use strict'
const test = require('node:test')
const assert = require('node:assert/strict')
const fs = require('node:fs')
const path = require('node:path')
const ELECTRON_DIR = __dirname
function readElectronFile(name) {
return fs.readFileSync(path.join(ELECTRON_DIR, name), 'utf8')
}
function requireHiddenChildOptions(source, needle) {
const index = source.indexOf(needle)
assert.notEqual(index, -1, `missing call site: ${needle}`)
const snippet = source.slice(index, index + 700)
assert.match(
snippet,
/hiddenWindowsChildOptions\(/,
`expected ${needle} to wrap child-process options with hiddenWindowsChildOptions`
)
}
test('desktop background child processes opt into hidden Windows consoles', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, "execFileSync(\n 'reg'")
requireHiddenChildOptions(source, 'execFileSync(pyExe')
requireHiddenChildOptions(source, 'spawn(resolveGitBinary()')
requireHiddenChildOptions(source, "execFileSync('taskkill'")
requireHiddenChildOptions(source, 'spawn(command, args')
requireHiddenChildOptions(source, "spawn('curl'")
requireHiddenChildOptions(source, 'spawn(backend.command, backend.args')
requireHiddenChildOptions(source, 'hermesProcess = spawn(backend.command, backend.args')
requireHiddenChildOptions(source, "spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary']")
})
test('intentional or interactive desktop child processes stay documented', () => {
const source = readElectronFile('main.cjs')
assert.match(source, /windowsHide: false/)
assert.match(source, /nodePty\.spawn\(command, args/)
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
})
test('bootstrap PowerShell runner hides Windows console children', () => {
const source = readElectronFile('bootstrap-runner.cjs')
assert.match(source, /function hiddenWindowsChildOptions\(options = \{\}\)/)
requireHiddenChildOptions(source, 'spawn(ps, fullArgs')
})
+1
View File
@@ -0,0 +1 @@
/home/ben/nous/hermes-agent/.worktrees/hermes-e14f8918/apps/desktop/node_modules
+1 -1
View File
@@ -35,7 +35,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",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs electron/windows-child-process.test.cjs",
"test:desktop:platforms": "node --test electron/bootstrap-platform.test.cjs electron/hardening.test.cjs electron/backend-probes.test.cjs electron/bootstrap-runner.test.cjs electron/connection-config.test.cjs electron/gateway-ws-probe.test.cjs electron/oauth-net-request.test.cjs electron/desktop-uninstall.test.cjs electron/session-windows.test.cjs electron/workspace-cwd.test.cjs",
"type-check": "tsc -b",
"lint": "eslint src/ electron/",
"lint:fix": "eslint src/ electron/ --fix",
+43 -3
View File
@@ -38,7 +38,6 @@ import { Skeleton } from '@/components/ui/skeleton'
import { Tip } from '@/components/ui/tooltip'
import { searchSessions, type SessionInfo, type SessionSearchResult } from '@/hermes'
import { useI18n } from '@/i18n'
import { normalizeCombo } from '@/lib/keybinds/combo'
import { profileColor } from '@/lib/profile-color'
import { sessionMatchesSearch } from '@/lib/session-search'
import { normalizeSessionSource, sessionSourceLabel } from '@/lib/session-source'
@@ -92,6 +91,7 @@ import {
} from '@/store/session'
import { type AppView, ARTIFACTS_ROUTE, MESSAGING_ROUTE, SKILLS_ROUTE } from '../../routes'
import { useWorkspaceGitRepos } from '../../session/hooks/use-workspace-git'
import { SidebarPanelLabel } from '../../shell/sidebar-label'
import type { SidebarNavItem } from '../../types'
@@ -112,7 +112,8 @@ const NON_SESSION_LOAD_STEP = 10
// Render the modifier key the user actually presses on this platform. The
// global accelerator is bound to both Cmd+N (macOS) and Ctrl+N (everywhere
// else) in desktop-controller.tsx, but the hint should match muscle memory.
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
const NEW_SESSION_KBD: readonly string[] =
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
const SIDEBAR_NAV: SidebarNavItem[] = [
{
@@ -297,6 +298,8 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
onNewSessionInWorkspace: (path: null | string) => void
onNewSessionWorktree: (path: null | string) => void
requestGateway: <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
onManageCronJob: (jobId: string) => void
onTriggerCronJob: (jobId: string) => void
}
@@ -311,6 +314,8 @@ export function ChatSidebar({
onDeleteSession,
onArchiveSession,
onNewSessionInWorkspace,
onNewSessionWorktree,
requestGateway,
onManageCronJob,
onTriggerCronJob
}: ChatSidebarProps) {
@@ -660,6 +665,21 @@ export function ChatSidebar({
sessionProfileTotals
])
// Probe each distinct workspace path for git-repo-ness (memoized, once per
// path) so the per-group "new session in a worktree" fork icon only appears
// for real repos.
const workspacePaths = useMemo(
() => agentGroups.map(g => g.path).filter((p): p is string => Boolean(p)),
[agentGroups]
)
const gitRepoPaths = useWorkspaceGitRepos(workspacePaths, requestGateway)
const agentGroupsWithRepo = useMemo(
() => agentGroups.map(g => ({ ...g, isGitRepo: g.path ? gitRepoPaths.has(g.path) : false })),
[agentGroups, gitRepoPaths]
)
const displayAgentSessions = agentSessions
// Pagination is scope-aware. In "All profiles" mode it tracks the global
@@ -681,7 +701,7 @@ export function ChatSidebar({
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroupsWithRepo : undefined
// The recents list owns its own (virtualized) scroll container only when it's a
// long flat list. In that case it must keep its scroller even in short mode, so
@@ -960,6 +980,7 @@ export function ChatSidebar({
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onNewSessionInWorkspace={showAllProfiles ? undefined : onNewSessionInWorkspace}
onNewSessionWorktree={showAllProfiles ? undefined : onNewSessionWorktree}
onReorder={showAllProfiles ? undefined : handleAgentDragEnd}
onResumeSession={onResumeSession}
onToggle={() => setSidebarRecentsOpen(!agentsOpen)}
@@ -1126,6 +1147,7 @@ interface SidebarSessionGroup {
onLoadMore?: () => void
sourceId?: string
totalCount?: number
isGitRepo?: boolean
}
interface MessagingSection {
@@ -1148,6 +1170,7 @@ interface SidebarSessionsSectionProps {
onArchiveSession: (sessionId: string) => void
onTogglePin: (sessionId: string) => void
onNewSessionInWorkspace?: (path: null | string) => void
onNewSessionWorktree?: (path: null | string) => void
pinned: boolean
rootClassName?: string
contentClassName?: string
@@ -1175,6 +1198,7 @@ function SidebarSessionsSection({
onArchiveSession,
onTogglePin,
onNewSessionInWorkspace,
onNewSessionWorktree,
pinned,
rootClassName,
contentClassName,
@@ -1249,6 +1273,7 @@ function SidebarSessionsSection({
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
onNewSessionWorktree={onNewSessionWorktree}
renderRows={renderNestedSessionList}
/>
) : (
@@ -1256,6 +1281,7 @@ function SidebarSessionsSection({
group={group}
key={group.id}
onNewSession={onNewSessionInWorkspace}
onNewSessionWorktree={onNewSessionWorktree}
renderRows={renderSessionList}
/>
)
@@ -1326,6 +1352,7 @@ interface SidebarWorkspaceGroupProps extends React.ComponentProps<'div'> {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
onNewSessionWorktree?: (path: null | string) => void
reorderable?: boolean
dragging?: boolean
dragHandleProps?: React.HTMLAttributes<HTMLElement>
@@ -1335,6 +1362,7 @@ function SidebarWorkspaceGroup({
group,
renderRows,
onNewSession,
onNewSessionWorktree,
reorderable = false,
dragging = false,
dragHandleProps,
@@ -1426,6 +1454,17 @@ function SidebarWorkspaceGroup({
</button>
</Tip>
)}
{group.isGitRepo && onNewSessionWorktree && group.path && (
<button
aria-label={`New worktree session in ${group.label}`}
className="grid size-4 shrink-0 place-items-center rounded-sm bg-transparent text-(--ui-text-quaternary) opacity-0 transition-opacity hover:bg-(--ui-control-hover-background) hover:text-foreground group-hover/workspace:opacity-100"
onClick={() => onNewSessionWorktree(group.path)}
title={`New session in a git worktree of ${group.label}`}
type="button"
>
<Codicon name="repo-forked" size="0.75rem" />
</button>
)}
{reorderable && (
<span
{...dragHandleProps}
@@ -1476,6 +1515,7 @@ interface SortableWorkspaceProps {
group: SidebarSessionGroup
renderRows: (sessions: SessionInfo[]) => React.ReactNode
onNewSession?: (path: null | string) => void
onNewSessionWorktree?: (path: null | string) => void
}
function SortableSidebarWorkspaceGroup(props: SortableWorkspaceProps) {
@@ -10,7 +10,6 @@ import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { modKey } from '@/lib/keybinds/combo'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@@ -134,11 +133,11 @@ export function SidebarSessionRow({
return
}
// ⌘-click (mac) / Ctrl-click (win/linux) pops the chat into its own
// ⌘-click (mac) / -click (win/linux) pops the chat into its own
// window — the universal "open in a new window" gesture. Archive
// lives in the row's ⋯ and right-click menus. Falls through to a
// normal resume when standalone windows aren't available (web embed).
if (event[modKey] && canOpenSessionWindow()) {
if ((event.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
event.preventDefault()
event.stopPropagation()
triggerHaptic('selection')
+21 -2
View File
@@ -70,6 +70,7 @@ import {
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setPendingWorktree,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
@@ -91,7 +92,6 @@ import { CommandPalette } from './command-palette'
import { useGatewayBoot } from './gateway/hooks/use-gateway-boot'
import { useGatewayRequest } from './gateway/hooks/use-gateway-request'
import { useKeybinds } from './hooks/use-keybinds'
import { modKey } from '@/lib/keybinds/combo'
import { SIDEBAR_COLLAPSE_MEDIA_QUERY } from './layout-constants'
import { ModelPickerOverlay } from './model-picker-overlay'
import { ModelVisibilityOverlay } from './model-visibility-overlay'
@@ -272,7 +272,7 @@ export function DesktopController() {
return
}
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
event.preventDefault()
event.stopPropagation()
closeActiveRightRailTab()
@@ -676,6 +676,23 @@ export function DesktopController() {
[requestGateway, startFreshSessionDraft]
)
const startSessionInWorktree = useCallback(
(path: null | string) => {
const target = path?.trim()
if (!target) {
return
}
// Same as a workspace new-session, but arm the one-shot worktree flag so
// the backend creates the session inside a fresh git worktree of this
// repo. startFreshSessionDraft() clears the flag, so arm it afterwards.
startSessionInWorkspace(target)
setPendingWorktree(true)
},
[startSessionInWorkspace]
)
const handleSkinCommand = useSkinCommand()
const {
@@ -792,12 +809,14 @@ export function DesktopController() {
}}
onNavigate={selectSidebarItem}
onNewSessionInWorkspace={startSessionInWorkspace}
onNewSessionWorktree={startSessionInWorktree}
onResumeSession={sessionId => navigate(sessionRoute(sessionId))}
onTriggerCronJob={jobId => {
void triggerCronJob(jobId)
.then(() => refreshCronJobs())
.catch(() => undefined)
}}
requestGateway={requestGateway}
/>
)
@@ -69,7 +69,7 @@ export function TerminalTab({ cwd, onAddSelectionToChat }: TerminalTabProps) {
variant="secondary"
>
{t.rightSidebar.addToChat}
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel}</span>
<span className="ml-1 text-[0.6rem] text-(--ui-text-tertiary)">{addSelectionShortcutLabel()}</span>
</Button>
</div>
)}
@@ -1,7 +1,6 @@
import type { ITheme, Terminal } from '@xterm/xterm'
import type { CSSProperties } from 'react'
import { formatCombo, modKey } from '@/lib/keybinds/combo'
import type { DesktopTerminalPalette } from '@/themes/types'
// VS Code's default integrated-terminal palette (terminalColorRegistry.ts) — a
@@ -98,10 +97,12 @@ export function resolveSurfaceColor(fallback: string): string {
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
}
export const addSelectionShortcutLabel = formatCombo('mod+l')
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
export function isAddSelectionShortcut(event: KeyboardEvent) {
const mod = event[modKey]
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
}
@@ -17,10 +17,9 @@ import { $activeGatewayProfile, $newChatProfile, ensureGatewayProfile, normalize
import {
$currentCwd,
$messages,
$pendingWorktree,
$sessions,
$yoloActive,
getRememberedWorkspaceCwd,
workspaceCwdForNewSession,
sessionPinId,
setActiveSessionId,
setAwaitingResponse,
@@ -37,12 +36,14 @@ import {
setFreshDraftReady,
setIntroSeed,
setMessages,
setPendingWorktree,
setSelectedStoredSessionId,
setSessions,
setSessionStartedAt,
setSessionsTotal,
setTurnStartedAt,
setYoloActive
setYoloActive,
workspaceCwdForNewSession
} from '@/store/session'
import { reportBackendContract } from '@/store/updates'
import type { SessionCreateResponse, SessionInfo, SessionResumeResponse, UsageStats } from '@/types/hermes'
@@ -316,6 +317,8 @@ export function useSessionActions({
// otherwise the sticky last-used workspace (PR #37586).
setCurrentCwd(workspaceCwdForNewSession())
setCurrentBranch('')
// A plain new-chat draft is never a worktree session; clear any stale arm.
setPendingWorktree(false)
clearComposerDraft()
clearComposerAttachments()
setFreshDraftReady(true)
@@ -339,11 +342,21 @@ export function useSessionActions({
// Pass the owning profile so a new chat under a non-launch profile (global
// remote mode) builds its agent + persists against THAT profile's home/db.
const newChatProfile = $newChatProfile.get()
// The fork icon arms a one-shot worktree request; consume + reset it so
// a later plain new-chat doesn't accidentally inherit it.
const worktree = $pendingWorktree.get()
if (worktree) {
setPendingWorktree(false)
}
const created = await requestGateway<SessionCreateResponse>('session.create', {
cols: 96,
...(cwd && { cwd }),
...(newChatProfile ? { profile: newChatProfile } : {})
...(newChatProfile ? { profile: newChatProfile } : {}),
...(worktree && cwd ? { worktree: true } : {})
})
const stored = created.stored_session_id ?? null
if (
@@ -0,0 +1,65 @@
import { useEffect } from 'react'
import { useStore } from '@nanostores/react'
import { atom } from 'nanostores'
/**
* Per-workspace git-repo detection for the sidebar.
*
* The "new session in a worktree" fork icon must only appear for workspace
* groups whose path is a real git repository. We probe each distinct path once
* via the `git.is_repo` gateway method and memoize the answer for the lifetime
* of the renderer a workspace doesn't stop being a repo while the app is open,
* and re-probing on every sidebar render would be wasteful.
*
* Results live in a module-level nanostore so every sidebar instance shares one
* cache and re-renders when a probe resolves.
*/
// path -> isRepo. Absence means "not yet probed".
const $repoByPath = atom<Record<string, boolean>>({})
// Paths with an in-flight or completed probe, so we never probe the same path
// twice (even before the first result lands).
const probed = new Set<string>()
type RequestGateway = <T = unknown>(method: string, params?: Record<string, unknown>) => Promise<T>
async function probePath(path: string, requestGateway: RequestGateway): Promise<void> {
try {
const res = await requestGateway<{ is_repo?: boolean }>('git.is_repo', { cwd: path })
$repoByPath.set({ ...$repoByPath.get(), [path]: Boolean(res?.is_repo) })
} catch {
// Treat a failed probe as "not a repo" — the icon simply won't appear, and
// the backend would fall back gracefully anyway if it somehow got asked.
$repoByPath.set({ ...$repoByPath.get(), [path]: false })
}
}
/**
* Probe every supplied workspace path for git-repo-ness (once each) and return
* a `Set` of the paths that are repos. Re-renders when probes resolve.
*
* @param paths Distinct, non-null workspace paths to probe.
* @param requestGateway Gateway RPC caller.
*/
export function useWorkspaceGitRepos(paths: string[], requestGateway: RequestGateway): Set<string> {
const repoByPath = useStore($repoByPath)
useEffect(() => {
for (const path of paths) {
if (!path || probed.has(path)) {
continue
}
probed.add(path)
void probePath(path, requestGateway)
}
}, [paths, requestGateway])
const repos = new Set<string>()
for (const [path, isRepo] of Object.entries(repoByPath)) {
if (isRepo) {
repos.add(path)
}
}
return repos
}
+2 -2
View File
@@ -14,7 +14,7 @@ import {
type KeybindActionMeta,
type KeybindReadonly
} from '@/lib/keybinds/actions'
import { formatCombo, formatFakeCombo } from '@/lib/keybinds/combo'
import { formatCombo } from '@/lib/keybinds/combo'
import { arraysEqual } from '@/lib/storage'
import {
$bindings,
@@ -210,7 +210,7 @@ function ReadonlyRow({ shortcut }: { shortcut: KeybindReadonly }) {
<div className="flex shrink-0 items-center gap-1">
{shortcut.keys.map(key => (
<span className="kbd-cap" key={key}>
{formatFakeCombo(key)}
{formatCombo(key)}
</span>
))}
</div>
@@ -722,14 +722,8 @@ function StickyHumanMessageContainer({ children }: { children: ReactNode }) {
// edit composer render the same bubble surface (rounded glass card);
// they only differ in border weight, cursor, and padding-right (the
// read-only view reserves room for the restore icon).
//
// no-drag: sticky bubbles park at --sticky-human-top (~4px), sliding under the
// titlebar's [-webkit-app-region:drag] strips (app-shell.tsx). Electron resolves
// drag regions at the compositor level — z-index and pointer-events don't help —
// so without the carve-out, clicking a stuck bubble drags the window instead of
// opening the edit composer.
const USER_BUBBLE_BASE_CLASS =
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left [-webkit-app-region:no-drag]'
'composer-human-message standalone-glass relative flex w-full min-w-0 max-w-full flex-col gap-1.5 overflow-hidden rounded-xl border bg-(--dt-user-bubble) px-3 py-2 text-left'
const USER_ACTION_ICON_BUTTON_CLASS =
'grid place-items-center rounded-md bg-transparent text-(--ui-text-secondary) transition-colors hover:bg-(--ui-control-active-background) hover:text-foreground disabled:cursor-default disabled:text-(--ui-text-quaternary) disabled:opacity-70'
@@ -16,7 +16,6 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
import { useI18n } from '@/i18n'
import { triggerHaptic } from '@/lib/haptics'
import { ChevronDown, Loader2 } from '@/lib/icons'
import { formatCombo } from '@/lib/keybinds/combo'
import { $gateway } from '@/store/gateway'
import { notifyError } from '@/store/notifications'
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
@@ -51,6 +50,8 @@ export const PendingToolApproval: FC<{ part: ToolPart }> = ({ part }) => {
return <ApprovalBar request={request} />
}
const isMac = typeof navigator !== 'undefined' && /Mac|iP(hone|ad|od)/.test(navigator.platform)
const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
const { t } = useI18n()
const copy = t.assistant.approval
@@ -126,7 +127,7 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
variant="ghost"
>
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</span>}
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
</Button>
<span aria-hidden className="w-px self-stretch bg-primary/20" />
<DropdownMenu>
@@ -13,9 +13,9 @@ import { DisclosureRow } from '@/components/chat/disclosure-row'
import { PreviewAttachment } from '@/components/chat/preview-attachment'
import { ZoomableImage } from '@/components/chat/zoomable-image'
import { BrailleSpinner } from '@/components/ui/braille-spinner'
import { Codicon } from '@/components/ui/codicon'
import { CopyButton } from '@/components/ui/copy-button'
import { FadeText } from '@/components/ui/fade-text'
import { ToolIcon } from '@/components/ui/tool-icon'
import { useI18n } from '@/i18n'
import { PrettyLink, LinkifiedText as SharedLinkifiedText, urlSlugTitleLabel } from '@/lib/external-link'
import { AlertCircle, CheckCircle2 } from '@/lib/icons'
@@ -136,7 +136,7 @@ function ToolGlyph({ copy, icon, status }: { copy: ToolStatusCopy; icon?: string
const node = status ? (
statusGlyph(status, copy)
) : icon ? (
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
) : null
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
@@ -1,141 +0,0 @@
import { ExportedMessageRepository } from '@assistant-ui/core/internal'
// Clicking a user bubble must open the inline edit composer — through the
// app's incremental external-store runtime (which reimplements capability
// resolution, incl. `edit: onEdit !== undefined`) and the stock runtime.
//
// Note: this covers the React/runtime wiring only. The Electron-level failure
// mode (titlebar -webkit-app-region:drag swallowing clicks on *stuck* sticky
// bubbles) is not reproducible in jsdom — see USER_BUBBLE_BASE_CLASS's no-drag
// carve-out in thread.tsx.
import { AssistantRuntimeProvider, type ThreadMessage, useExternalStoreRuntime } from '@assistant-ui/react'
import { fireEvent, render, screen, waitFor } from '@testing-library/react'
import { describe, expect, it, vi } from 'vitest'
import { useIncrementalExternalStoreRuntime } from '@/lib/incremental-external-store-runtime'
import { Thread } from './thread'
const createdAt = new Date('2026-05-01T00:00:00.000Z')
class TestResizeObserver {
observe() {}
unobserve() {}
disconnect() {}
}
vi.stubGlobal('ResizeObserver', TestResizeObserver)
vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) =>
window.setTimeout(() => callback(performance.now()), 0)
)
vi.stubGlobal('cancelAnimationFrame', (id: number) => window.clearTimeout(id))
Element.prototype.scrollTo = function scrollTo() {}
function stubOffsetDimension(
prop: 'offsetHeight' | 'offsetWidth',
clientProp: 'clientHeight' | 'clientWidth',
fallback: number
) {
const previous = Object.getOwnPropertyDescriptor(HTMLElement.prototype, prop)
Object.defineProperty(HTMLElement.prototype, prop, {
configurable: true,
get() {
return previous?.get?.call(this) || (this as HTMLElement)[clientProp] || fallback
}
})
}
stubOffsetDimension('offsetWidth', 'clientWidth', 800)
stubOffsetDimension('offsetHeight', 'clientHeight', 600)
function userMessage(): ThreadMessage {
return {
id: 'user-1',
role: 'user',
content: [{ type: 'text', text: 'edit me please' }],
attachments: [],
createdAt,
metadata: { custom: {} }
} as ThreadMessage
}
function assistantMessage(): ThreadMessage {
return {
id: 'assistant-1',
role: 'assistant',
content: [{ type: 'text', text: 'done' }],
status: { type: 'complete', reason: 'stop' },
createdAt,
metadata: {
unstable_state: null,
unstable_annotations: [],
unstable_data: [],
steps: [],
custom: {}
}
} as ThreadMessage
}
// Mirrors chat/index.tsx: incremental runtime + messageRepository + onEdit.
function IncrementalHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const repository = ExportedMessageRepository.fromArray([userMessage(), assistantMessage()])
const runtime = useIncrementalExternalStoreRuntime<ThreadMessage>({
messageRepository: repository,
isRunning: false,
setMessages: () => {},
onNew: async () => {},
onEdit,
onCancel: async () => {},
onReload: async () => {}
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
// Control: stock external store runtime.
function StockHarness({ onEdit }: { onEdit: () => Promise<void> }) {
const runtime = useExternalStoreRuntime<ThreadMessage>({
messages: [userMessage(), assistantMessage()],
isRunning: false,
onNew: async () => {},
onEdit
})
return (
<AssistantRuntimeProvider runtime={runtime}>
<Thread />
</AssistantRuntimeProvider>
)
}
describe('click-to-edit user message', () => {
it('opens the edit composer with the incremental runtime', async () => {
const { container } = render(<IncrementalHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
it('opens the edit composer with the stock runtime', async () => {
const { container } = render(<StockHarness onEdit={async () => {}} />)
const bubble = await screen.findByRole('button', { name: 'Edit message' })
fireEvent.click(bubble)
await waitFor(() => {
expect(container.querySelector('[data-slot="aui_edit-composer-root"]')).toBeTruthy()
})
})
})
@@ -1,65 +0,0 @@
import type * as React from 'react'
import { Codicon } from '@/components/ui/codicon'
import { cn } from '@/lib/utils'
// Solid (filled) glyphs for in-thread tool rows. Codicons are an outline icon
// *font*, so an outline glyph has no separate fillable region — a filled look
// can't be derived from it (stroke-thickening just bolds the outline). To get
// the Cursor-style filled tool icons we render dedicated solid SVG paths,
// keyed by the same names used in `TOOL_META` (tool-fallback-model.ts).
//
// Paths are Phosphor Icons (MIT) "fill" weight, 256×256 viewBox. Inlining the
// path data mirrors the existing precedent in `directive-text.tsx`.
const TOOL_ICON_PATHS: Record<string, string> = {
diff: 'M118.18,213.08c-.11.14-.24.27-.36.4l-.16.18-.17.15a4.83,4.83,0,0,1-.42.37,3.92,3.92,0,0,1-.32.25l-.3.22-.38.23a2.91,2.91,0,0,1-.3.17l-.37.19-.34.15-.36.13a2.84,2.84,0,0,1-.38.13l-.36.1c-.14,0-.26.07-.4.09l-.42.07-.35.05a7,7,0,0,1-.79,0H64a8,8,0,0,1,0-16H92.69L55,162.34a23.85,23.85,0,0,1-7-17V95a32,32,0,1,1,16,0v50.38A8,8,0,0,0,66.34,151L104,188.69V160a8,8,0,0,1,16,0v48a7,7,0,0,1,0,.8c0,.11,0,.21,0,.32s0,.3-.07.46a2.83,2.83,0,0,1-.09.37c0,.13-.06.26-.1.39s-.08.23-.12.35l-.14.39-.15.31c-.06.13-.12.27-.19.4s-.11.18-.16.28l-.24.39-.21.28ZM208,161V110.63a23.85,23.85,0,0,0-7-17L163.31,56H192a8,8,0,0,0,0-16H143.82l-.6,0c-.14,0-.28,0-.41.06l-.37,0-.43.11-.33.08-.4.14-.34.13-.35.16-.36.18a3.14,3.14,0,0,0-.31.18c-.12.07-.25.14-.36.22a3.55,3.55,0,0,0-.31.23,3.81,3.81,0,0,0-.32.24c-.15.12-.28.24-.42.37l-.17.15-.16.18c-.12.13-.25.26-.36.4l-.26.35-.21.28-.24.39c-.05.1-.11.19-.16.28s-.13.27-.19.4l-.15.31-.14.39c0,.12-.09.23-.12.35s-.07.26-.1.39a2.83,2.83,0,0,0-.09.37c0,.16,0,.31-.07.46s0,.21-.05.32a7,7,0,0,0,0,.8V96a8,8,0,0,0,16,0V67.31L189.66,105a8,8,0,0,1,2.34,5.66V161a32,32,0,1,0,16,0Z',
edit: 'M227.31,73.37,182.63,28.68a16,16,0,0,0-22.63,0L36.69,152A15.86,15.86,0,0,0,32,163.31V208a16,16,0,0,0,16,16H92.69A15.86,15.86,0,0,0,104,219.31L227.31,96a16,16,0,0,0,0-22.63ZM192,108.68,147.31,64l24-24L216,84.68Z',
eye: 'M247.31,124.76c-.35-.79-8.82-19.58-27.65-38.41C194.57,61.26,162.88,48,128,48S61.43,61.26,36.34,86.35C17.51,105.18,9,124,8.69,124.76a8,8,0,0,0,0,6.5c.35.79,8.82,19.57,27.65,38.4C61.43,194.74,93.12,208,128,208s66.57-13.26,91.66-38.34c18.83-18.83,27.3-37.61,27.65-38.4A8,8,0,0,0,247.31,124.76ZM128,168a40,40,0,1,1,40-40A40,40,0,0,1,128,168Z',
file: 'M213.66,82.34l-56-56A8,8,0,0,0,152,24H56A16,16,0,0,0,40,40V216a16,16,0,0,0,16,16H200a16,16,0,0,0,16-16V88A8,8,0,0,0,213.66,82.34ZM152,88V44l44,44Z',
'file-media':
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40ZM156,88a12,12,0,1,1-12,12A12,12,0,0,1,156,88Zm60,112H40V160.69l46.34-46.35a8,8,0,0,1,11.32,0h0L165,181.66a8,8,0,0,0,11.32-11.32l-17.66-17.65L173,138.34a8,8,0,0,1,11.31,0L216,170.07V200Z',
files:
'M213.66,66.34l-40-40A8,8,0,0,0,168,24H88A16,16,0,0,0,72,40V56H56A16,16,0,0,0,40,72V216a16,16,0,0,0,16,16H168a16,16,0,0,0,16-16V200h16a16,16,0,0,0,16-16V72A8,8,0,0,0,213.66,66.34ZM136,192H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm0-32H88a8,8,0,0,1,0-16h48a8,8,0,0,1,0,16Zm64,24H184V104a8,8,0,0,0-2.34-5.66l-40-40A8,8,0,0,0,136,56H88V40h76.69L200,75.31Z',
globe:
'M128,24h0A104,104,0,1,0,232,128,104.12,104.12,0,0,0,128,24Zm78.36,64H170.71a135.28,135.28,0,0,0-22.3-45.6A88.29,88.29,0,0,1,206.37,88ZM216,128a87.61,87.61,0,0,1-3.33,24H174.16a157.44,157.44,0,0,0,0-48h38.51A87.61,87.61,0,0,1,216,128ZM128,43a115.27,115.27,0,0,1,26,45H102A115.11,115.11,0,0,1,128,43ZM102,168H154a115.11,115.11,0,0,1-26,45A115.27,115.27,0,0,1,102,168Zm-3.9-16a140.84,140.84,0,0,1,0-48h59.88a140.84,140.84,0,0,1,0,48Zm50.35,61.6a135.28,135.28,0,0,0,22.3-45.6h35.66A88.29,88.29,0,0,1,148.41,213.6Z',
question:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm0,168a12,12,0,1,1,12-12A12,12,0,0,1,128,192Zm8-48.72V144a8,8,0,0,1-16,0v-8a8,8,0,0,1,8-8c13.23,0,24-9,24-20s-10.77-20-24-20-24,9-24,20v4a8,8,0,0,1-16,0v-4c0-19.85,17.94-36,40-36s40,16.15,40,36C168,125.38,154.24,139.93,136,143.28Z',
search:
'M168,112a56,56,0,1,1-56-56A56,56,0,0,1,168,112Zm61.66,117.66a8,8,0,0,1-11.32,0l-50.06-50.07a88,88,0,1,1,11.32-11.31l50.06,50.06A8,8,0,0,1,229.66,229.66ZM112,184a72,72,0,1,0-72-72A72.08,72.08,0,0,0,112,184Z',
terminal:
'M216,40H40A16,16,0,0,0,24,56V200a16,16,0,0,0,16,16H216a16,16,0,0,0,16-16V56A16,16,0,0,0,216,40Zm-91,94.25-40,32a8,8,0,1,1-10-12.5L107.19,128,75,102.25a8,8,0,1,1,10-12.5l40,32a8,8,0,0,1,0,12.5ZM176,168H136a8,8,0,0,1,0-16h40a8,8,0,0,1,0,16Z',
tools:
'M232,96a72,72,0,0,1-100.94,66L79,222.22c-.12.14-.26.29-.39.42a32,32,0,0,1-45.26-45.26c.14-.13.28-.27.43-.39L94,124.94a72.07,72.07,0,0,1,83.54-98.78,8,8,0,0,1,3.93,13.19L144,80l5.66,26.35L176,112l40.65-37.52a8,8,0,0,1,13.19,3.93A72.6,72.6,0,0,1,232,96Z',
watch:
'M128,24A104,104,0,1,0,232,128,104.11,104.11,0,0,0,128,24Zm56,112H128a8,8,0,0,1-8-8V72a8,8,0,0,1,16,0v48h48a8,8,0,0,1,0,16Z'
}
export interface ToolIconProps {
className?: string
name: string
size?: number | string
}
/** Filled tool glyph. Falls back to the outline codicon font for any name not
* covered by the solid set so new tools still render an icon. */
export function ToolIcon({ className, name, size = '0.875rem' }: ToolIconProps) {
const path = TOOL_ICON_PATHS[name]
if (!path) {
return <Codicon className={className} name={name} size={size} />
}
const dimension: React.CSSProperties = { height: size, width: size }
return (
<svg
aria-hidden="true"
className={cn('shrink-0', className)}
fill="currentColor"
style={dimension}
viewBox="0 0 256 256"
>
<path d={path} />
</svg>
)
}
+3 -4
View File
@@ -1,5 +1,4 @@
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -519,7 +518,7 @@ export const en: Translations = {
loading: 'Loading archived sessions…',
archivedTitle: 'Archived sessions',
archivedIntro:
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
'Archived chats are hidden from the sidebar but keep all their messages. Ctrl/⌘-click a chat in the sidebar to archive it.',
emptyArchivedTitle: 'Nothing archived',
emptyArchivedDesc: 'Archive a chat to hide it here.',
unarchive: 'Unarchive',
@@ -530,7 +529,7 @@ export const en: Translations = {
defaultDirTitle: 'Default project directory',
defaultDirDesc:
'New sessions start in this folder unless you pick another. Leave it unset to use your home directory.',
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
defaultDirUpdated: 'Default project directory updated — start a new chat (Ctrl/⌘+N) for it to take effect',
defaultsTo: label => `Defaults to ${label}.`,
change: 'Change',
choose: 'Choose',
@@ -1678,7 +1677,7 @@ export const en: Translations = {
loadingQuestion: 'Loading question…',
other: 'Other (type your answer)',
placeholder: 'Type your answer…',
shortcut: `${formatCombo('mod+enter')} to send`,
shortcut: '⌘/Ctrl + Enter to send',
back: 'Back',
skip: 'Skip',
send: 'Send'
+2 -3
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale'
@@ -643,7 +642,7 @@ export const ja = defineLocale({
loading: 'アーカイブ済みセッションを読み込み中…',
archivedTitle: 'アーカイブ済みセッション',
archivedIntro:
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
emptyArchivedTitle: 'アーカイブがありません',
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
unarchive: 'アーカイブを解除',
@@ -1812,7 +1811,7 @@ export const ja = defineLocale({
loadingQuestion: '質問を読み込み中…',
other: 'その他(回答を入力)',
placeholder: '回答を入力…',
shortcut: `${formatCombo('mod+enter')} で送信`,
shortcut: '⌘/Ctrl + Enter で送信',
back: '戻る',
skip: 'スキップ',
send: '送信'
+2 -3
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import { defineLocale } from './define-locale'
@@ -628,7 +627,7 @@ export const zhHant = defineLocale({
loading: '正在載入已封存工作階段…',
archivedTitle: '已封存工作階段',
archivedIntro:
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
emptyArchivedTitle: '暫無封存',
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
unarchive: '取消封存',
@@ -1773,7 +1772,7 @@ export const zhHant = defineLocale({
loadingQuestion: '正在載入問題…',
other: '其他(輸入您的答案)',
placeholder: '輸入您的答案…',
shortcut: `${formatCombo('mod+enter')} 傳送`,
shortcut: '⌘/Ctrl + Enter 傳送',
back: '返回',
skip: '略過',
send: '傳送'
+2 -3
View File
@@ -1,5 +1,4 @@
import { defineFieldCopy } from '@/app/settings/field-copy'
import { formatCombo } from '@/lib/keybinds/combo'
import type { Translations } from './types'
@@ -713,7 +712,7 @@ export const zh: Translations = {
sessions: {
loading: '正在加载已归档会话…',
archivedTitle: '已归档会话',
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
emptyArchivedTitle: '暂无归档',
emptyArchivedDesc: '归档一个对话后会显示在这里。',
unarchive: '取消归档',
@@ -1857,7 +1856,7 @@ export const zh: Translations = {
loadingQuestion: '正在加载问题…',
other: '其他 (输入你的答案)',
placeholder: '输入你的答案…',
shortcut: `${formatCombo('mod+enter')} 发送`,
shortcut: '⌘/Ctrl + Enter 发送',
back: '返回',
skip: '跳过',
send: '发送'
+11 -17
View File
@@ -5,9 +5,6 @@
// like navigate / theme); labels come from i18n (`t.keybinds.actions[id]`). To
// add a hotkey, add a row here and a handler there — nothing else.
import type { Combo, FakeCombo } from "./combo";
export type KeybindCategory = 'composer' | 'profiles' | 'session' | 'navigation' | 'view'
// The self-referential opener — bound + dispatched like any action, but shown in
@@ -30,16 +27,15 @@ export interface KeybindActionMeta {
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
export const PROFILE_SLOT_COUNT = 18
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => {
const slot = i+1
const combo = (slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`) as Combo
function comboForSlot(slot: number): string {
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
}
return ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [combo]
})
})
const PROFILE_SWITCH_ACTIONS: KeybindActionMeta[] = Array.from({ length: PROFILE_SLOT_COUNT }, (_, i) => ({
id: `profile.switch.${i + 1}`,
category: 'profiles' as const,
defaults: [comboForSlot(i + 1)]
}))
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
// `mod` keeps one binding cross-platform; on macOS this shadows the system
@@ -108,12 +104,10 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
return ACTION_BY_ID.get(id)
}
export type KeybindBindings = Record<string, Combo[]>
export type KeybindBindings = Record<string, string[]>
export function defaultBindings(): KeybindBindings {
return Object.fromEntries<string, Combo[]>(
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
)
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
}
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
@@ -123,7 +117,7 @@ export function defaultBindings(): KeybindBindings {
export interface KeybindReadonly {
id: string
category: KeybindCategory
keys: readonly FakeCombo[]
keys: readonly string[]
}
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
+56 -97
View File
@@ -10,13 +10,11 @@
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
// folds `ctrl` → `mod`.
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
// event.code → canonical base token. Letters/digits map to their lowercase
// character; everything else uses an explicit name so combos read cleanly.
const CODE_TO_KEY = {
const CODE_TO_KEY: Record<string, string> = {
Backquote: '`',
Backslash: '\\',
BracketLeft: '[',
@@ -37,50 +35,8 @@ const CODE_TO_KEY = {
ArrowDown: 'down',
ArrowLeft: 'left',
ArrowRight: 'right'
} as const satisfies Record<Capitalize<string>, Lowercase<string>>
type SpecialKey = typeof CODE_TO_KEY[keyof typeof CODE_TO_KEY]
type Alpha = 'a'|'b'|'c'|'d'|'e'|'f'|'g'|'h'|'i'|'j'|'k'|'l'|'m'
| 'n'|'o'|'p'|'q'|'r'|'s'|'t'|'u'|'v'|'w'|'x'|'y'|'z'
export type Digit = '0'|'1'|'2'|'3'|'4'|'5'|'6'|'7'|'8'|'9'
type FKey =
| 'f1' | 'f2' | 'f3' | 'f4' | 'f5' | 'f6'
| 'f7' | 'f8' | 'f9' | 'f10' | 'f11' | 'f12'
| 'f13' | 'f14' | 'f15' | 'f16' | 'f17' | 'f18'
| 'f19' | 'f20' | 'f21' | 'f22' | 'f23' | 'f24'
type BaseKey = Alpha | Digit | FKey | SpecialKey
// subset of https://developer.mozilla.org/en-US/docs/Web/API/UI_Events/Keyboard_event_code_values
type KeyCode = Uppercase<FKey> | `Digit${Digit}` | `Key${Uppercase<Alpha>}` | keyof typeof CODE_TO_KEY
function baseKeyFromCode(code: KeyCode): BaseKey | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase() as Alpha
}
if (code.startsWith('Digit')) {
return code.slice(5) as Digit
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest as Digit : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase() as FKey
}
return CODE_TO_KEY[code as keyof typeof CODE_TO_KEY] ?? null
}
const MODIFIER_CODES = new Set([
'AltLeft',
'AltRight',
@@ -92,20 +48,42 @@ const MODIFIER_CODES = new Set([
'ShiftRight'
])
function baseKeyFromCode(code: string): string | null {
if (code.startsWith('Key')) {
return code.slice(3).toLowerCase()
}
if (code.startsWith('Digit')) {
return code.slice(5)
}
if (code.startsWith('Numpad')) {
const rest = code.slice(6)
return /^[0-9]$/.test(rest) ? rest : null
}
if (code.startsWith('F') && /^F\d{1,2}$/.test(code)) {
return code.toLowerCase()
}
return CODE_TO_KEY[code] ?? null
}
// Returns the canonical combo for a keydown, or null while only modifiers are
// held (so capture mode keeps waiting for a real key).
export function comboFromEvent(event: KeyboardEvent): Combo | null {
export function comboFromEvent(event: KeyboardEvent): string | null {
if (MODIFIER_CODES.has(event.code)) {
return null
}
const base = baseKeyFromCode(event.code as KeyCode)
const base = baseKeyFromCode(event.code)
if (!base) {
return null
}
const parts: Combo[] = []
const parts: string[] = []
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
// Control IS the accelerator, so it folds into `mod`.
@@ -127,7 +105,7 @@ export function comboFromEvent(event: KeyboardEvent): Combo | null {
parts.push(base)
return parts.join('+') as Combo
return parts.join('+')
}
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
@@ -137,14 +115,7 @@ export function canonicalizeCombo(combo: string): string {
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
}
const MOD_LABELS = {
mod: IS_MAC ? '⌘' : 'Ctrl',
ctrl: IS_MAC ? '⌃' : 'Ctrl',
alt: IS_MAC ? '⌥' : 'Alt',
shift: IS_MAC ? '⇧' : 'Shift'
} as const
const FANCY_KEY_LABELS = {
const TOKEN_LABELS: Record<string, string> = {
enter: '↵',
escape: 'Esc',
backspace: '⌫',
@@ -153,47 +124,39 @@ const FANCY_KEY_LABELS = {
up: '↑',
down: '↓',
left: '←',
right: '→',
} as const
const TOKEN_LABELS: Record<string, string> = {
...MOD_LABELS,
...FANCY_KEY_LABELS
right: '→'
}
function labelForToken(token: string): string {
if (TOKEN_LABELS[token]) {
return TOKEN_LABELS[token]
function labelForBase(base: string): string {
if (TOKEN_LABELS[base]) {
return TOKEN_LABELS[base]
}
if (/^f\d{1,2}$/.test(token)) {
return token.toUpperCase()
if (/^f\d{1,2}$/.test(base)) {
return base.toUpperCase()
}
return token.length === 1 ? token.toUpperCase() : token
return base.length === 1 ? base.toUpperCase() : base
}
//
function labelForMod(mod: string): string {
if (mod === 'mod') {
return IS_MAC ? '⌘' : 'Ctrl'
}
type ModKey = keyof typeof MOD_LABELS
if (mod === 'ctrl') {
return IS_MAC ? '⌃' : 'Ctrl'
}
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
if (mod === 'alt') {
return IS_MAC ? '⌥' : 'Alt'
}
type ModPrefixedCombo<Suffix extends string> =
| `${ModPrefix}${Suffix}`
| ModKey
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
| 'ctrl+tab' | 'ctrl+shift+tab'
| `ctrl+${Digit}`
if (mod === 'shift') {
return IS_MAC ? '⇧' : 'Shift'
}
export type Combo = ModPrefixedCombo<BaseKey>
export type FakeCombo = ModPrefixedCombo<BaseKey | '@' | '?'>
// Human-readable keys, e.g. "mod+shift+k" returns ["⌘","⇧","K"] on macos, ["Ctrl","Shift","K"] elsewhere.
export function normalizeCombo(combo: Combo): string[] {
const parts = combo.split('+')
return parts.map(p => labelForToken(p.trim()))
return mod
}
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
@@ -202,18 +165,14 @@ export function comboTokens(combo: string): string[] {
const parts = combo.split('+')
const base = parts.pop() ?? ''
return [...parts.map(labelForToken), labelForToken(base)]
return [...parts.map(labelForMod), labelForBase(base)]
}
// Human-readable label, e.g. "mod+shift+k" returns "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: Combo): string {
return normalizeCombo(combo).join(IS_MAC ? '' : '+')
}
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
export function formatCombo(combo: string): string {
const tokens = comboTokens(combo)
// like `formatCombo` but allows any input like `@`
export function formatFakeCombo(combo: FakeCombo): string {
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
return IS_MAC ? tokens.join('') : tokens.join('+')
}
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
@@ -231,6 +190,6 @@ export function isEditableTarget(target: EventTarget | null): boolean {
// A primary modifier (Cmd/Ctrl/Control) fires even while typing (e.g. ⌘K or
// ⌃Tab from the composer); bare/Shift-only combos are suppressed in inputs.
export function comboAllowedInInput(combo: Combo): boolean {
export function comboAllowedInInput(combo: string): boolean {
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
}
+3 -4
View File
@@ -7,7 +7,6 @@ import {
type KeybindBindings
} from '@/lib/keybinds/actions'
import { canonicalizeCombo } from '@/lib/keybinds/combo'
import type { Combo } from '@/lib/keybinds/combo'
import { arraysEqual, persistString, storedString } from '@/lib/storage'
const STORAGE_KEY = 'hermes.desktop.keybinds'
@@ -29,7 +28,7 @@ function loadBindings(): KeybindBindings {
const value = parsed[id]
if (Array.isArray(value)) {
base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
}
}
} catch {
@@ -79,7 +78,7 @@ export const $comboIndex = computed($bindings, bindings => {
return index
})
export function setBinding(actionId: string, combos: Combo[]): void {
export function setBinding(actionId: string, combos: string[]): void {
if (!keybindAction(actionId)) {
return
}
@@ -102,7 +101,7 @@ export function resetAllBindings(): void {
}
// Other actions that already use `combo` (excluding `actionId` itself).
export function conflictsFor(actionId: string, combo: Combo): string[] {
export function conflictsFor(actionId: string, combo: string): string[] {
const bindings = $bindings.get()
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
+5
View File
@@ -186,6 +186,10 @@ export const $currentFastMode = atom(false)
export const $yoloActive = atom(false)
export const $currentCwd = atom(getRememberedWorkspaceCwd())
export const $currentBranch = atom('')
// When true, the next backend session is created inside a fresh git worktree of
// $currentCwd (set by the sidebar's "new session in a worktree" fork icon).
// Consumed and reset by the create-session path.
export const $pendingWorktree = atom(false)
export const $currentUsage = atom<UsageStats>({
calls: 0,
input: 0,
@@ -240,6 +244,7 @@ export const workspaceCwdForNewSession = (): string =>
getConfiguredDefaultProjectDir() || getRememberedWorkspaceCwd() || $currentCwd.get().trim()
export const setCurrentBranch = (next: Updater<string>) => updateAtom($currentBranch, next)
export const setPendingWorktree = (next: Updater<boolean>) => updateAtom($pendingWorktree, next)
export const setCurrentUsage = (next: Updater<UsageStats>) => updateAtom($currentUsage, next)
export const setSessionStartedAt = (next: Updater<number | null>) => updateAtom($sessionStartedAt, next)
export const setTurnStartedAt = (next: Updater<number | null>) => updateAtom($turnStartedAt, next)
+1 -24
View File
@@ -415,8 +415,7 @@ prompt_caching:
# Auxiliary Models (Advanced — Experimental)
# =============================================================================
# Hermes uses lightweight "auxiliary" models for side tasks: image analysis,
# browser screenshot analysis, web page summarization, TTS audio-tag insertion,
# and context compression.
# browser screenshot analysis, web page summarization, and context compression.
#
# By default these use Gemini Flash via OpenRouter or Nous Portal and are
# auto-detected from your credentials. You do NOT need to change anything
@@ -461,12 +460,6 @@ prompt_caching:
# provider: "auto"
# model: ""
#
# # Gemini 3.1 TTS hidden audio-tag insertion
# tts_audio_tags:
# provider: "auto" # empty model = your main chat model
# model: ""
# timeout: 30
#
# # Session search — summarizes matching past sessions
# session_search:
# provider: "auto"
@@ -842,22 +835,6 @@ platform_toolsets:
# max_tool_rounds: 5 # tool loop limit (0 = disable)
# log_level: "info" # audit verbosity
# =============================================================================
# Text-to-Speech
# =============================================================================
# TTS defaults to Edge TTS unless changed in ~/.hermes/config.yaml.
# Gemini TTS supports persona/director prompt files, and Gemini 3.1 Flash TTS
# can use a hidden auxiliary rewrite pass to insert expressive square-bracket
# audio tags into the TTS script without showing tags in chat.
#
# tts:
# provider: "gemini"
# gemini:
# model: "gemini-3.1-flash-tts-preview"
# voice: "Kore"
# audio_tags: false
# persona_prompt_file: "" # e.g. ~/.hermes/tts/radio-host.md
# =============================================================================
# Voice Transcription (Speech-to-Text)
# =============================================================================
+11 -83
View File
@@ -890,10 +890,6 @@ def _cleanup_all_browsers(*args, **kwargs):
# Guard to prevent cleanup from running multiple times on exit
_cleanup_done = False
# One-shot CLI finalization runs before process cleanup so plugins can observe
# the session boundary while the agent is still attached. If a signal lands in
# that narrow window, atexit cleanup must not emit that session finalize again.
_single_query_finalize_attempted_session_ids: set[str | None] = set()
# Weak reference to the active AIAgent for memory provider shutdown at exit
_active_agent_ref = None
_deferred_agent_startup_done = False
@@ -993,13 +989,11 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
# Shut down memory provider (on_session_end + shutdown_all) at actual
# session boundary — NOT per-turn inside run_conversation().
if notify_session_finalize:
cleanup_session_id = _active_agent_ref.session_id if _active_agent_ref else None
if _should_emit_cleanup_session_finalize(cleanup_session_id):
_notify_session_finalize(
session_id=cleanup_session_id,
platform="cli",
reason="shutdown",
)
_notify_session_finalize(
session_id=_active_agent_ref.session_id if _active_agent_ref else None,
platform="cli",
reason="shutdown",
)
try:
if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'):
# Forward the agent's own transcript so memory providers'
@@ -1017,14 +1011,6 @@ def _run_cleanup(*, notify_session_finalize: bool = True):
pass
def _should_emit_cleanup_session_finalize(session_id: str | None) -> bool:
if not _single_query_finalize_attempted_session_ids:
return True
if session_id is None:
return False
return session_id not in _single_query_finalize_attempted_session_ids
def _notify_session_finalize(
*,
session_id: str | None,
@@ -1082,17 +1068,11 @@ def _emit_interrupted_session_end(cli, *, reason: str = "keyboard_interrupt") ->
def _notify_single_query_session_finalize(cli, *, reason: str = "shutdown") -> None:
agent = getattr(cli, "agent", None)
session_id = getattr(agent, "session_id", None) or getattr(cli, "session_id", None)
if session_id in _single_query_finalize_attempted_session_ids:
return
try:
_notify_session_finalize(
session_id=session_id,
platform=getattr(agent, "platform", None) or "cli",
reason=reason,
)
finally:
_single_query_finalize_attempted_session_ids.add(session_id)
_notify_session_finalize(
session_id=session_id,
platform=getattr(agent, "platform", None) or "cli",
reason=reason,
)
def _finalize_single_query(cli) -> None:
@@ -6516,47 +6496,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
}
self._invalidate(min_interval=0.0)
def _confirm_expensive_model_switch(self, result) -> bool:
"""Ask for explicit confirmation before applying costly model switches."""
if not getattr(result, "success", False):
return True
try:
from hermes_cli.model_cost_guard import expensive_model_warning
warning = expensive_model_warning(
result.new_model,
provider=result.target_provider,
base_url=result.base_url or self.base_url or "",
api_key=result.api_key or self.api_key or "",
model_info=result.model_info,
)
except Exception:
warning = None
if warning is None:
return True
choices = [
("once", "Switch anyway", "Use this model for the current Hermes session."),
("cancel", "Cancel", "Keep the current model."),
]
raw = self._prompt_text_input_modal(
title="!!! Expensive Model Warning !!!",
detail=warning.message,
choices=choices,
timeout=120,
)
choice = self._normalize_slash_confirm_choice(raw, choices)
return choice == "once"
def _confirm_and_apply_model_switch_result(self, result, persist_global: bool) -> None:
try:
if result.success and not self._confirm_expensive_model_switch(result):
_cprint(" Model switch cancelled.")
return
self._apply_model_switch_result(result, persist_global)
except Exception as exc:
_cprint(f" ✗ Model selection failed: {exc}")
def _close_model_picker(self) -> None:
self._model_picker_state = None
self._restore_modal_input_snapshot()
@@ -6733,14 +6672,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
custom_providers=state.get("custom_provs"),
)
self._close_model_picker()
if getattr(self, "_app", None):
threading.Thread(
target=self._confirm_and_apply_model_switch_result,
args=(result, persist_global),
daemon=True,
).start()
else:
self._confirm_and_apply_model_switch_result(result, persist_global)
self._apply_model_switch_result(result, persist_global)
return
self._close_model_picker()
@@ -6841,10 +6773,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
_cprint(f"{result.error_message}")
return
if not self._confirm_expensive_model_switch(result):
_cprint(" Model switch cancelled.")
return
# Apply to CLI state.
# Update requested_provider so _ensure_runtime_credentials() doesn't
# overwrite the switch on the next turn (it re-resolves from this).
-8
View File
@@ -262,14 +262,6 @@ if [ -d "$HERMES_HOME/profiles" ]; then
chown -R hermes:hermes "$HERMES_HOME/profiles" 2>/dev/null || true
fi
# Always reset ownership of $HERMES_HOME/cron on every boot for the same
# docker-exec/root-write reason as profiles/. The cron scheduler state
# (jobs.json) must stay readable by the unprivileged hermes runtime even
# after root-context maintenance commands or scheduler writes.
if [ -d "$HERMES_HOME/cron" ]; then
chown -R hermes:hermes "$HERMES_HOME/cron" 2>/dev/null || true
fi
# Reset ownership of hermes-owned top-level state files on every boot.
# The targeted data-volume chown above only covers hermes-owned
# *subdirectories*; loose state files living directly under $HERMES_HOME
-8
View File
@@ -207,14 +207,6 @@ class GatewayAuthorizationMixin:
if platform_allow_all_var and os.getenv(platform_allow_all_var, "").lower() in {"true", "1", "yes"}:
return True
# Adapter-verified role auth: the Discord adapter already confirmed the
# user holds a role in DISCORD_ALLOWED_ROLES before dispatching the message.
# Compare with ``is True`` so the real bool field authorizes while a
# MagicMock source (test fixtures using ``object.__new__`` runners with
# mock sources) does not auto-truthy through this gate (see pitfall #13).
if getattr(source, "role_authorized", False) is True:
return True
if getattr(source, "is_bot", False):
allow_bots_var = platform_allow_bots_map.get(source.platform)
if allow_bots_var and os.getenv(allow_bots_var, "none").lower().strip() in {"mentions", "all"}:
+4 -29
View File
@@ -33,7 +33,6 @@ _AUDIO_EXTS = frozenset({'.ogg', '.opus', '.mp3', '.wav', '.m4a', '.flac'})
# delivered as a regular document.
_TELEGRAM_AUDIO_ATTACHMENT_EXTS = frozenset({'.mp3', '.m4a'})
_TELEGRAM_VOICE_EXTS = frozenset({'.ogg', '.opus'})
_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS = 30.0
def _platform_name(platform) -> str:
@@ -1804,18 +1803,6 @@ class BasePlatformAdapter(ABC):
# preview (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
# The command prefix users can always TYPE on this platform to reach
# Hermes commands. Default "/" (most platforms deliver "/approve" etc.
# as plain message text). Platforms where typing a leading "/" is
# intercepted or restricted by the client (Slack blocks native slash
# commands inside threads; Matrix clients reserve "/" for client-local
# commands) ship a "!" alias rewrite in their adapter and set this to
# "!" so user-facing instruction text ("Reply `!approve` ...") tells
# users the form that actually works everywhere. Capability flag —
# shared prompt builders read it via getattr(adapter,
# "typed_command_prefix", "/"); no per-platform branching at call sites.
typed_command_prefix: str = "/"
def __init__(self, config: PlatformConfig, platform: Platform):
self.config = config
self.platform = platform
@@ -4475,15 +4462,6 @@ class BasePlatformAdapter(ABC):
except Exception:
pass # Last resort — don't let error reporting crash the handler
finally:
# Stop typing before any deferred callback work. Post-delivery
# callbacks may perform platform I/O; a stuck callback must not
# leave the typing refresh task running indefinitely.
await _stop_typing_task()
try:
if hasattr(self, "stop_typing"):
await self.stop_typing(event.source.chat_id)
except Exception:
pass
# Fire any one-shot post-delivery callback registered for this
# session (e.g. deferred background-review notifications).
#
@@ -4511,12 +4489,11 @@ class BasePlatformAdapter(ABC):
try:
_post_result = _post_cb()
if inspect.isawaitable(_post_result):
await asyncio.wait_for(
_post_result,
timeout=_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS,
)
except (asyncio.TimeoutError, Exception):
await _post_result
except Exception:
pass
# Stop typing indicator
await _stop_typing_task()
# Also cancel any platform-level persistent typing tasks (e.g. Discord)
# that may have been recreated by _keep_typing after the last stop_typing()
try:
@@ -4674,7 +4651,6 @@ class BasePlatformAdapter(ABC):
guild_id: Optional[str] = None,
parent_chat_id: Optional[str] = None,
message_id: Optional[str] = None,
role_authorized: bool = False,
) -> SessionSource:
"""Helper to build a SessionSource for this platform."""
# Normalize empty topic to None
@@ -4695,7 +4671,6 @@ class BasePlatformAdapter(ABC):
guild_id=str(guild_id) if guild_id else None,
parent_chat_id=str(parent_chat_id) if parent_chat_id else None,
message_id=str(message_id) if message_id else None,
role_authorized=role_authorized,
)
@abstractmethod
+4 -9
View File
@@ -422,11 +422,6 @@ class MatrixAdapter(BasePlatformAdapter):
supports_code_blocks = True # Matrix renders fenced code blocks (HTML/markdown)
# 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.
@@ -1355,11 +1350,11 @@ class MatrixAdapter(BasePlatformAdapter):
"⚠️ **Dangerous command requires approval**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {description}\n\n"
"Reply `!approve` to execute, `!approve session` to approve this pattern for the session, "
"`!approve always` to approve permanently, or `!deny` to cancel.\n\n"
"Reply `/approve` to execute, `/approve session` to approve this pattern for the session, "
"`/approve always` to approve permanently, or `/deny` to cancel.\n\n"
"You can also click the reaction to approve:\n"
"✅ = approve\n"
"❎ = deny"
"✅ = /approve\n"
"❎ = /deny"
)
result = await self.send(chat_id, text, metadata=metadata)
+8 -25
View File
@@ -318,11 +318,6 @@ class SlackAdapter(BasePlatformAdapter):
MAX_MESSAGE_LENGTH = 39000 # Slack API allows 40,000 chars; leave margin
supports_code_blocks = True # Slack mrkdwn renders fenced code blocks
# Slack blocks typed native slash commands inside threads ("/approve is
# not supported in threads. Sorry!"). The adapter rewrites a leading
# "!" to "/" for known commands (see _handle_slack_message), so "!" is
# the prefix that works everywhere — instruction text must show it.
typed_command_prefix = "!"
def __init__(self, config: PlatformConfig):
super().__init__(config, Platform.SLACK)
@@ -2697,26 +2692,19 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
cmd_preview = command[:2900] + "..." if len(command) > 2900 else command
thread_ts = self._resolve_thread_ts(None, metadata)
# Slack hard-caps a section block's text at 3000 chars; an
# oversized block fails the whole send with ``invalid_blocks``
# and the gateway falls back to the plain-text prompt (no
# buttons). execute_code approvals embed the entire script in
# ``command``, so budget the preview against the fixed parts
# instead of a flat truncation that overflows once the header +
# reason are added.
header = ":warning: *Command Approval Required*\n"
reason = f"Reason: {description[:500]}"
budget = 3000 - len(header) - len(reason) - len("``````\n") - len("...")
cmd_preview = command[:budget] + "..." if len(command) > budget else command
blocks = [
{
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"{header}```{cmd_preview}```\n{reason}",
"text": (
f":warning: *Command Approval Required*\n"
f"```{cmd_preview}```\n"
f"Reason: {description}"
),
},
},
{
@@ -2784,13 +2772,8 @@ class SlackAdapter(BasePlatformAdapter):
return SendResult(success=False, error="Not connected")
try:
body = message[:2900] + "..." if len(message) > 2900 else message
thread_ts = self._resolve_thread_ts(None, metadata)
# Same 3000-char section-block cap as send_exec_approval: budget
# the body against the rendered title so the wrapper never pushes
# the block over the limit (overflow → invalid_blocks → no buttons).
_title = (title or "Confirm")[:150]
budget = 3000 - len(f"*{_title}*\n\n") - len("...")
body = message[:budget] + "..." if len(message) > budget else message
# Encode session_key and confirm_id into the button value so the
# callback handler can resolve without extra bookkeeping.
value = f"{session_key}|{confirm_id}"
@@ -2800,7 +2783,7 @@ class SlackAdapter(BasePlatformAdapter):
"type": "section",
"text": {
"type": "mrkdwn",
"text": f"*{_title}*\n\n{body}",
"text": f"*{title or 'Confirm'}*\n\n{body}",
},
},
{
+3 -86
View File
@@ -3030,7 +3030,7 @@ class TelegramAdapter(BasePlatformAdapter):
async def _handle_model_picker_callback(
self, query, data: str, chat_id: str
) -> None:
"""Handle model picker inline keyboard callbacks (mp:/mm:/mc:/mb:/mx:/mg:)."""
"""Handle model picker inline keyboard callbacks (mp:/mm:/mb:/mx:/mg:)."""
state = self._model_picker_state.get(chat_id)
if not state:
await query.answer(text="Picker expired — use /model again.")
@@ -3115,55 +3115,6 @@ class TelegramAdapter(BasePlatformAdapter):
)
await query.answer()
elif data.startswith("mc:"):
# --- Expensive model confirmed: perform the switch ---
try:
idx = int(data[3:])
except ValueError:
await query.answer(text="Invalid selection.")
return
model_list = state.get("model_list", [])
if idx < 0 or idx >= len(model_list):
await query.answer(text="Invalid model index.")
return
model_id = model_list[idx]
provider_slug = state.get("selected_provider", "")
callback = state.get("on_model_selected")
if not callback:
await query.answer(text="Picker expired.")
return
switch_failed = False
try:
result_text = await callback(chat_id, model_id, provider_slug)
except Exception as exc:
logger.error("Model picker switch failed: %s", exc)
result_text = f"Error switching model: {exc}"
switch_failed = True
try:
await query.edit_message_text(
text=self.format_message(result_text),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=None,
)
except Exception:
try:
await query.edit_message_text(
text=result_text,
parse_mode=None,
reply_markup=None,
)
except Exception:
pass
await query.answer(
text="Switch failed." if switch_failed else "Model switched!"
)
self._model_picker_state.pop(chat_id, None)
elif data.startswith("mm:"):
# --- Model selected: perform the switch ---
try:
@@ -3185,43 +3136,11 @@ class TelegramAdapter(BasePlatformAdapter):
await query.answer(text="Picker expired.")
return
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
warning = await asyncio.to_thread(
expensive_model_warning,
model_id,
provider=provider_slug,
)
except Exception:
warning = None
if warning is not None:
keyboard = InlineKeyboardMarkup([
[InlineKeyboardButton("Switch anyway", callback_data=f"mc:{idx}")],
[
InlineKeyboardButton("◀ Back", callback_data="mb"),
InlineKeyboardButton("✗ Cancel", callback_data="mx"),
],
])
await query.edit_message_text(
text=self.format_message(
f"⚠ *Expensive Model Warning*\n\n{warning.message}"
),
parse_mode=ParseMode.MARKDOWN_V2,
reply_markup=keyboard,
)
await query.answer(text="Confirm expensive model")
return
switch_failed = False
try:
result_text = await callback(chat_id, model_id, provider_slug)
except Exception as exc:
logger.error("Model picker switch failed: %s", exc)
result_text = f"Error switching model: {exc}"
switch_failed = True
# Edit message to show confirmation, remove buttons
try:
@@ -3240,9 +3159,7 @@ class TelegramAdapter(BasePlatformAdapter):
)
except Exception:
pass
await query.answer(
text="Switch failed." if switch_failed else "Model switched!"
)
await query.answer(text="Model switched!")
# Clean up state
self._model_picker_state.pop(chat_id, None)
@@ -3343,7 +3260,7 @@ class TelegramAdapter(BasePlatformAdapter):
query_user_name = getattr(query.from_user, "first_name", None)
# --- Model picker callbacks ---
if data.startswith(("mp:", "mpg:", "mm:", "mc:", "mb", "mx", "mg:")):
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
chat_id = str(query.message.chat_id) if query.message else None
if chat_id:
await self._handle_model_picker_callback(query, data, chat_id)
+9 -30
View File
@@ -6473,12 +6473,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_tool_approval_live = False
if _pending_confirm and not _tool_approval_live:
_raw_reply = (event.text or "").strip()
# Accept bang-prefixed replies (`!always`, `!cancel`) verbatim.
# Slack/Matrix instruction text shows the `!` prefix (typed `/`
# is blocked in Slack threads), but the adapters only rewrite
# `!<known-command>` — `always`/`cancel` are confirm keywords,
# not registered commands, so the `!` survives to here.
_norm_reply = _raw_reply.lstrip("!/").lower()
_cmd_reply = event.get_command()
_confirm_choice = None
if _cmd_reply in {"approve", "yes", "ok", "confirm"}:
@@ -6487,11 +6481,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
_confirm_choice = "always"
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
_confirm_choice = "cancel"
elif _norm_reply in {"approve", "approve once", "once"}:
elif _raw_reply.lower() in {"approve", "approve once", "once"}:
_confirm_choice = "once"
elif _norm_reply in {"always", "always approve"}:
elif _raw_reply.lower() in {"always", "always approve"}:
_confirm_choice = "always"
elif _norm_reply in {"cancel", "nevermind", "no"}:
elif _raw_reply.lower() in {"cancel", "nevermind", "no"}:
_confirm_choice = "cancel"
if _confirm_choice is not None:
_resolved = await _slash_confirm_mod.resolve(
@@ -7065,9 +7059,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
if canonical == "memory":
return await self._handle_memory_command(event)
if canonical == "skills":
return await self._handle_skills_command(event)
if canonical == "fast":
return await self._handle_fast_command(event)
@@ -9376,12 +9367,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
adapter._voice_input_callback = self._handle_voice_channel_input
if hasattr(adapter, "_on_voice_disconnect"):
adapter._on_voice_disconnect = self._handle_voice_timeout_cleanup
# Let the adapter's inactivity timer see the live voice-reply mode so it
# doesn't disconnect a deliberately text-only (/voice off) session.
if hasattr(adapter, "_voice_mode_getter"):
adapter._voice_mode_getter = lambda chat_id: self._voice_mode.get(
self._voice_key(Platform.DISCORD, str(chat_id)), "off"
)
try:
success = await adapter.join_voice_channel(voice_channel)
@@ -10637,7 +10622,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
return result
return result
_p = self._typed_command_prefix_for(event.source.platform)
prompt_message = (
f"⚠️ **Confirm /{command}**\n\n"
f"{detail}\n\n"
@@ -10645,7 +10629,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"• **Approve Once** — proceed this time only\n"
"• **Always Approve** — proceed and silence this prompt permanently\n"
"• **Cancel** — keep current conversation\n\n"
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
"_Text fallback: reply `/approve`, `/always`, or `/cancel`._"
)
return await self._request_slash_confirm(
event=event,
@@ -11040,12 +11024,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
logger.debug("Button-based update prompt failed: %s", btn_err)
if not sent_buttons:
default_hint = f" (default: {default})" if default else ""
_p = getattr(adapter, "typed_command_prefix", "/")
await adapter.send(
chat_id,
f"⚕ **Update needs your input:**\n\n"
f"{prompt_text}{default_hint}\n\n"
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), "
f"Reply `/approve` (yes) or `/deny` (no), "
f"or type your answer directly.",
metadata=metadata,
)
@@ -11555,7 +11538,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# when we successfully transcribed the audio — it's redundant.
_placeholder = "(The user sent a message with no text content)"
if user_text and user_text.strip() == _placeholder:
return prefix, successful_transcripts
return prefix
if user_text:
return f"{prefix}\n\n{user_text}", successful_transcripts
return prefix, successful_transcripts
@@ -14112,18 +14095,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
"Button-based approval failed, falling back to text: %s", _e
)
# Fallback: plain text approval prompt. Use the adapter's
# typed prefix so Slack/Matrix users are told the form they
# can actually type (`!approve`) — typed "/" is blocked in
# Slack threads and reserved by Matrix clients.
_p = getattr(_status_adapter, "typed_command_prefix", "/")
# Fallback: plain text approval prompt
cmd_preview = cmd[:200] + "..." if len(cmd) > 200 else cmd
msg = (
f"⚠️ **Dangerous command requires approval:**\n"
f"```\n{cmd_preview}\n```\n"
f"Reason: {desc}\n\n"
f"Reply `{_p}approve` to execute, `{_p}approve session` to approve this pattern "
f"for the session, `{_p}approve always` to approve permanently, or `{_p}deny` to cancel."
f"Reply `/approve` to execute, `/approve session` to approve this pattern "
f"for the session, `/approve always` to approve permanently, or `/deny` to cancel."
)
try:
_approval_send_fut = safe_schedule_threadsafe(
-1
View File
@@ -91,7 +91,6 @@ class SessionSource:
guild_id: Optional[str] = None # Discord guild / Slack workspace / Matrix server scope
parent_chat_id: Optional[str] = None # Parent channel when chat_id refers to a thread
message_id: Optional[str] = None # ID of the triggering message (for pin/reply/react)
role_authorized: bool = False # True when adapter granted access via role (not user ID)
@property
def description(self) -> str:
+141 -261
View File
@@ -47,19 +47,6 @@ logger = logging.getLogger("gateway.run")
class GatewaySlashCommandsMixin:
"""In-session slash-command handlers for GatewayRunner."""
def _typed_command_prefix_for(self, platform) -> str:
"""Return the prefix users can always type to reach Hermes commands.
Reads the adapter's ``typed_command_prefix`` capability flag
(default "/"). Slack and Matrix return "!" because typed "/"
commands are blocked in Slack threads / reserved by Matrix clients;
their adapters rewrite "!command" to "/command" on receive.
Instruction text built for those platforms must show the prefix
that actually works when typed.
"""
adapter = self.adapters.get(platform) if getattr(self, "adapters", None) else None
return getattr(adapter, "typed_command_prefix", "/") if adapter is not None else "/"
async def _handle_reset_command(self, event: MessageEvent) -> Union[str, EphemeralReply]:
"""Handle /new or /reset command."""
source = event.source
@@ -1159,198 +1146,149 @@ class GatewaySlashCommandsMixin:
if not result.success:
return t("gateway.model.error_prefix", error=result.error_message)
async def _finish_switch() -> str:
"""Apply the resolved switch (agent, session, config) and build the reply."""
# If there's a cached agent, update it in-place
cached_entry = None
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
if _cache_lock and _cache is not None:
with _cache_lock:
cached_entry = _cache.get(session_key)
# If there's a cached agent, update it in-place
cached_entry = None
_cache_lock = getattr(self, "_agent_cache_lock", None)
_cache = getattr(self, "_agent_cache", None)
if _cache_lock and _cache is not None:
with _cache_lock:
cached_entry = _cache.get(session_key)
if cached_entry and cached_entry[0] is not None:
try:
cached_entry[0].switch_model(
new_model=result.new_model,
new_provider=result.target_provider,
api_key=result.api_key,
base_url=result.base_url,
api_mode=result.api_mode,
)
except Exception as exc:
logger.warning("In-place model switch failed for cached agent: %s", exc)
# Persist the new model to the session DB so the dashboard
# shows the updated model (#34850).
_sess_db = getattr(self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
if not hasattr(self, "_pending_model_notes"):
self._pending_model_notes = {}
self._pending_model_notes[session_key] = (
f"[Note: model was just switched from {current_model} to {result.new_model} "
f"via {result.provider_label or result.target_provider}. "
f"Adjust your self-identification accordingly.]"
)
# Store session override so next agent creation uses the new model
self._session_model_overrides[session_key] = {
"model": result.new_model,
"provider": result.target_provider,
"api_key": result.api_key,
"base_url": result.base_url,
"api_mode": result.api_mode,
}
# Evict cached agent so the next turn creates a fresh agent from the
# override rather than relying on cache signature mismatch detection.
self._evict_cached_agent(session_key)
# Persist to config if --global
if persist_global:
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
else:
cfg = {}
# Coerce scalar/None ``model:`` into a dict before mutation —
# otherwise ``cfg.setdefault("model", {})`` returns the existing
# scalar and the next assignment raises
# ``TypeError: 'str' object does not support item assignment``.
# Reproduces when ``config.yaml`` has ``model: <name>`` (flat
# string) instead of the proper nested ``model: {default: ...}``.
raw_model = cfg.get("model")
if isinstance(raw_model, dict):
model_cfg = raw_model
elif isinstance(raw_model, str) and raw_model.strip():
model_cfg = {"default": raw_model.strip()}
cfg["model"] = model_cfg
else:
model_cfg = {}
cfg["model"] = model_cfg
model_cfg["default"] = result.new_model
model_cfg["provider"] = result.target_provider
if result.base_url:
model_cfg["base_url"] = result.base_url
from hermes_cli.config import save_config
save_config(cfg)
except Exception as e:
logger.warning("Failed to persist model switch: %s", e)
# Build confirmation message with full metadata
provider_label = result.provider_label or result.target_provider
lines = [t("gateway.model.switched", model=result.new_model)]
lines.append(t("gateway.model.provider_label", provider=provider_label))
# Context: always resolve via the provider-aware chain so Codex OAuth,
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
_sw2_config_ctx = None
if cached_entry and cached_entry[0] is not None:
try:
_sw2_cfg = _load_gateway_config()
_sw2_model_cfg = _sw2_cfg.get("model", {})
if isinstance(_sw2_model_cfg, dict):
_sw2_raw = _sw2_model_cfg.get("context_length")
if _sw2_raw is not None:
_sw2_config_ctx = int(_sw2_raw)
except Exception:
pass
ctx = resolve_display_context_length(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=mi,
custom_providers=custom_provs,
config_context_length=_sw2_config_ctx,
)
if ctx:
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
if mi:
if mi.max_output:
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
if mi.has_cost_data():
lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
cached_entry[0].switch_model(
new_model=result.new_model,
new_provider=result.target_provider,
api_key=result.api_key,
base_url=result.base_url,
api_mode=result.api_mode,
)
except Exception as exc:
logger.warning("In-place model switch failed for cached agent: %s", exc)
# Cache notice
cache_enabled = (
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
lines.append(t("gateway.model.prompt_caching_enabled"))
# Persist the new model to the session DB so the dashboard
# shows the updated model (#34850).
_sess_db = getattr(self, "_session_db", None)
if _sess_db is not None:
try:
_sess_entry = self.session_store.get_or_create_session(source)
_sess_db.update_session_model(
_sess_entry.session_id, result.new_model
)
except Exception as exc:
logger.debug(
"Failed to persist model switch to DB: %s", exc
)
if result.warning_message:
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
# Store a note to prepend to the next user message so the model
# knows about the switch (avoids system messages mid-history).
if not hasattr(self, "_pending_model_notes"):
self._pending_model_notes = {}
self._pending_model_notes[session_key] = (
f"[Note: model was just switched from {current_model} to {result.new_model} "
f"via {result.provider_label or result.target_provider}. "
f"Adjust your self-identification accordingly.]"
)
if persist_global:
lines.append(t("gateway.model.saved_global"))
else:
lines.append(t("gateway.model.session_only_hint"))
# Store session override so next agent creation uses the new model
self._session_model_overrides[session_key] = {
"model": result.new_model,
"provider": result.target_provider,
"api_key": result.api_key,
"base_url": result.base_url,
"api_mode": result.api_mode,
}
return "\n".join(lines)
# Evict cached agent so the next turn creates a fresh agent from the
# override rather than relying on cache signature mismatch detection.
self._evict_cached_agent(session_key)
# Expensive-model confirmation gate (typed /model <name> path).
# The pickers (Telegram/Discord inline keyboards, TUI, dashboard)
# already confirm via their own UI affordances; this covers the
# direct text command, which previously bypassed the guard.
# expensive_model_warning() may hit models.dev or a /models endpoint
# on a cache miss, so run it off the event loop.
_cost_warning = None
# Persist to config if --global
if persist_global:
try:
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
cfg = yaml.safe_load(f) or {}
else:
cfg = {}
# Coerce scalar/None ``model:`` into a dict before mutation —
# otherwise ``cfg.setdefault("model", {})`` returns the existing
# scalar and the next assignment raises
# ``TypeError: 'str' object does not support item assignment``.
# Reproduces when ``config.yaml`` has ``model: <name>`` (flat
# string) instead of the proper nested ``model: {default: ...}``.
raw_model = cfg.get("model")
if isinstance(raw_model, dict):
model_cfg = raw_model
elif isinstance(raw_model, str) and raw_model.strip():
model_cfg = {"default": raw_model.strip()}
cfg["model"] = model_cfg
else:
model_cfg = {}
cfg["model"] = model_cfg
model_cfg["default"] = result.new_model
model_cfg["provider"] = result.target_provider
if result.base_url:
model_cfg["base_url"] = result.base_url
from hermes_cli.config import save_config
save_config(cfg)
except Exception as e:
logger.warning("Failed to persist model switch: %s", e)
# Build confirmation message with full metadata
provider_label = result.provider_label or result.target_provider
lines = [t("gateway.model.switched", model=result.new_model)]
lines.append(t("gateway.model.provider_label", provider=provider_label))
# Context: always resolve via the provider-aware chain so Codex OAuth,
# Copilot, and Nous-enforced caps win over the raw models.dev entry.
mi = result.model_info
from hermes_cli.model_switch import resolve_display_context_length
_sw2_config_ctx = None
try:
from hermes_cli.model_cost_guard import expensive_model_warning
_cost_warning = await asyncio.to_thread(
expensive_model_warning,
result.new_model,
provider=result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=result.model_info,
)
_sw2_cfg = _load_gateway_config()
_sw2_model_cfg = _sw2_cfg.get("model", {})
if isinstance(_sw2_model_cfg, dict):
_sw2_raw = _sw2_model_cfg.get("context_length")
if _sw2_raw is not None:
_sw2_config_ctx = int(_sw2_raw)
except Exception:
_cost_warning = None
if _cost_warning is not None:
async def _on_cost_confirm(choice: str) -> str:
if choice == "cancel":
return (
f"🟡 Model switch cancelled. Current model unchanged "
f"({current_model or 'unknown'})."
)
# "once" and "always" both proceed — there is no persistent
# opt-out for the cost guard (each expensive switch should be
# an explicit decision).
return await _finish_switch()
pass
ctx = resolve_display_context_length(
result.new_model,
result.target_provider,
base_url=result.base_url or current_base_url or "",
api_key=result.api_key or current_api_key or "",
model_info=mi,
custom_providers=custom_provs,
config_context_length=_sw2_config_ctx,
)
if ctx:
lines.append(t("gateway.model.context_label", tokens=f"{ctx:,}"))
if mi:
if mi.max_output:
lines.append(t("gateway.model.max_output_label", tokens=f"{mi.max_output:,}"))
if mi.has_cost_data():
lines.append(t("gateway.model.cost_label", cost=mi.format_cost()))
lines.append(t("gateway.model.capabilities_label", capabilities=mi.format_capabilities()))
_p = self._typed_command_prefix_for(event.source.platform)
return await self._request_slash_confirm(
event=event,
command="model",
title="Expensive Model Warning",
message=(
f"⚠️ **Expensive Model Warning**\n\n{_cost_warning.message}\n\n"
f"_Text fallback: reply `{_p}approve` to switch or `{_p}cancel` to keep "
"the current model._"
),
handler=_on_cost_confirm,
)
# Cache notice
cache_enabled = (
(base_url_host_matches(result.base_url or "", "openrouter.ai") and "claude" in result.new_model.lower())
or result.api_mode == "anthropic_messages"
)
if cache_enabled:
lines.append(t("gateway.model.prompt_caching_enabled"))
return await _finish_switch()
if result.warning_message:
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
if persist_global:
lines.append(t("gateway.model.saved_global"))
else:
lines.append(t("gateway.model.session_only_hint"))
return "\n".join(lines)
async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
"""Handle /codex-runtime command in the gateway.
@@ -2017,12 +1955,12 @@ class GatewaySlashCommandsMixin:
return t("gateway.reasoning.set_session", effort=effort)
async def _handle_memory_command(self, event: MessageEvent) -> str:
"""Handle /memory — review pending memory writes + toggle the approval gate.
"""Handle /memory — review pending memory writes + set write mode.
Memory entries are small enough to review inline in a chat bubble, so
the full pending/approve/reject/approval flow works on every platform.
Gate changes persist to config.yaml and evict the cached agent so the
new setting takes effect on the next message.
the full pending/approve/reject/mode flow works on every platform.
Mode changes persist to config.yaml and evict the cached agent so the
new write_mode takes effect on the next message.
"""
from gateway.run import _hermes_home
from hermes_cli.write_approval_commands import handle_pending_subcommand
@@ -2034,15 +1972,15 @@ class GatewaySlashCommandsMixin:
session_key = self._session_key_for_source(event.source)
config_path = _hermes_home / "config.yaml"
def _set_approval(enabled: bool):
def _set_mode(mode: str):
import yaml
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
user_config.setdefault("memory", {})["write_approval"] = bool(enabled)
user_config.setdefault("memory", {})["write_mode"] = mode
atomic_yaml_write(config_path, user_config)
# New setting must take effect next message → drop cached agent.
# New write_mode must take effect next message → drop cached agent.
self._evict_cached_agent(session_key)
# Apply approved writes against a fresh on-disk store (the gateway has
@@ -2051,69 +1989,11 @@ class GatewaySlashCommandsMixin:
store.load_from_disk()
out = handle_pending_subcommand(
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval,
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_mode,
)
if out is None:
out = ("Unknown /memory subcommand. Use: pending, approve <id>, "
"reject <id>, approval <on|off>.")
return out
async def _handle_skills_command(self, event: MessageEvent) -> str:
"""Handle /skills on the gateway — pending skill-write review only.
The full skills hub (search/browse/install) stays CLI-only; this
handler covers the write-approval review surface (pending / approve /
reject / diff / approval) so a skill staged from a gateway session can
be reviewed from that same session. Gated by ``skills.write_approval``
via the CommandDef's ``gateway_config_gate``; also answers when staged
writes still exist after the gate was turned off (so they are never
stranded).
``diff`` output is truncated for chat bubbles the full diff lives in
the CLI (``/skills diff <id>``) and the pending JSON file.
"""
from gateway.run import _hermes_home
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
raw_args = event.get_command_args().strip()
args = raw_args.split() if raw_args else []
session_key = self._session_key_for_source(event.source)
config_path = _hermes_home / "config.yaml"
gate_on = wa.write_approval_enabled(wa.SKILLS)
wants_toggle = bool(args) and args[0].lower() in {"approval", "mode"}
if not gate_on and not wants_toggle and wa.pending_count(wa.SKILLS) == 0:
return ("Skill write approval is off (skills.write_approval). "
"Enable it with /skills approval on, then review staged "
"writes here with /skills pending.")
def _set_approval(enabled: bool):
import yaml
user_config = {}
if config_path.exists():
with open(config_path, encoding="utf-8") as f:
user_config = yaml.safe_load(f) or {}
user_config.setdefault("skills", {})["write_approval"] = bool(enabled)
atomic_yaml_write(config_path, user_config)
# New setting must take effect next message → drop cached agent.
self._evict_cached_agent(session_key)
out = handle_pending_subcommand(
wa.SKILLS, args, set_mode_fn=_set_approval,
)
if out is None:
return ("Unknown /skills subcommand on this platform. Use: pending, "
"approve <id>, reject <id>, diff <id>, approval <on|off>. "
"(Search/install are CLI-only.)")
# Chat bubbles can't hold a full skill diff — truncate and point at
# the real review surfaces.
if args and args[0].lower() == "diff" and len(out) > 3000:
pending_id = args[1] if len(args) > 1 else "<id>"
out = (out[:3000]
+ f"\n… (truncated — full diff: `/skills diff {pending_id}` "
f"on the CLI, or ~/.hermes/pending/skills/{pending_id}.json)")
"reject <id>, mode <on|off|approve>.")
return out
async def _handle_fast_command(self, event: MessageEvent) -> str:
+15 -60
View File
@@ -19,74 +19,29 @@ __release_date__ = "2026.6.5"
def _ensure_utf8():
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
Several environments select a legacy, non-UTF-8 encoding for the standard
streams:
- Windows services and terminals default to cp1252.
- Linux hosts with a latin-1 / C / POSIX locale (common on minimal Debian
installs and Raspberry Pi) select latin-1 or ASCII.
The CLI prints box-drawing characters () and the glyph in the setup
wizard, doctor, and status banners. Encoding those under a non-UTF-8 codec
raises an unhandled UnicodeEncodeError that crashes the command before it
can even start e.g. `hermes setup` on a fresh Pi.
This runs at import time so it protects every CLI subcommand, on any
platform. It re-wraps stdout/stderr as UTF-8 when their encoding is not
already UTF-8, preferring TextIOWrapper.reconfigure() so the existing
stream object is fixed in place (cached `sys.stdout` references keep
working) and falling back to reopening the file descriptor with
closefd=False (the CPython-recommended safe variant).
No-op when the streams are already UTF-8: a healthy UTF-8 system sees no
stream change and no environment mutation.
Note: this is intentionally the earliest, platform-agnostic guard.
hermes_cli/stdio.py::configure_windows_stdio() runs later from the entry
points and layers on the Windows-only extras (console code-page flip,
EDITOR default, PATH augmentation); its stream reconfiguration is a
harmless idempotent no-op once we have already repaired the streams here.
Windows services and terminals default to cp1252, which cannot encode
box-drawing characters used in CLI output. This causes unhandled
UnicodeEncodeError crashes on gateway startup.
"""
repaired = False
if sys.platform != "win32":
return
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
for stream_name in ("stdout", "stderr"):
stream = getattr(sys, stream_name, None)
if stream is None:
continue
try:
encoding = (getattr(stream, "encoding", "") or "").lower().replace("-", "")
if encoding == "utf8":
continue
# Preferred: reconfigure the existing TextIOWrapper in place. This
# preserves object identity so any code already holding a reference
# to the old sys.stdout benefits from the repair too.
reconfigure = getattr(stream, "reconfigure", None)
if callable(reconfigure):
reconfigure(encoding="utf-8", errors="replace")
repaired = True
continue
# Fallback: reopen the underlying file descriptor as UTF-8. Used
# for streams that don't expose reconfigure() (e.g. some wrapped
# or replaced streams). closefd=False keeps the original fd open.
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
errors="replace", buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
repaired = True
except (AttributeError, OSError, ValueError):
if getattr(stream, "encoding", "").lower().replace("-", "") != "utf8":
new_stream = open(
stream.fileno(), "w", encoding="utf-8",
buffering=1, closefd=False,
)
setattr(sys, stream_name, new_stream)
except (AttributeError, OSError):
pass
# Only nudge child processes toward UTF-8 when we actually detected a
# non-UTF-8 locale. On a healthy UTF-8 host children inherit UTF-8 from the
# locale already, so leave the environment untouched (minimal footprint).
if repaired:
os.environ.setdefault("PYTHONUTF8", "1")
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
_ensure_utf8()
+7 -91
View File
@@ -2665,23 +2665,12 @@ def _xai_wait_for_callback(
result: dict[str, Any],
*,
timeout_seconds: float = 180.0,
manual_paste_redirect_uri: Optional[str] = None,
) -> dict[str, Any]:
deadline = time.monotonic() + max(5.0, timeout_seconds)
if manual_paste_redirect_uri and sys.stdin.isatty():
print()
print("If xAI shows a Grok Build code instead of redirecting,")
print("paste that code here and press Enter.")
try:
while time.monotonic() < deadline:
if result["code"] or result["error"]:
return result
if manual_paste_redirect_uri:
raw_paste = _read_ready_stdin_line()
if raw_paste and raw_paste.strip():
pasted = _parse_pasted_callback(raw_paste)
pasted["_manual_paste"] = True
return pasted
time.sleep(0.1)
finally:
server.shutdown()
@@ -2705,21 +2694,6 @@ def _xai_wait_for_callback(
)
def _read_ready_stdin_line() -> Optional[str]:
"""Return one pending stdin line without blocking, if the terminal has one."""
try:
if not sys.stdin.isatty():
return None
import select
ready, _, _ = select.select([sys.stdin], [], [], 0)
if not ready:
return None
return sys.stdin.readline()
except Exception:
return None
def _spotify_token_payload_to_state(
token_payload: Dict[str, Any],
*,
@@ -6175,40 +6149,6 @@ def _reset_config_provider() -> Path:
return config_path
def _confirm_expensive_model_selection(
model_id: str,
*,
provider: str = "",
base_url: str = "",
api_key: str = "",
) -> bool:
"""Prompt before saving a model whose known pricing exceeds guardrails."""
try:
from hermes_cli.model_cost_guard import expensive_model_warning
warning = expensive_model_warning(
model_id,
provider=provider,
base_url=base_url,
api_key=api_key,
)
except Exception:
warning = None
if warning is None:
return True
print()
print("=" * 72)
print(warning.message)
print("=" * 72)
try:
response = input("Switch anyway? [y/N]: ").strip().lower()
except (KeyboardInterrupt, EOFError):
print()
return False
return response in {"y", "yes"}
def _prompt_model_selection(
model_ids: List[str],
current_model: str = "",
@@ -6216,9 +6156,6 @@ def _prompt_model_selection(
unavailable_models: Optional[List[str]] = None,
portal_url: str = "",
unavailable_message: str = "",
confirm_provider: str = "",
confirm_base_url: str = "",
confirm_api_key: str = "",
) -> Optional[str]:
"""Interactive model selection. Puts current_model first with a marker. Returns chosen model ID or None.
@@ -6232,18 +6169,6 @@ def _prompt_model_selection(
_unavailable = unavailable_models or []
def _confirmed_selection(mid: str) -> Optional[str]:
if not mid:
return None
if confirm_provider and not _confirm_expensive_model_selection(
mid,
provider=confirm_provider,
base_url=confirm_base_url,
api_key=confirm_api_key,
):
return None
return mid
# Reorder: current model first, then the rest (deduplicated)
ordered = []
if current_model and current_model in model_ids:
@@ -6359,13 +6284,13 @@ def _prompt_model_selection(
return None
print()
if idx < len(ordered):
return _confirmed_selection(ordered[idx])
return ordered[idx]
elif idx == len(ordered):
try:
custom = input("Enter model name: ").strip()
except (EOFError, KeyboardInterrupt):
return None
return _confirmed_selection(custom) if custom else None
return custom if custom else None
return None
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
pass
@@ -6397,10 +6322,10 @@ def _prompt_model_selection(
return None
idx = int(choice)
if 1 <= idx <= n:
return _confirmed_selection(ordered[idx - 1])
return ordered[idx - 1]
elif idx == n + 1:
custom = input("Enter model name: ").strip()
return _confirmed_selection(custom) if custom else None
return custom if custom else None
elif idx == n + 2:
return None
print(f"Please enter 1-{n + 2}")
@@ -6744,7 +6669,6 @@ def _xai_oauth_loopback_login(
authorization_endpoint = discovery["authorization_endpoint"]
token_endpoint = discovery["token_endpoint"]
allow_missing_state = False
if manual_paste:
# No HTTP listener — synthesize a redirect_uri matching what
# the server would have bound to so the authorize URL the user
@@ -6771,7 +6695,6 @@ def _xai_oauth_loopback_login(
print("Open this URL to authorize Hermes with xAI:")
print(authorize_url)
callback = _prompt_manual_callback_paste(redirect_uri)
allow_missing_state = True
else:
server, thread, callback_result, redirect_uri = _xai_start_callback_server()
try:
@@ -6811,7 +6734,6 @@ def _xai_oauth_loopback_login(
thread,
callback_result,
timeout_seconds=max(30.0, timeout_seconds * 9),
manual_paste_redirect_uri=redirect_uri,
)
except AuthError as exc:
if (
@@ -6828,7 +6750,6 @@ def _xai_oauth_loopback_login(
callback = _prompt_manual_callback_paste(redirect_uri)
if callback.get("code") is None and callback.get("error") is None:
raise exc
allow_missing_state = True
except Exception:
try:
server.shutdown()
@@ -6849,7 +6770,7 @@ def _xai_oauth_loopback_login(
code="xai_authorization_failed",
)
callback_state = callback.get("state")
# Manual bare-code paths: when a user pastes only the opaque
# Manual-paste bare-code path: when a user pastes only the opaque
# authorization code (no ``code=``/``state=`` query parameters),
# ``_parse_pasted_callback`` returns ``state=None``. xAI's consent
# page renders the code in-page rather than redirecting through the
@@ -6857,12 +6778,10 @@ def _xai_oauth_loopback_login(
# VPS, container consoles) the bare code is the only thing the user
# can obtain. PKCE (code_verifier) still binds the exchange to this
# client, so the local state-equality check is redundant on the
# bare-code paths — we substitute the locally generated state to keep
# bare-code path — we substitute the locally generated state to keep
# the rest of the validation chain (and the token exchange) unchanged.
# See #26923 (AccursedGalaxy comment, 2026-05-20).
if callback.get("_manual_paste"):
allow_missing_state = True
if callback_state is None and (manual_paste or allow_missing_state):
if callback_state is None and manual_paste:
callback_state = state
if callback_state != state:
raise AuthError(
@@ -7779,9 +7698,6 @@ def _login_nous(args, pconfig: ProviderConfig) -> None:
unavailable_models=unavailable_models,
portal_url=_portal,
unavailable_message=unavailable_message,
confirm_provider="nous",
confirm_base_url=inference_base_url,
confirm_api_key=runtime_key,
)
elif unavailable_models:
_url = (_portal or DEFAULT_NOUS_PORTAL_URL).rstrip("/")
+8 -8
View File
@@ -1306,12 +1306,12 @@ class CLICommandsMixin:
parts = cmd.strip().split()
args = parts[1:] if len(parts) > 1 else []
if args and args[0].lower() in {"pending", "approve", "apply", "reject",
"deny", "drop", "diff", "approval", "mode"}:
"deny", "drop", "diff", "mode"}:
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
out = handle_pending_subcommand(
wa.SKILLS, args,
set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled),
set_mode_fn=lambda m: self._save_write_mode("skills", m),
)
if out is not None:
print(out)
@@ -1320,7 +1320,7 @@ class CLICommandsMixin:
handle_skills_slash(cmd, ChatConsole())
def _handle_memory_command(self, cmd: str):
"""Handle /memory slash command — pending review + approval-gate toggle."""
"""Handle /memory slash command — pending review + write-mode control."""
from hermes_cli.write_approval_commands import handle_pending_subcommand
from tools import write_approval as wa
parts = cmd.strip().split()
@@ -1329,17 +1329,17 @@ class CLICommandsMixin:
out = handle_pending_subcommand(
wa.MEMORY, args,
memory_store=store,
set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled),
set_mode_fn=lambda m: self._save_write_mode("memory", m),
)
if out is None:
out = ("Unknown /memory subcommand. "
"Use: pending, approve <id>, reject <id>, approval <on|off>.")
"Use: pending, approve <id>, reject <id>, mode <on|off|approve>.")
print(out)
def _save_write_approval(self, subsystem: str, enabled: bool):
"""Persist <subsystem>.write_approval to config (for /memory|/skills approval)."""
def _save_write_mode(self, subsystem: str, mode: str):
"""Persist <subsystem>.write_mode to config (for /memory|/skills mode)."""
from cli import save_config_value
save_config_value(f"{subsystem}.write_approval", bool(enabled))
save_config_value(f"{subsystem}.write_mode", mode)
def _handle_background_command(self, cmd: str):
"""Handle /background <prompt> — run a prompt in a separate background session.
+4 -5
View File
@@ -167,13 +167,12 @@ COMMAND_REGISTRY: list[CommandDef] = [
cli_only=True),
CommandDef("skills", "Search, install, inspect, or manage skills",
"Tools & Skills", cli_only=True,
gateway_config_gate="skills.write_approval",
subcommands=("search", "browse", "inspect", "install", "audit",
"pending", "approve", "reject", "diff", "approval")),
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
"pending", "approve", "reject", "diff", "mode")),
CommandDef("memory", "Review pending memory writes / set write mode",
"Tools & Skills",
args_hint="[pending|approve|reject|approval] [id|on|off]",
subcommands=("pending", "approve", "reject", "approval")),
args_hint="[pending|approve|reject|mode] [id|on|off|approve]",
subcommands=("pending", "approve", "reject", "mode")),
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
"Tools & Skills"),
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
+26 -83
View File
@@ -1290,14 +1290,6 @@ DEFAULT_CONFIG = {
"timeout": 30,
"extra_body": {},
},
"tts_audio_tags": {
"provider": "auto",
"model": "",
"base_url": "",
"api_key": "",
"timeout": 30,
"extra_body": {},
},
# Triage specifier — flesh out a rough one-liner in the Kanban
# Triage column into a concrete spec, then promote it to ``todo``.
# Invoked by ``hermes kanban specify`` (single id or --all). Set a
@@ -1564,7 +1556,7 @@ DEFAULT_CONFIG = {
# Each provider supports an optional `max_text_length:` override for the
# per-request input-character cap. Omit it to use the provider's documented
# limit (OpenAI 4096, xAI 15000, MiniMax 10000, ElevenLabs 5k-40k model-aware,
# Gemini 32000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
# Gemini 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
"tts": {
"provider": "edge", # "edge" (free) | "elevenlabs" (premium) | "openai" | "xai" | "minimax" | "mistral" | "gemini" | "neutts" (local) | "kittentts" (local) | "piper" (local)
"edge": {
@@ -1580,19 +1572,6 @@ DEFAULT_CONFIG = {
"voice": "alloy",
# Voices: alloy, echo, fable, onyx, nova, shimmer
},
"gemini": {
"model": "gemini-2.5-flash-preview-tts",
"voice": "Kore",
# When true, Gemini 3.1 TTS uses a hidden auxiliary-model rewrite
# pass to insert freeform square-bracket audio tags into the TTS
# script. Visible chat replies are unchanged.
"audio_tags": False,
# Optional local Markdown/text file with Gemini TTS performance
# direction. It may include AUDIO PROFILE, SCENE, DIRECTOR'S NOTES,
# SAMPLE CONTEXT, and either a `{transcript}` placeholder or no
# transcript section; Hermes appends the live transcript when absent.
"persona_prompt_file": "",
},
"xai": {
"voice_id": "eve", # or custom voice ID — see https://docs.x.ai/developers/model-capabilities/audio/custom-voices
"language": "en",
@@ -1674,19 +1653,18 @@ DEFAULT_CONFIG = {
"memory": {
"memory_enabled": True,
"user_profile_enabled": True,
# Approval gate for memory writes (add/replace/remove), applied to BOTH
# Write gate for the memory tool (add/replace/remove), applied to BOTH
# foreground agent turns and the background self-improvement review fork
# (the source of unprompted "wrong assumption" saves users reported).
# false (default) — write freely; the gate is off (pre-gate behaviour)
# true — require approval: foreground writes prompt inline
# (entries are small enough to review in a chat
# bubble); background-review writes are staged
# instead of committed (a daemon thread cannot block
# on a prompt). Review staged entries with
# /memory pending, /memory approve <id>,
# /memory reject <id>.
# To disable memory entirely, use memory_enabled: false instead.
"write_approval": False,
# (the source of unprompted "wrong assumption" saves users reported):
# on — write freely (default, current behaviour)
# off — never write; the memory tool returns a clean disabled result
# approve — foreground writes block on an inline approve/deny prompt
# (entries are small enough to review in a chat bubble);
# background-review writes are staged for review instead of
# committed (a daemon thread cannot block on a prompt).
# Pending entries: /memory pending, /memory approve <id>,
# /memory reject <id>.
"write_mode": "on",
"memory_char_limit": 2200, # ~800 tokens at 2.75 chars/token
"user_char_limit": 1375, # ~500 tokens at 2.75 chars/token
# External memory provider plugin (empty = built-in only).
@@ -1791,18 +1769,17 @@ DEFAULT_CONFIG = {
# External hub installs (trusted/community sources) are always
# scanned regardless of this setting.
"guard_agent_created": False,
# Approval gate for skill_manage (create/edit/patch/write_file/delete/
# Write gate for skill_manage (create/edit/patch/write_file/delete/
# remove_file), applied to BOTH foreground agent turns and the
# background self-improvement review fork.
# false (default) — write freely; the gate is off (pre-gate behaviour)
# true — require approval: stage the write for review
# instead of committing (a SKILL.md is too large to
# review inline, so skills always stage rather than
# prompt). List with /skills pending, inspect with
# /skills diff <id> (full diff — CLI/dashboard/file,
# never crammed into a chat bubble), apply with
# /skills approve <id> or drop with /skills reject <id>.
"write_approval": False,
# background self-improvement review fork:
# on — write freely (default, current behaviour)
# off — never write; skill_manage returns a clean disabled result
# approve — stage the write for review instead of committing.
# Pending skills are listed with /skills pending, reviewed
# with /skills diff <id> (full diff — CLI/dashboard/file,
# never crammed into a chat bubble), and applied with
# /skills approve <id> or dropped with /skills reject <id>.
"write_mode": "on",
},
# Curator — background skill maintenance.
@@ -2486,7 +2463,7 @@ DEFAULT_CONFIG = {
# Config schema version - bump this when adding new required fields
"_config_version": 29,
"_config_version": 28,
}
# =============================================================================
@@ -4757,34 +4734,6 @@ def migrate_config(interactive: bool = True, quiet: bool = False) -> Dict[str, A
if not quiet:
print(" ✓ Lowered model_catalog.ttl_hours to 1 (hourly picker refresh)")
# ── Version 28 → 29: rename memory/skills write_mode → write_approval ──
# The tri-state write_mode (on|off|approve) was replaced by a clear boolean
# write_approval (default false = gate off, writes flow freely; true =
# require approval). Only an explicit "approve" carried gating intent, so
# it maps to true; everything else (on/off/unset) → false. The old
# "off = block all writes" mode is dropped — memory_enabled: false disables
# memory entirely. Only rewrite a key the user actually persisted; never
# invent one.
if current_ver < 29:
config = read_raw_config()
touched = False
for subsystem in ("memory", "skills"):
sub = config.get(subsystem)
if not isinstance(sub, dict) or "write_mode" not in sub:
continue
old = sub.pop("write_mode")
old_norm = old.strip().lower() if isinstance(old, str) else old
sub["write_approval"] = (old_norm == "approve")
config[subsystem] = sub
touched = True
results["config_added"].append(
f"{subsystem}.write_mode → write_approval={sub['write_approval']}"
)
if touched:
save_config(config)
if not quiet:
print(" ✓ Renamed write_mode → write_approval (boolean gate)")
if current_ver < latest_ver and not quiet:
print(f"Config version: {current_ver}{latest_ver}")
@@ -5770,21 +5719,19 @@ def save_env_value(key: str, value: str):
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
# Preserve the original file mode (e.g. 0640 for Docker volume mounts)
# instead of letting _secure_file unconditionally tighten to 0600.
# Restore original permissions before _secure_file may tighten them.
if original_mode is not None:
try:
os.chmod(env_path, original_mode)
except OSError:
pass
else:
_secure_file(env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_secure_file(env_path)
os.environ[key] = value
invalidate_env_cache()
@@ -5829,22 +5776,18 @@ def remove_env_value(key: str) -> bool:
f.flush()
os.fsync(f.fileno())
atomic_replace(tmp_path, env_path)
# Preserve the original file mode (e.g. 0640 for Docker volume
# mounts) instead of letting _secure_file unconditionally tighten
# to 0600. Mirrors save_env_value().
if original_mode is not None:
try:
os.chmod(env_path, original_mode)
except OSError:
pass
else:
_secure_file(env_path)
except BaseException:
try:
os.unlink(tmp_path)
except OSError:
pass
raise
_secure_file(env_path)
os.environ.pop(key, None)
invalidate_env_cache()
+4 -202
View File
@@ -499,7 +499,6 @@ from hermes_cli import __version__, __release_date__
# (god-file decomposition Phase 2). Re-imported here so select_provider_and_model and
# existing test monkeypatches (hermes_cli.main._model_flow_*) keep resolving unchanged.
from hermes_cli.model_setup_flows import (
_prompt_auth_credentials_choice,
_model_flow_openrouter,
_model_flow_nous,
_model_flow_openai_codex,
@@ -2831,12 +2830,7 @@ def select_provider_and_model(args=None):
member_labels = [
provider_labels.get(m, m) for m in selected_members
]
group_label = ordered[provider_idx][1].split("", 1)[0]
member_idx = _prompt_provider_choice(
member_labels,
default=member_default,
title=f"Select {group_label} provider:",
)
member_idx = _prompt_provider_choice(member_labels, default=member_default)
if member_idx is None:
print("No change.")
return
@@ -2980,7 +2974,6 @@ _AUX_TASKS: list[tuple[str, str, str]] = [
("approval", "Approval", "smart command approval"),
("mcp", "MCP", "MCP tool reasoning"),
("title_generation", "Title generation", "session titles"),
("tts_audio_tags", "TTS audio tags", "Gemini TTS tag insertion"),
("skills_hub", "Skills hub", "skills search/install"),
("triage_specifier", "Triage specifier", "kanban spec fleshing"),
("kanban_decomposer", "Kanban decomposer", "task decomposition"),
@@ -3270,7 +3263,6 @@ def _aux_flow_provider_model(
model_list,
current_model=current_model,
pricing=pricing,
confirm_provider=provider_slug,
)
if selected is None:
print("No change.")
@@ -3339,7 +3331,7 @@ def _aux_flow_custom_endpoint(task: str, task_cfg: dict) -> None:
print(f"{display_name}: custom ({short_url})" + (f" · {model}" if model else ""))
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
def _prompt_provider_choice(choices, *, default=0):
"""Show provider selection menu with curses arrow-key navigation.
Falls back to a numbered list when curses is unavailable (e.g. piped
@@ -3349,7 +3341,7 @@ def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
try:
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice(title, choices, default)
idx = _curses_prompt_choice("Select provider:", choices, default)
if idx >= 0:
print()
return idx
@@ -3357,7 +3349,7 @@ def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
pass
# Fallback: numbered list
print(title)
print("Select provider:")
for i, c in enumerate(choices, 1):
marker = "" if i - 1 == default else " "
print(f" {marker} {i}. {c}")
@@ -6393,167 +6385,6 @@ def _load_installable_optional_extras(group: str = "all") -> list[str]:
return referenced
# Install-scoped breadcrumb dropped right before ``hermes update`` mutates the
# venv and cleared only after the dependency install verifies clean. If a user
# kills the update mid-install (Ctrl-C, terminal close, WSL OOM), the marker
# survives and the next ``hermes`` launch finishes the install instead of
# limping along on a half-built venv (e.g. pip wiped, a core dep like Pillow
# never landed). Lives next to the venv (not under $HERMES_HOME) because the
# venv is shared across all profiles, so a single marker covers every profile.
def _update_marker_path() -> Path:
return PROJECT_ROOT / ".update-incomplete"
def _write_update_incomplete_marker() -> None:
"""Drop the interrupted-install breadcrumb. Never raises."""
try:
_update_marker_path().write_text(
f"started={_time.time()}\npid={os.getpid()}\n", encoding="utf-8"
)
except OSError as exc:
logger.debug("Could not write update-incomplete marker: %s", exc)
def _clear_update_incomplete_marker() -> None:
"""Remove the interrupted-install breadcrumb. Never raises."""
try:
_update_marker_path().unlink()
except FileNotFoundError:
pass
except OSError as exc:
logger.debug("Could not clear update-incomplete marker: %s", exc)
def _recover_from_interrupted_install() -> None:
"""Finish a dependency install that a prior ``hermes update`` left half-done.
Triggered on launch when ``.update-incomplete`` is present meaning the
code was pulled but the dep install was killed before it verified clean.
Unconditionally bootstraps pip via ``ensurepip`` (a killed ``pip install``
can wipe pip from the venv entirely, which blocks the venv from recovering
on its own), then re-runs the editable ``.[all]`` install + core-dependency
verification, then clears the marker.
Never raises: a recovery failure must not block launch. If it can't
self-heal it prints the one-line manual command and leaves the marker so
the next launch tries again.
Concurrency: the marker lives next to the shared venv, so a gateway start
plus a CLI launch (or two profiles starting at once) can both see it. An
``O_EXCL`` lockfile ensures only one process runs the reinstall; the
others skip and let the winner clear the marker.
Output: everything our status lines AND the streamed pip/uv install
(which inherits fd 1) is routed to stderr. Launches whose stdout is a
protocol stream (``hermes acp`` speaks JSON-RPC on stdout) must never get
install noise on stdout.
"""
if not _update_marker_path().exists():
return
# Skip in managed/Docker installs and on PyPI installs with no git checkout:
# those don't run the source-tree update path, so a stray marker is not ours
# to act on. Just clear it.
if not (PROJECT_ROOT / "pyproject.toml").is_file():
_clear_update_incomplete_marker()
return
# Single-flight guard: atomically claim the recovery lock. If another
# process holds it, skip — it is running the same reinstall into the same
# shared venv right now. A crashed holder leaves a stale lock; break it
# after an hour (well past any realistic install) so recovery can't be
# wedged forever.
lock_path = PROJECT_ROOT / ".update-incomplete.lock"
try:
fd = os.open(lock_path, os.O_CREAT | os.O_EXCL | os.O_WRONLY)
os.write(fd, f"{os.getpid()}\n".encode())
os.close(fd)
except FileExistsError:
try:
if _time.time() - lock_path.stat().st_mtime > 3600:
lock_path.unlink()
except OSError:
pass
return
except OSError as exc:
# Couldn't create the lock (read-only fs, perms). Proceed unlocked —
# the install itself will surface the real problem.
logger.debug("Could not create install-recovery lock: %s", exc)
saved_stdout_fd = None
saved_sys_stdout = sys.stdout
try:
# Route Python-level prints AND subprocess-inherited fd 1 to stderr
# for the duration of recovery (see docstring: ACP stdout safety).
try:
saved_stdout_fd = os.dup(1)
os.dup2(2, 1)
except OSError:
saved_stdout_fd = None
sys.stdout = sys.stderr
print(
"⚠ A previous `hermes update` was interrupted mid-install — "
"finishing dependency installation now..."
)
try:
from hermes_cli.managed_uv import ensure_uv
# Always bootstrap pip first: a killed install can leave the venv with
# no pip module at all, and uv may also be gone. ensurepip restores a
# known-good pip so at least the plain-pip path below can proceed.
try:
subprocess.run(
[sys.executable, "-m", "ensurepip", "--upgrade", "--default-pip"],
cwd=PROJECT_ROOT,
capture_output=True,
)
except Exception as exc:
logger.debug("ensurepip during install recovery failed: %s", exc)
uv_bin = ensure_uv()
if uv_bin:
uv_env = {**os.environ, "VIRTUAL_ENV": str(PROJECT_ROOT / "venv")}
if _is_termux_env(uv_env):
uv_env.pop("PYTHONPATH", None)
uv_env.pop("PYTHONHOME", None)
_install_python_dependencies_with_optional_fallback(
[uv_bin, "pip"],
env=uv_env,
group="termux-all" if _is_termux_env(uv_env) else "all",
)
else:
_install_python_dependencies_with_optional_fallback(
[sys.executable, "-m", "pip"],
group="termux-all" if _is_termux_env() else "all",
)
_clear_update_incomplete_marker()
print("✓ Dependency installation recovered — your install is healthy again.")
except Exception as exc:
# Leave the marker in place so the next launch retries. Give the user
# the exact manual recovery command in the meantime.
logger.debug("Interrupted-install recovery failed: %s", exc)
print("✗ Could not auto-recover the interrupted install.")
print(" Recover manually with:")
print(f" cd {PROJECT_ROOT}")
print(f" {sys.executable} -m ensurepip --upgrade")
print(f" {sys.executable} -m pip install -e '.[all]'")
finally:
sys.stdout = saved_sys_stdout
if saved_stdout_fd is not None:
try:
os.dup2(saved_stdout_fd, 1)
os.close(saved_stdout_fd)
except OSError:
pass
try:
lock_path.unlink()
except OSError:
pass
def _run_install_with_heartbeat(
cmd: list[str],
*,
@@ -8485,13 +8316,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
# Reinstall Python dependencies. Prefer .[all], but if one optional extra
# breaks on this machine, keep base deps and reinstall the remaining extras
# individually so update does not silently strip working capabilities.
#
# Drop the interrupted-install breadcrumb BEFORE touching the venv. If
# the install is killed mid-flight (Ctrl-C, terminal close, WSL OOM),
# the marker survives and the next ``hermes`` launch finishes the
# install via ``_recover_from_interrupted_install``. Cleared only after
# the install + core-dependency verification completes below.
_write_update_incomplete_marker()
print("→ Updating Python dependencies...")
from hermes_cli.managed_uv import ensure_uv, update_managed_uv
@@ -8545,12 +8369,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
_install_psutil_android_compat(pip_cmd)
_install_python_dependencies_with_optional_fallback(pip_cmd, group=install_group)
# Core Python deps installed AND verified (the fallback helper runs
# _verify_core_dependencies_installed). Clear the interrupted-install
# breadcrumb now — the remaining steps (lazy refresh, node deps, web
# UI, desktop rebuild) are non-core and can't brick the venv.
_clear_update_incomplete_marker()
_refresh_active_lazy_features()
_update_node_dependencies()
@@ -10865,22 +10683,6 @@ def main():
except Exception:
pass
# Self-heal a venv left half-built by an interrupted ``hermes update``
# (Ctrl-C, terminal close, WSL OOM mid-install). Skip when the user is
# *running* update — that flow writes and clears its own marker, and we
# don't want a recovery install racing the real one. Never raises.
#
# The substring match is deliberately loose: argv isn't parsed yet at this
# point, and the failure modes are asymmetric. Over-matching (e.g.
# ``hermes skills install update``) merely defers recovery one launch;
# under-matching (missing ``hermes -p work update``) would race a recovery
# install against the real one. Loose wins.
try:
if "update" not in sys.argv[1:]:
_recover_from_interrupted_install()
except Exception:
pass
if _try_termux_fast_tui_launch():
return
if _try_termux_fast_cli_launch():
-134
View File
@@ -1,134 +0,0 @@
"""Expensive-model confirmation helpers for model selection surfaces."""
from __future__ import annotations
from dataclasses import dataclass
from decimal import Decimal, InvalidOperation
from typing import Optional
from agent.models_dev import ModelInfo
INPUT_COST_WARNING_THRESHOLD = Decimal("20")
OUTPUT_COST_WARNING_THRESHOLD = Decimal("100")
GPT55_PRO_OPENROUTER_ID = "openai/gpt-5.5-pro"
GPT55_SUGGESTION = "did you mean to select openai/gpt-5.5?"
@dataclass(frozen=True)
class ExpensiveModelWarning:
"""Confirmation payload for models above Hermes' cost guardrail."""
model: str
provider: str
input_cost_per_million: Optional[Decimal]
output_cost_per_million: Optional[Decimal]
source: str
message: str
def _to_decimal(value: object) -> Optional[Decimal]:
if value is None:
return None
try:
return Decimal(str(value))
except (InvalidOperation, ValueError):
return None
def _format_money(value: Optional[Decimal]) -> str:
if value is None:
return "unknown"
return f"${value:.2f}/M"
def _pricing_from_model_info(
model_info: Optional[ModelInfo],
) -> tuple[Optional[Decimal], Optional[Decimal], str]:
if model_info is None or not model_info.has_cost_data():
return None, None, ""
return (
_to_decimal(model_info.cost_input),
_to_decimal(model_info.cost_output),
"models.dev",
)
def expensive_model_warning(
model_name: str,
*,
provider: Optional[str] = None,
base_url: Optional[str] = None,
api_key: Optional[str] = None,
model_info: Optional[ModelInfo] = None,
) -> Optional[ExpensiveModelWarning]:
"""Return a warning payload when known pricing exceeds safety thresholds.
The guard only triggers when pricing is known. Callers should use this after
model resolution so aliases and provider-specific model IDs have settled.
"""
model = (model_name or "").strip()
if not model:
return None
input_cost, output_cost, source = _pricing_from_model_info(model_info)
if input_cost is None and output_cost is None and provider:
try:
from agent.models_dev import get_model_info
input_cost, output_cost, source = _pricing_from_model_info(
get_model_info(provider, model)
)
except Exception:
pass
if input_cost is None and output_cost is None:
try:
from agent.usage_pricing import get_pricing_entry
entry = get_pricing_entry(
model,
provider=provider,
base_url=base_url,
api_key=api_key,
)
except Exception:
entry = None
if entry is not None:
input_cost = entry.input_cost_per_million
output_cost = entry.output_cost_per_million
source = entry.source
over_input = (
input_cost is not None and input_cost > INPUT_COST_WARNING_THRESHOLD
)
over_output = (
output_cost is not None and output_cost > OUTPUT_COST_WARNING_THRESHOLD
)
if not over_input and not over_output:
return None
lines = [
"!!! EXPENSIVE MODEL WARNING !!!",
"",
f"{model} has known pricing above Hermes' safety threshold.",
f"Input tokens: {_format_money(input_cost)}",
f"Output tokens: {_format_money(output_cost)}",
(
"Threshold: more than $20/M input tokens or more than "
"$100/M output tokens."
),
]
if source:
lines.append(f"Pricing source: {source}.")
if model.lower() == GPT55_PRO_OPENROUTER_ID:
lines.append(GPT55_SUGGESTION)
lines.append("Confirm only if you intend to use this model.")
return ExpensiveModelWarning(
model=model,
provider=(provider or "").strip(),
input_cost_per_million=input_cost,
output_cost_per_million=output_cost,
source=source or "unknown",
message="\n".join(lines),
)
+43 -131
View File
@@ -25,44 +25,6 @@ import os
import subprocess
def _prompt_auth_credentials_choice(title: str) -> str:
"""Prompt for reuse / reauthenticate / cancel with the standard radio UI.
Returns one of ``"use"``, ``"reauth"``, ``"cancel"``. Falls back to a
numbered prompt when curses is unavailable (piped stdin, non-TTY).
"""
choices = [
"Use existing credentials",
"Reauthenticate (new OAuth login)",
"Cancel",
]
try:
from hermes_cli.setup import _curses_prompt_choice
idx = _curses_prompt_choice(title, choices, 0)
if idx >= 0:
print()
return ("use", "reauth", "cancel")[idx]
except Exception:
pass
print(title)
for i, label in enumerate(choices, 1):
marker = "" if i == 1 else " "
print(f" {marker} {i}. {label}")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "2":
return "reauth"
if choice == "3":
return "cancel"
return "use"
def _model_flow_openrouter(config, current_model=""):
"""OpenRouter provider: ensure API key, then pick model."""
from hermes_cli.main import _prompt_api_key
@@ -102,12 +64,7 @@ def _model_flow_openrouter(config, current_model=""):
pricing = get_pricing_for_provider("openrouter", force_refresh=True)
selected = _prompt_model_selection(
openrouter_models,
current_model=current_model,
pricing=pricing,
confirm_provider="openrouter",
confirm_base_url=OPENROUTER_BASE_URL,
confirm_api_key=_resolved or existing_key,
openrouter_models, current_model=current_model, pricing=pricing
)
if selected:
_save_model_choice(selected)
@@ -316,9 +273,6 @@ def _model_flow_nous(config, current_model="", args=None):
unavailable_models=unavailable_models,
portal_url=_nous_portal_url,
unavailable_message=unavailable_message,
confirm_provider="nous",
confirm_base_url=creds.get("base_url", ""),
confirm_api_key=creds.get("api_key", ""),
)
if selected:
_save_model_choice(selected)
@@ -367,9 +321,16 @@ def _model_flow_openai_codex(config, current_model=""):
if status.get("logged_in"):
print(" OpenAI Codex credentials: ✓")
print()
choice = _prompt_auth_credentials_choice("OpenAI Codex credentials:")
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "reauth":
if choice == "2":
print("Starting a fresh OpenAI Codex login...")
print()
try:
@@ -389,7 +350,7 @@ def _model_flow_openai_codex(config, current_model=""):
if not status.get("logged_in"):
print("Login failed.")
return
elif choice == "cancel":
elif choice == "3":
return
else:
print("Not logged into OpenAI Codex. Starting login...")
@@ -424,13 +385,7 @@ def _model_flow_openai_codex(config, current_model=""):
codex_models = get_codex_model_ids(access_token=_codex_token)
selected = _prompt_model_selection(
codex_models,
current_model=current_model,
confirm_provider="openai-codex",
confirm_base_url=DEFAULT_CODEX_BASE_URL,
confirm_api_key=_codex_token or "",
)
selected = _prompt_model_selection(codex_models, current_model=current_model)
if selected:
_save_model_choice(selected)
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
@@ -456,11 +411,16 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
if status.get("logged_in"):
print(" xAI Grok OAuth (SuperGrok / Premium+) credentials: ✓")
print()
choice = _prompt_auth_credentials_choice(
"xAI Grok OAuth (SuperGrok / Premium+) credentials:"
)
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "reauth":
if choice == "2":
print("Starting a fresh xAI OAuth login...")
print()
try:
@@ -484,7 +444,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
except Exception as exc:
print(f"Login failed: {exc}")
return
elif choice == "cancel":
elif choice == "3":
return
else:
print("Not logged into xAI Grok OAuth (SuperGrok / Premium+). Starting login...")
@@ -560,12 +520,7 @@ def _model_flow_qwen_oauth(_config, current_model=""):
models = list(_DEFAULT_QWEN_PORTAL_MODELS)
default = current_model or (models[0] if models else "qwen3-coder-plus")
selected = _prompt_model_selection(
models,
current_model=default,
confirm_provider="qwen-oauth",
confirm_base_url=DEFAULT_QWEN_BASE_URL,
)
selected = _prompt_model_selection(models, current_model=default)
if selected:
_save_model_choice(selected)
_update_config_for_provider("qwen-oauth", DEFAULT_QWEN_BASE_URL)
@@ -614,12 +569,7 @@ def _model_flow_minimax_oauth(config, current_model="", args=None):
from hermes_cli.models import _PROVIDER_MODELS
model_ids = _PROVIDER_MODELS.get("minimax-oauth", [])
selected = _prompt_model_selection(
model_ids,
current_model,
confirm_provider="minimax-oauth",
confirm_base_url=creds["base_url"],
)
selected = _prompt_model_selection(model_ids, current_model)
if not selected:
return
_save_model_choice(selected)
@@ -688,12 +638,7 @@ def _model_flow_google_gemini_cli(_config, current_model=""):
models = list(_PROVIDER_MODELS.get("google-gemini-cli") or [])
default = current_model or (models[0] if models else "gemini-3-flash-preview")
selected = _prompt_model_selection(
models,
current_model=default,
confirm_provider="google-gemini-cli",
confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
)
selected = _prompt_model_selection(models, current_model=default)
if selected:
_save_model_choice(selected)
_update_config_for_provider(
@@ -1618,11 +1563,7 @@ def _model_flow_copilot(config, current_model=""):
if model_list:
selected = _prompt_model_selection(
model_list,
current_model=normalized_current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=api_key,
model_list, current_model=normalized_current_model
)
else:
try:
@@ -1760,9 +1701,6 @@ def _model_flow_copilot_acp(config, current_model=""):
selected = _prompt_model_selection(
model_list,
current_model=normalized_current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=catalog_api_key,
)
else:
try:
@@ -1867,13 +1805,7 @@ def _model_flow_kimi(config, current_model=""):
model_list = _PROVIDER_MODELS.get("moonshot", [])
if model_list:
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
selected = _prompt_model_selection(model_list, current_model=current_model)
else:
try:
selected = input("Enter model name: ").strip()
@@ -1981,13 +1913,7 @@ def _model_flow_stepfun(config, current_model=""):
)
if model_list:
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
selected = _prompt_model_selection(model_list, current_model=current_model)
else:
try:
selected = input("Model name: ").strip()
@@ -2063,13 +1989,7 @@ def _model_flow_bedrock_api_key(config, region, current_model=""):
print(f" Showing {len(model_list)} curated models")
if model_list:
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="custom",
confirm_base_url=mantle_base_url,
confirm_api_key=existing_key,
)
selected = _prompt_model_selection(model_list, current_model=current_model)
else:
try:
selected = input(" Model ID: ").strip()
@@ -2258,12 +2178,7 @@ def _model_flow_bedrock(config, current_model=""):
# 4. Model selection
if model_list:
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="bedrock",
confirm_base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
)
selected = _prompt_model_selection(model_list, current_model=current_model)
else:
try:
selected = input(" Model ID: ").strip()
@@ -2547,13 +2462,7 @@ def _model_flow_api_key_provider(config, provider_id, current_model=""):
model_list = list(dict.fromkeys(mid for mid in model_list if mid))
if model_list:
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider=provider_id,
confirm_base_url=effective_base,
confirm_api_key=existing_key,
)
selected = _prompt_model_selection(model_list, current_model=current_model)
else:
try:
selected = input("Model name: ").strip()
@@ -2651,13 +2560,20 @@ def _model_flow_anthropic(config, current_model=""):
elif cc_available:
print(" Claude Code credentials: ✓ (auto-detected)")
print()
choice = _prompt_auth_credentials_choice("Anthropic credentials:")
print(" 1. Use existing credentials")
print(" 2. Reauthenticate (new OAuth login)")
print(" 3. Cancel")
print()
try:
choice = input(" Choice [1/2/3]: ").strip()
except (KeyboardInterrupt, EOFError):
choice = "1"
if choice == "reauth":
if choice == "2":
needs_auth = True
elif choice == "cancel":
elif choice == "3":
return
# choice == "use" or default: use existing, proceed to model selection
# choice == "1" or default: use existing, proceed to model selection
if needs_auth:
# Show auth method choice
@@ -2703,11 +2619,7 @@ def _model_flow_anthropic(config, current_model=""):
# Model selection
model_list = _PROVIDER_MODELS.get("anthropic", [])
if model_list:
selected = _prompt_model_selection(
model_list,
current_model=current_model,
confirm_provider="anthropic",
)
selected = _prompt_model_selection(model_list, current_model=current_model)
else:
try:
selected = input("Model name (e.g., claude-sonnet-4-20250514): ").strip()
+1 -17
View File
@@ -351,29 +351,13 @@ def do_browse(page: int = 1, page_size: int = 20, source: str = "all",
"lobehub": 500, "browse-sh": 500,
}
with c.status("[bold]Fetching skills from registries...") as status:
# Live progress: tick off each source as it resolves so the wait is
# visible instead of a frozen spinner. parallel_search_sources invokes
# this callback from the collecting thread as each source completes;
# the page itself is still rendered once, after the correctly-merged
# and trust-sorted result set is final (browse's ordering contract is
# computed over the whole set, so we never render a half-sorted page).
_done: List[str] = []
def _on_source_done(sid: str, count: int) -> None:
_done.append(f"{sid} ({count})")
status.update(
"[bold]Fetching skills from registries...[/] "
f"[dim]done: {', '.join(_done)}[/]"
)
with c.status("[bold]Fetching skills from registries..."):
all_results, source_counts, timed_out = parallel_search_sources(
sources,
query="",
per_source_limits=_PER_SOURCE_LIMIT,
source_filter=source,
overall_timeout=30,
on_source_done=_on_source_done,
)
if not all_results:
+78 -100
View File
@@ -246,31 +246,7 @@ def _has_valid_session_token(request: Request) -> bool:
def _require_token(request: Request) -> None:
"""Authorize a sensitive endpoint, raising 401 if the caller isn't allowed.
Two auth schemes protect the dashboard, exactly one active per bind:
* **Loopback / ``--insecure`` mode** (``auth_required`` False): the
ephemeral ``_SESSION_TOKEN`` is injected into the SPA HTML and echoed
back via ``X-Hermes-Session-Token`` (or the legacy ``Bearer`` header).
Validate it here.
* **Gated / OAuth mode** (``auth_required`` True): ``_SESSION_TOKEN`` is
NOT injected (the SPA authenticates with a session cookie), so there is
no token to check. The ``gated_auth_middleware`` has already verified the
cookie before the request reached this handler any non-public ``/api/``
route it lets through carries a verified ``request.state.session``. The
legacy ``auth_middleware`` likewise short-circuits in this mode. Requiring
the (absent) token here would 401 every cookie-authenticated request,
making plugin install/enable/disable and the other ``_require_token``
endpoints permanently unreachable behind the gate. Defer to the gate.
"""
if getattr(request.app.state, "auth_required", False):
# Gate is authoritative. It attaches ``request.state.session`` on
# success and 401s otherwise, so a request that reached us is already
# authenticated. Belt-and-braces: confirm the session is present.
if getattr(request.state, "session", None) is not None:
return
raise HTTPException(status_code=401, detail="Unauthorized")
"""Validate the ephemeral session token. Raises 401 on mismatch."""
if not _has_valid_session_token(request):
raise HTTPException(status_code=401, detail="Unauthorized")
@@ -657,6 +633,21 @@ class AudioTranscriptionRequest(BaseModel):
mime_type: Optional[str] = None
class ModelAssignment(BaseModel):
"""Payload for POST /api/model/set — assign a provider/model to a slot.
scope="main" writes model.provider + model.default
scope="auxiliary" writes auxiliary.<task>.provider + auxiliary.<task>.model
scope="auxiliary" with task="" applied to every auxiliary.* slot
scope="auxiliary" with task="__reset__" resets every slot to provider="auto"
"""
scope: str
provider: str
model: str
task: str = ""
_AUDIO_MIME_EXTENSIONS: Dict[str, str] = {
"audio/aac": ".aac",
"audio/flac": ".flac",
@@ -698,7 +689,6 @@ class ModelAssignment(BaseModel):
# reads model.base_url from config (it ignores OPENAI_BASE_URL), so this is
# the path that actually wires a local endpoint into resolution.
base_url: str = ""
confirm_expensive_model: bool = False
def _apply_main_model_assignment(
@@ -1372,28 +1362,11 @@ def _tail_lines(path: Path, n: int) -> List[str]:
return lines[-n:] if n > 0 else lines
def _spawn_gateway_restart() -> Tuple[subprocess.Popen, bool]:
"""Spawn ``hermes gateway restart``, reusing an in-flight restart.
Multiple dashboard paths can request a restart in quick succession
(restart button double-click, or a stale cached frontend firing its own
restart after the server already auto-restarted post-onboarding). Two
concurrent ``hermes gateway restart`` children race each other on the
manual kill-and-start path, so reuse the live one instead.
Returns ``(proc, reused)``.
"""
existing = _ACTION_PROCS.get("gateway-restart")
if existing is not None and existing.poll() is None:
return existing, True
return _spawn_hermes_action(["gateway", "restart"], "gateway-restart"), False
@app.post("/api/gateway/restart")
async def restart_gateway():
"""Kick off a ``hermes gateway restart`` in the background."""
try:
proc, _reused = _spawn_gateway_restart()
proc = _spawn_hermes_action(["gateway", "restart"], "gateway-restart")
except Exception as exc:
_log.exception("Failed to spawn gateway restart")
raise HTTPException(status_code=500, detail=f"Failed to restart gateway: {exc}")
@@ -2473,30 +2446,6 @@ async def set_model_assignment(body: ModelAssignment):
try:
cfg = load_config()
if model and not body.confirm_expensive_model:
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
warning = await asyncio.to_thread(
expensive_model_warning,
model,
provider=provider,
base_url=base_url,
)
except Exception:
warning = None
if warning is not None:
return {
"ok": False,
"scope": scope,
"provider": provider,
"model": model,
"confirm_required": True,
"confirm_message": warning.message,
}
if scope == "main":
if not provider or not model:
raise HTTPException(status_code=400, detail="provider and model required for main")
@@ -3765,34 +3714,6 @@ async def get_telegram_onboarding_status(pairing_id: str):
)
def _restart_gateway_after_telegram_onboarding() -> dict[str, Any]:
"""Best-effort gateway restart after saving Telegram QR onboarding.
The QR flow naturally pulls users into Telegram on another device. If the
saved token waits on a separate dashboard restart click, Hermes appears
broken from the chat side. Keep the config save authoritative, but report
restart failures so the UI can fall back to the existing manual banner.
"""
try:
proc, reused = _spawn_gateway_restart()
except Exception as exc:
_log.exception("Failed to auto-restart gateway after Telegram onboarding")
return {
"restart_started": False,
"restart_error": str(exc),
}
if reused:
_log.info(
"Telegram onboarding: reusing in-flight gateway restart (pid %s)",
proc.pid,
)
return {
"restart_started": True,
"restart_action": "gateway-restart",
"restart_pid": proc.pid,
}
@app.post("/api/messaging/telegram/onboarding/{pairing_id}/apply")
async def apply_telegram_onboarding(
pairing_id: str, body: TelegramOnboardingApply
@@ -3847,14 +3768,11 @@ async def apply_telegram_onboarding(
with _telegram_onboarding_lock:
_telegram_onboarding_pairings.pop(pairing_id, None)
restart_result = _restart_gateway_after_telegram_onboarding()
return {
"ok": True,
"platform": "telegram",
"bot_username": bot_username,
"needs_restart": not restart_result["restart_started"],
**restart_result,
"needs_restart": True,
}
@@ -5639,6 +5557,58 @@ class SessionRename(BaseModel):
profile: Optional[str] = None
def _cleanup_session_worktree(db, sid: str) -> Optional[bool]:
"""Remove the git worktree owned by a session, honoring the unpushed guard.
Returns:
* ``None`` the session had no worktree (nothing to do).
* ``False`` the worktree was removed.
* ``True`` the worktree was *preserved* because its branch has commits
not reachable from any remote (the unpushed-commits guard in
``_cleanup_worktree`` declined to delete it).
On removal, the worktree mapping is cleared from the session row so a repeat
archive is a no-op. Best-effort: never raises into the request handler.
"""
try:
row = db.get_session(sid)
except Exception:
return None
if not row:
return None
wt_path = row.get("worktree_path")
if not wt_path:
return None
info = {
"path": wt_path,
"branch": row.get("worktree_branch"),
"repo_root": row.get("worktree_repo_root"),
}
try:
from cli import _cleanup_worktree
except Exception:
_log.debug("worktree cleanup helper unavailable", exc_info=True)
return None
try:
_cleanup_worktree(info)
except Exception:
_log.debug("worktree cleanup failed for session %s", sid, exc_info=True)
return None
# _cleanup_worktree preserves worktrees with unpushed commits. Probe the
# filesystem to learn what actually happened: gone → removed (clear the
# mapping); still present → preserved (keep the mapping so we can retry).
still_present = os.path.isdir(wt_path)
if not still_present:
try:
db.set_session_worktree(sid, None)
except Exception:
_log.debug("failed to clear worktree mapping for %s", sid, exc_info=True)
return still_present
@app.patch("/api/sessions/{session_id}")
async def rename_session_endpoint(session_id: str, body: SessionRename):
"""Update a session: rename (or clear its title) and/or archive it.
@@ -5668,6 +5638,14 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
result = {"ok": True, "title": db.get_session_title(sid) or ""}
if body.archived is not None:
result["archived"] = bool(body.archived)
# Archiving a session reclaims its git worktree (created via the
# desktop "new session in a worktree" fork icon). _cleanup_worktree
# keeps the unpushed-commits guard, so a worktree whose branch has
# commits not on any remote is preserved rather than destroyed.
if body.archived:
preserved = _cleanup_session_worktree(db, sid)
if preserved is not None:
result["worktree_preserved"] = preserved
return result
finally:
db.close()
+21 -33
View File
@@ -20,10 +20,7 @@ from typing import List, Optional
from tools import write_approval as wa
def _fmt_state(subsystem: str) -> str:
on = wa.write_approval_enabled(subsystem)
return f"{subsystem}.write_approval = {'on' if on else 'off'}"
_VALID_MODES = (wa.MODE_ON, wa.MODE_OFF, wa.MODE_APPROVE)
# ---------------------------------------------------------------------------
@@ -66,17 +63,18 @@ def handle_pending_subcommand(
memory_store: live MemoryStore for applying approved memory writes
(CLI passes ``self.agent._memory_store``; gateway applies against a
freshly loaded store).
set_mode_fn: optional callable ``(enabled: bool) -> None`` that
persists the new write_approval boolean to config (gateway provides
this; CLI uses its own ``save_config_value`` and passes a closure).
set_mode_fn: optional callable ``(mode: str) -> None`` that persists the
new write_mode to config (gateway provides this; CLI uses its own
``save_config_value`` and passes a closure).
Returns a text string to show the user. Returns None when the args are not
a write-approval subcommand (caller falls through to its other handling,
e.g. /skills search).
"""
if not args:
# Bare /memory or /skills with no sub → show pending + gate state.
return f"{_fmt_state(subsystem)}\n\n" + _fmt_pending_list(subsystem)
# Bare /memory or /skills with no sub → show pending + current mode.
mode = wa.get_write_mode(subsystem)
return f"{subsystem}.write_mode = {mode}\n\n" + _fmt_pending_list(subsystem)
sub = args[0].lower()
rest = args[1:]
@@ -93,8 +91,8 @@ def handle_pending_subcommand(
if sub == "diff" and subsystem == wa.SKILLS:
return _diff(rest)
if sub in {"approval", "mode"}: # 'mode' kept as a back-compat alias
return _set_approval(subsystem, rest, set_mode_fn)
if sub == "mode":
return _set_mode(subsystem, rest, set_mode_fn)
return None # not ours — caller handles
@@ -181,29 +179,19 @@ def _diff(rest: List[str]) -> str:
return header + "\n" + diff
def _set_approval(subsystem: str, rest: List[str], set_mode_fn) -> str:
"""Turn the approval gate on/off for a subsystem.
``set_mode_fn`` (when provided) persists the new boolean to config.
"""
def _set_mode(subsystem: str, rest: List[str], set_mode_fn) -> str:
if not rest:
return (f"{_fmt_state(subsystem)}\n"
f"Set with: /{subsystem} approval <on|off>")
arg = rest[0].strip().lower()
truthy = {"on", "true", "yes", "1", "enable", "enabled"}
falsey = {"off", "false", "no", "0", "disable", "disabled"}
if arg in truthy:
enabled = True
elif arg in falsey:
enabled = False
else:
return f"Invalid value '{arg}'. Use: on or off."
cur = wa.get_write_mode(subsystem)
return (f"{subsystem}.write_mode = {cur}\n"
f"Set with: /{subsystem} mode <on|off|approve>")
mode = rest[0].lower()
if mode not in _VALID_MODES:
return f"Invalid mode '{mode}'. Use: on, off, approve."
if set_mode_fn is None:
val = "true" if enabled else "false"
return (f"To change the {subsystem} approval gate, run:\n"
f" hermes config set {subsystem}.write_approval {val}")
return (f"To change {subsystem} write mode, run:\n"
f" hermes config set {subsystem}.write_mode {mode}")
try:
set_mode_fn(enabled)
set_mode_fn(mode)
except Exception as e:
return f"Failed to set {subsystem}.write_approval: {e}"
return f"{subsystem}.write_approval set to '{'on' if enabled else 'off'}'."
return f"Failed to set {subsystem}.write_mode: {e}"
return f"{subsystem}.write_mode set to '{mode}'."
+30
View File
@@ -471,6 +471,9 @@ CREATE TABLE IF NOT EXISTS sessions (
handoff_error TEXT,
rewind_count INTEGER NOT NULL DEFAULT 0,
archived INTEGER NOT NULL DEFAULT 0,
worktree_path TEXT,
worktree_branch TEXT,
worktree_repo_root TEXT,
FOREIGN KEY (parent_session_id) REFERENCES sessions(id)
);
@@ -1727,6 +1730,33 @@ class SessionDB:
rowcount = self._execute_write(_do)
return rowcount > 0
def set_session_worktree(
self,
session_id: str,
worktree_path: Optional[str],
worktree_branch: Optional[str] = None,
worktree_repo_root: Optional[str] = None,
) -> bool:
"""Record (or clear) the git worktree owned by a session.
Desktop "new session in a worktree" creates a throwaway git worktree and
runs the session inside it. We stamp the worktree path/branch/repo-root
on the row so the archive handler can find and remove the worktree later
even across a backend restart, when the in-memory session map is gone.
Pass ``worktree_path=None`` to clear the mapping (e.g. after cleanup).
Returns True when a row was updated.
"""
def _do(conn):
cursor = conn.execute(
"UPDATE sessions SET worktree_path = ?, worktree_branch = ?, "
"worktree_repo_root = ? WHERE id = ?",
(worktree_path, worktree_branch, worktree_repo_root, session_id),
)
return cursor.rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
def get_session_by_title(self, title: str) -> Optional[Dict[str, Any]]:
"""Look up a session by exact title. Returns session dict or None."""
with self._lock:
+1
View File
@@ -0,0 +1 @@
/home/ben/nous/hermes-agent/.worktrees/hermes-e14f8918/node_modules
+3 -23
View File
@@ -116,8 +116,6 @@ class OpenRouterProfile(ProviderProfile):
the same backend server across turns.
"""
extra_body: dict[str, Any] = {}
top_level: dict[str, Any] = {}
extra_headers: dict[str, Any] = {}
if supports_reasoning:
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
# future named models) use *adaptive* thinking: the model decides
@@ -134,36 +132,18 @@ class OpenRouterProfile(ProviderProfile):
# The only reliable behavior is to omit ``reasoning`` and let the
# model default to adaptive. See hermes-agent#42991 (disable case)
# and the tool-replay follow-up.
#
# ``reasoning.effort`` being ignored does NOT mean these models have
# no effort lever — OpenRouter honors the requested effort on the
# top-level ``verbosity`` field instead (it maps to Anthropic's
# ``output_config.effort``; ``reasoning.effort`` is accepted but
# ignored — confirmed by OpenRouter's Claude migration docs and a
# live token-spend probe in hermes-agent#43432). Route the existing
# ``reasoning_config["effort"]`` (sourced from
# ``agent.reasoning_effort``) onto ``verbosity`` so the knob the user
# already sets keeps working for these models. We still send NO
# ``reasoning`` field, preserving the #42991 400 fix.
if _anthropic_reasoning_is_mandatory(model):
cfg = reasoning_config or {}
effort = cfg.get("effort")
# Only emit when effort is actually requested and reasoning
# isn't explicitly disabled. Otherwise omit ``verbosity`` so the
# model keeps its own adaptive default (``high``).
if cfg.get("enabled", True) is not False and effort and effort != "none":
top_level["verbosity"] = effort
pass # omit reasoning entirely → adaptive default
elif reasoning_config is not None:
extra_body["reasoning"] = dict(reasoning_config)
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
extra_headers: dict[str, Any] = {}
if session_id and model and model.startswith(("x-ai/grok-", "xai/grok-")):
extra_headers["x-grok-conv-id"] = session_id
if extra_headers:
top_level["extra_headers"] = extra_headers
return extra_body, top_level
return extra_body, {"extra_headers": extra_headers} if extra_headers else {}
openrouter = OpenRouterProfile(
+1 -24
View File
@@ -227,30 +227,7 @@ def _trace_key(task_id: str, session_id: str) -> str:
return f"thread:{threading.get_ident()}"
def _is_base64_data_uri(value: str) -> bool:
prefix = value[:200].lower()
return prefix.startswith("data:") and ";base64," in prefix
def _redact_data_uri(value: str) -> dict[str, Any]:
header = value.split(",", 1)[0] if "," in value else "data:"
media_type = header[5:].split(";", 1)[0] if header.startswith("data:") else ""
return {
"type": "data_uri",
"media_type": media_type or None,
"omitted": True,
"length": len(value),
}
def _truncate_text(value: str, max_chars: int) -> Any:
# Langfuse SDK treats data:*;base64 strings as media and attempts to
# decode them. Truncating those strings produces invalid base64 and noisy
# "Error parsing base64 data URI" logs. Observability only needs metadata,
# not raw image/audio payloads, so redact the whole data URI before it
# reaches the SDK.
if _is_base64_data_uri(value):
return _redact_data_uri(value)
def _truncate_text(value: str, max_chars: int) -> str:
if len(value) <= max_chars:
return value
return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]"
+4 -114
View File
@@ -602,11 +602,6 @@ class DiscordAdapter(BasePlatformAdapter):
self._voice_listen_tasks: Dict[int, asyncio.Task] = {} # guild_id -> listen loop
self._voice_input_callback: Optional[Callable] = None # set by run.py
self._on_voice_disconnect: Optional[Callable] = None # set by run.py
# Resolves the current voice-reply mode ("off"|"voice_only"|"all") for a
# linked text-channel id; set by run.py. Lets the inactivity timer leave
# the bot in the channel when the user deliberately picked text-only
# (/voice off) instead of leaving (/voice leave).
self._voice_mode_getter: Optional[Callable] = None # set by run.py
# Phase 3: continuous voice mixer (ambient idle bed + ducked speech).
# Installed once per guild on join; lets acks / TTS / the "thinking"
# loop overlap in one outgoing stream instead of stop-and-swap.
@@ -794,7 +789,6 @@ class DiscordAdapter(BasePlatformAdapter):
# Must run BEFORE the user allowlist check so that bots
# permitted by DISCORD_ALLOW_BOTS are not rejected for
# not being in DISCORD_ALLOWED_USERS (fixes #4466).
_role_authorized = False
if getattr(message.author, "bot", False):
allow_bots = os.getenv("DISCORD_ALLOW_BOTS", "none").lower().strip()
if allow_bots == "none":
@@ -818,7 +812,6 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=_is_dm,
):
return
_role_authorized = bool(getattr(self, "_allowed_role_ids", set()))
# Multi-agent filtering: if the message mentions specific bots
# but NOT this bot, the sender is talking to another agent —
@@ -860,7 +853,7 @@ class DiscordAdapter(BasePlatformAdapter):
if "*" not in _free_channels and not (_channel_ids & _free_channels):
return
await self._handle_message(message, role_authorized=_role_authorized)
await self._handle_message(message)
@self._client.event
async def on_voice_state_update(member, before, after):
@@ -2272,20 +2265,6 @@ class DiscordAdapter(BasePlatformAdapter):
except asyncio.CancelledError:
return
text_ch_id = self._voice_text_channels.get(guild_id)
# ``/voice off`` mutes spoken replies but deliberately keeps the bot in
# the channel (leaving is ``/voice leave``). The inactivity timer only
# counts the bot's OWN audio as activity, so under voice-off mode it
# fires every VOICE_TIMEOUT seconds, yanks the bot out, and spams the
# text channel with "Left voice channel (inactivity timeout)." Honor the
# user's choice: skip the auto-disconnect while voice replies are off.
# (The timer re-arms when the bot next speaks or hears a user.)
_mode_getter = getattr(self, "_voice_mode_getter", None)
if text_ch_id is not None and _mode_getter is not None:
try:
if _mode_getter(str(text_ch_id)) == "off":
return
except Exception:
pass
await self.leave_voice_channel(guild_id)
# Notify the runner so it can clean up voice_mode state
if self._on_voice_disconnect and text_ch_id:
@@ -2416,11 +2395,6 @@ class DiscordAdapter(BasePlatformAdapter):
is_dm=False,
):
continue
# A user speaking to the bot is activity too — not just the
# bot's own playback. Reset the inactivity timer so an active
# listener isn't disconnected mid-conversation (this also
# covers voice-on text-only sessions that never play audio).
self._reset_voice_timeout(guild_id)
await self._process_voice_input(guild_id, user_id, pcm_data)
except asyncio.CancelledError:
pass
@@ -4728,7 +4702,7 @@ class DiscordAdapter(BasePlatformAdapter):
raise Exception(f"HTTP {resp.status}")
return await resp.read()
async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> None:
async def _handle_message(self, message: DiscordMessage) -> None:
"""Handle incoming Discord messages."""
# In server channels (not DMs), require the bot to be @mentioned
# UNLESS the channel is in the free-response list or the message is
@@ -4912,7 +4886,6 @@ class DiscordAdapter(BasePlatformAdapter):
guild_id=str(guild.id) if guild else None,
parent_chat_id=parent_channel_id,
message_id=str(message.id),
role_authorized=role_authorized,
)
# Build media URLs -- download image attachments to local cache so the
@@ -5638,7 +5611,6 @@ def _define_discord_view_classes() -> None:
self.allowed_role_ids = allowed_role_ids or set()
self.resolved = False
self._selected_provider: str = ""
self._pending_expensive_model: str = ""
self._build_provider_select()
@@ -5721,41 +5693,6 @@ def _define_discord_view_classes() -> None:
cancel_btn.callback = self._on_cancel
self.add_item(cancel_btn)
def _build_expensive_confirm(self, model_id: str):
"""Build confirmation buttons for unusually expensive models."""
self.clear_items()
self._pending_expensive_model = model_id
confirm_btn = discord.ui.Button(
label="Switch anyway",
style=discord.ButtonStyle.red,
custom_id="model_expensive_confirm",
)
confirm_btn.callback = self._on_expensive_confirm
self.add_item(confirm_btn)
cancel_btn = discord.ui.Button(
label="Cancel",
style=discord.ButtonStyle.grey,
custom_id="model_expensive_cancel",
)
cancel_btn.callback = self._on_cancel
self.add_item(cancel_btn)
async def _expensive_warning_for(self, model_id: str):
try:
from hermes_cli.model_cost_guard import expensive_model_warning
# Pricing lookup can hit models.dev / a /models endpoint on a
# cache miss — keep it off the event loop.
return await asyncio.to_thread(
expensive_model_warning,
model_id,
provider=self._selected_provider,
)
except Exception:
return None
async def _on_provider_selected(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
@@ -5785,11 +5722,7 @@ def _define_discord_view_classes() -> None:
view=self,
)
async def _switch_selected_model(
self,
interaction: discord.Interaction,
model_id: str,
):
async def _on_model_selected(self, interaction: discord.Interaction):
if self.resolved:
await interaction.response.send_message(
"Already resolved~", ephemeral=True
@@ -5802,6 +5735,7 @@ def _define_discord_view_classes() -> None:
return
self.resolved = True
model_id = interaction.data["values"][0]
self.clear_items()
await interaction.response.edit_message(
embed=discord.Embed(
@@ -5830,50 +5764,6 @@ def _define_discord_view_classes() -> None:
view=None,
)
async def _on_model_selected(self, interaction: discord.Interaction):
if self.resolved:
await interaction.response.send_message(
"Already resolved~", ephemeral=True
)
return
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
model_id = interaction.data["values"][0]
warning = await self._expensive_warning_for(model_id)
if warning is not None:
self._build_expensive_confirm(model_id)
await interaction.response.edit_message(
embed=discord.Embed(
title="⚠ Expensive Model Warning",
description=warning.message,
color=discord.Color.red(),
),
view=self,
)
return
await self._switch_selected_model(interaction, model_id)
async def _on_expensive_confirm(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
"You're not authorized~", ephemeral=True
)
return
if not self._pending_expensive_model:
await interaction.response.send_message(
"Model selection expired.", ephemeral=True
)
return
await self._switch_selected_model(
interaction,
self._pending_expensive_model,
)
async def _on_back(self, interaction: discord.Interaction):
if not self._check_auth(interaction):
await interaction.response.send_message(
+6 -17
View File
@@ -196,7 +196,7 @@ from agent.tool_dispatch_helpers import (
_extract_error_preview,
_trajectory_normalize_msg, # noqa: F401 # re-exported for tests that `from run_agent import _trajectory_normalize_msg`
)
from utils import atomic_json_write, base_url_host_matches, base_url_hostname, is_truthy_value, model_forces_max_completion_tokens
from utils import atomic_json_write, base_url_host_matches, base_url_hostname, is_truthy_value
@@ -1253,24 +1253,13 @@ class AIAgent:
def _max_tokens_param(self, value: int) -> dict:
"""Return the correct max tokens kwarg for the current provider.
OpenAI's newer models (gpt-4o, gpt-4.1, gpt-5+, o-series) require
'max_completion_tokens'. Azure OpenAI and GitHub Copilot also require
'max_completion_tokens' for those families served via their
OpenAI-compatible endpoints. OpenRouter, local models, and older
OpenAI's newer models (gpt-4o, o-series, gpt-5+) require
'max_completion_tokens'. Azure OpenAI also requires
'max_completion_tokens' for gpt-5.x models served via the
OpenAI-compatible endpoint. OpenRouter, local models, and older
OpenAI models use 'max_tokens'.
The check is URL-first (api.openai.com / Azure / Copilot all use the
new kwarg), then falls back to a model-name check so third-party
OpenAI-compatible endpoints fronting those models are recognised
URL-only detection misses that case and silently sends the wrong
kwarg, which the upstream model rejects with a 400.
"""
if (
self._is_direct_openai_url()
or self._is_azure_openai_url()
or self._is_github_copilot_url()
or model_forces_max_completion_tokens(self.model)
):
if self._is_direct_openai_url() or self._is_azure_openai_url() or self._is_github_copilot_url():
return {"max_completion_tokens": value}
return {"max_tokens": value}
-6
View File
@@ -45,9 +45,6 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"barronlroth@gmail.com": "barronlroth",
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
"tomasz.panek@gmail.com": "tomekpanek",
"philipadsouza@gmail.com": "PhilipAD",
"zhuhaoyu0909@icloud.com": "underthestars-zhy",
"raysun12142006@gmail.com": "yanxue06",
@@ -133,7 +130,6 @@ AUTHOR_MAP = {
"wangpuv@hotmail.com": "wangpuv",
"202622897+ticketclosed-wontfix@users.noreply.github.com": "ticketclosed-wontfix",
"wuxuebin1993@gmail.com": "victorGPT",
"xiaoxingitee@gmail.com": "xiaoxinova",
"wei.chen.coder@gmail.com": "wenchengxucool",
"frowte3k@gmail.com": "Frowtek",
"211828103+julio-cloudvisor@users.noreply.github.com": "julio-cloudvisor",
@@ -1042,7 +1038,6 @@ AUTHOR_MAP = {
"zhang9w0v5@qq.com": "zhang9w0v5",
"fuleinist@outlook.com": "fuleinist",
"43494187+Llugaes@users.noreply.github.com": "Llugaes",
"xiangji.chen@centurygame.com": "Llugaes",
"fengtianyu88@users.noreply.github.com": "fengtianyu88",
"l.moncany@gmail.com": "lmoncany",
"fatinghenji@users.noreply.github.com": "fatinghenji",
@@ -1508,7 +1503,6 @@ AUTHOR_MAP = {
"singhsanidhya741@gmail.com": "sanidhyasin", # PR #40403 salvage (model.default_headers for custom OpenAI-compatible providers, #40033)
"josephjohnson.joel@gmail.com": "JoelJJohnson", # PR #39913 salvage (Windows ConPTY dashboard chat bridge)
"andreas@schwarz-ketsch.de": "Nea74", # PR #40022 co-author credit (same Windows ConPTY bridge design)
"chanhokyim@gmail.com": "joel611", # PR #33958 salvage (DISCORD_ALLOWED_ROLES role_authorized gateway flag)
}
-79
View File
@@ -3791,82 +3791,3 @@ class TestAuxUnhealthyCache:
)
# After the 402, OpenRouter is in the unhealthy cache.
assert _is_provider_unhealthy("openrouter") is True
# ── auxiliary_max_tokens_param ──────────────────────────────────────────────
class TestAuxiliaryMaxTokensParam:
"""Verify the kwarg emitted by ``auxiliary_max_tokens_param`` across
URL / provider / model-name combinations. Regression cover: a custom
OpenAI-compatible endpoint serving ``gpt-5.x`` was silently getting
``max_tokens`` and 400-ing on ``unsupported_parameter``."""
def test_direct_openai_returns_max_completion_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://api.openai.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_completion_tokens": 4096}
def test_local_endpoint_without_model_uses_max_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="http://localhost:11434/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_tokens": 4096}
def test_openrouter_api_key_present_keeps_max_tokens_without_model_hint(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test")
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://openrouter.ai/api/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096) == {"max_tokens": 4096}
# Model-name fallback — this is the regression guard.
def test_custom_endpoint_serving_gpt5_uses_max_completion_tokens(self):
"""Third-party gateway + gpt-5.x: name-based detection must kick in."""
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="gpt-5.4") == {
"max_completion_tokens": 4096
}
def test_openrouter_serving_gpt4o_uses_max_completion_tokens(self, monkeypatch):
monkeypatch.setenv("OPENROUTER_API_KEY", "sk-or-v1-test")
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://openrouter.ai/api/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="openai/gpt-4o-mini") == {
"max_completion_tokens": 4096
}
def test_custom_endpoint_serving_classic_llama_keeps_max_tokens(self):
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="llama3-70b") == {
"max_tokens": 4096
}
def test_empty_model_falls_back_to_url_only(self):
"""No model hint → only the URL-based rule applies."""
with (
patch("agent.auxiliary_client._current_custom_base_url",
return_value="https://my-gateway.example.com/v1"),
patch("agent.auxiliary_client._read_nous_auth", return_value=None),
):
assert auxiliary_max_tokens_param(4096, model="") == {"max_tokens": 4096}
assert auxiliary_max_tokens_param(4096, model=None) == {"max_tokens": 4096}
+2 -2
View File
@@ -668,8 +668,8 @@ def test_state_atomic_write_no_tmp_leftovers(curator_env):
c = curator_env["curator"]
c.save_state({"paused": True})
parent = c._state_file().parent
tmp_files = [p.name for p in parent.iterdir() if p.name.endswith(".tmp")]
assert tmp_files == []
for p in parent.iterdir():
assert not p.name.startswith(".curator_state_"), f"tmp leftover: {p.name}"
def test_state_preserves_last_report_path(curator_env):
-51
View File
@@ -964,57 +964,6 @@ class TestClassifyApiError:
assert result.reason == FailoverReason.format_error
assert result.retryable is False
def test_400_unsupported_max_tokens_param_not_context_overflow(self):
"""A GPT-5 model rejecting max_tokens must NOT be misclassified as
context overflow. The OpenAI error string contains the literal
'max_tokens' (a _CONTEXT_OVERFLOW_PATTERNS entry), so without the
request-validation guard it was routed into the compression loop,
re-sent with the same bad param, and ended in "Cannot compress
further". Regression for gpt-5-context-overflow-misclassification."""
msg = ("Unsupported parameter: 'max_tokens' is not supported with this "
"model. Use 'max_completion_tokens' instead.")
e = MockAPIError(
msg,
status_code=400,
body={"error": {"message": msg, "type": "invalid_request_error",
"code": "unsupported_parameter"}},
)
# Tiny context against a huge window — definitely not a real overflow.
result = classify_api_error(e, model="gpt-5.4",
approx_tokens=6962, context_length=1050000)
assert result.reason == FailoverReason.format_error
assert result.retryable is False
assert result.should_compress is False
def test_400_unknown_parameter_not_context_overflow(self):
"""'Unknown parameter' 400s are deterministic request-validation
failures, not overflows."""
e = MockAPIError(
"Unknown parameter: 'foo'.",
status_code=400,
body={"error": {"message": "Unknown parameter: 'foo'.",
"code": "unknown_parameter"}},
)
result = classify_api_error(e, approx_tokens=1000)
assert result.reason == FailoverReason.format_error
assert result.should_compress is False
def test_400_real_overflow_with_invalid_request_error_code_still_compresses(self):
"""Guard the guard: OpenAI stamps genuine context-overflow 400s with
the generic 'invalid_request_error' code. The request-validation guard
must NOT key off that code, or real overflows stop compressing."""
msg = ("This model's maximum context length is 128000 tokens, however "
"you requested 150000 tokens.")
e = MockAPIError(
msg,
status_code=400,
body={"error": {"message": msg, "type": "invalid_request_error"}},
)
result = classify_api_error(e, model="gpt-5.4",
approx_tokens=150000, context_length=128000)
assert result.reason == FailoverReason.context_overflow
assert result.should_compress is True
def test_422_format_error(self):
e = MockAPIError("Unprocessable Entity", status_code=422)
result = classify_api_error(e)
-26
View File
@@ -192,32 +192,6 @@ def test_custom_endpoint_models_api_pricing_is_supported(monkeypatch):
assert float(entry.output_cost_per_million) == 2.0
def test_nous_portal_pricing_preserves_vendor_prefixed_model_ids(monkeypatch):
seen = {}
def _fake_fetch_endpoint_model_metadata(base_url, api_key=None):
seen["base_url"] = base_url
return {
"openai/gpt-5.5-pro": {
"pricing": {
"prompt": "0.000025",
"completion": "0.000125",
}
}
}
monkeypatch.setattr(
"agent.usage_pricing.fetch_endpoint_model_metadata",
_fake_fetch_endpoint_model_metadata,
)
entry = get_pricing_entry("openai/gpt-5.5-pro", provider="nous")
assert seen["base_url"] == "https://inference-api.nousresearch.com/v1"
assert float(entry.input_cost_per_million) == 25.0
assert float(entry.output_cost_per_million) == 125.0
def test_deepseek_v4_pro_pricing_entry_exists():
"""Regression test: deepseek-v4-pro must have a pricing entry.
@@ -5,12 +5,6 @@ import pytest
import cli
@pytest.fixture(autouse=True)
def reset_single_query_finalize_state(monkeypatch):
monkeypatch.setattr(cli, "_single_query_finalize_attempted_session_ids", set())
monkeypatch.setattr(cli, "_cleanup_done", False)
def test_finalize_single_query_runs_cleanup_without_reemitting_finalize_before_release(monkeypatch):
calls = []
fake_cli = SimpleNamespace(_release_active_session=lambda: calls.append(("release", {})))
@@ -76,54 +70,6 @@ def test_finalize_single_query_runs_cleanup_when_finalize_hook_fails(monkeypatch
assert calls == ["finalize", "cleanup", "release"]
def test_finalize_single_query_signal_window_does_not_reemit_during_atexit(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
fake_cli = SimpleNamespace(
agent=fake_agent,
session_id="cli-session",
_release_active_session=lambda: calls.append(("release", {})),
)
def invoke_hook(name, **kwargs):
calls.append((name, kwargs))
def interrupted_cleanup(**_kwargs):
raise KeyboardInterrupt()
expected_finalize = (
"on_session_finalize",
{
"session_id": "agent-session",
"platform": "cli",
"reason": "shutdown",
},
)
original_run_cleanup = cli._run_cleanup
monkeypatch.setattr("hermes_cli.plugins.invoke_hook", invoke_hook)
monkeypatch.setattr(cli, "_run_cleanup", interrupted_cleanup)
with pytest.raises(KeyboardInterrupt):
cli._finalize_single_query(fake_cli)
assert calls == [expected_finalize, ("release", {})]
# Simulate later atexit cleanup after the interrupted one-shot path. The
# active agent may already be unavailable by then.
monkeypatch.setattr(cli, "_run_cleanup", original_run_cleanup)
monkeypatch.setattr(cli, "_active_agent_ref", None)
monkeypatch.setattr(cli, "_reset_terminal_input_modes_on_exit", lambda: None)
monkeypatch.setattr(cli, "_cleanup_all_terminals", lambda: None)
monkeypatch.setattr(cli, "_cleanup_all_browsers", lambda: None)
monkeypatch.setattr("tools.mcp_tool.shutdown_mcp_servers", lambda: None)
monkeypatch.setattr("agent.auxiliary_client.shutdown_cached_clients", lambda: None)
cli._run_cleanup()
assert calls == [expected_finalize, ("release", {})]
def test_notify_single_query_session_finalize_uses_agent_session(monkeypatch):
calls = []
fake_agent = SimpleNamespace(session_id="agent-session", platform="cli")
@@ -80,91 +80,3 @@ async def test_model_picker_clears_controls_before_running_switch_callback():
interaction.response.edit_message.assert_awaited_once()
interaction.response.defer.assert_not_called()
interaction.edit_original_response.assert_awaited_once()
@pytest.mark.asyncio
async def test_expensive_model_requires_confirmation(monkeypatch):
events: list[object] = []
async def on_model_selected(chat_id: str, model_id: str, provider_slug: str) -> str:
events.append(("switch", chat_id, model_id, provider_slug))
return "Model switched"
async def edit_message(**kwargs):
events.append(
(
"edit",
kwargs["embed"].title,
kwargs["embed"].description,
kwargs["view"],
)
)
async def edit_original_response(**kwargs):
events.append((
"final-edit",
kwargs["embed"].title,
kwargs["embed"].description,
kwargs["view"],
))
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(
message="!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?"
),
)
view = ModelPickerView(
providers=[
{
"slug": "openrouter",
"name": "OpenRouter",
"models": ["openai/gpt-5.5-pro"],
"total_models": 1,
"is_current": True,
}
],
current_model="openai/gpt-5.5",
current_provider="openrouter",
session_key="session-1",
on_model_selected=on_model_selected,
allowed_user_ids={"123"}, # matches the interaction user; empty = fail-closed
)
view._selected_provider = "openrouter"
interaction = SimpleNamespace(
user=SimpleNamespace(id=123),
channel_id=456,
data={"values": ["openai/gpt-5.5-pro"]},
response=SimpleNamespace(
send_message=AsyncMock(),
edit_message=AsyncMock(side_effect=edit_message),
),
edit_original_response=AsyncMock(side_effect=edit_original_response),
)
await view._on_model_selected(interaction)
assert events == [
(
"edit",
"⚠ Expensive Model Warning",
"!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?",
view,
),
]
assert view.resolved is False
await view._on_expensive_confirm(interaction)
assert events[1:] == [
(
"edit",
"⚙ Switching Model",
"Switching to `openai/gpt-5.5-pro`...",
None,
),
("switch", "456", "openai/gpt-5.5-pro", "openrouter"),
("final-edit", "⚙ Model Switched", "Model switched", None),
]
@@ -1,186 +0,0 @@
"""Gateway typed ``/model <name>`` must route through the expensive-model
confirmation gate.
The pickers (Telegram/Discord inline keyboards, TUI, dashboard) confirm
expensive models via their own UI affordances; the typed text command
previously bypassed the guard entirely a user typing
``/model openai/gpt-5.5-pro`` switched silently while the picker warned.
These tests pin the typed path:
- warning fires handler returns the slash-confirm prompt, switch NOT applied
- confirm ("once") switch applies (session override set)
- cancel switch not applied, current model unchanged
- no warning (cheap model) switch applies immediately, no prompt
"""
from types import SimpleNamespace
import pytest
import yaml
from gateway.config import Platform
from gateway.platforms.base import MessageEvent, MessageType
from gateway.run import GatewayRunner
from gateway.session import SessionSource
def _make_runner():
runner = object.__new__(GatewayRunner)
runner.adapters = {}
runner._voice_mode = {}
runner._session_model_overrides = {}
runner._running_agents = {}
return runner
def _make_event(text):
return MessageEvent(
text=text,
message_type=MessageType.TEXT,
source=SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm"),
)
def _fake_switch_result():
from hermes_cli.model_switch import ModelSwitchResult
return ModelSwitchResult(
success=True,
new_model="openai/gpt-5.5-pro",
target_provider="openrouter",
provider_changed=False,
api_key="sk-test",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
provider_label="OpenRouter",
)
def _fake_warning():
return SimpleNamespace(
message=(
"!!! EXPENSIVE MODEL WARNING !!!\n"
"openai/gpt-5.5-pro has known pricing above Hermes' safety threshold.\n"
"did you mean to select openai/gpt-5.5?"
),
)
def _setup_isolated_home(tmp_path, monkeypatch, *, warn):
import gateway.run as gateway_run
hermes_home = tmp_path / ".hermes"
hermes_home.mkdir()
cfg_path = hermes_home / "config.yaml"
cfg_path.write_text(
yaml.safe_dump({"model": {"default": "old-model", "provider": "openrouter"}, "providers": {}}),
encoding="utf-8",
)
monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home)
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model",
lambda **kw: _fake_switch_result(),
)
monkeypatch.setattr("hermes_constants.get_hermes_home", lambda: hermes_home)
monkeypatch.setattr("hermes_cli.config.get_hermes_home", lambda: hermes_home)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
(lambda *a, **kw: _fake_warning()) if warn else (lambda *a, **kw: None),
)
return cfg_path
@pytest.mark.asyncio
async def test_typed_model_expensive_prompts_instead_of_switching(tmp_path, monkeypatch):
"""Expensive model typed directly → confirm prompt, no switch applied."""
_setup_isolated_home(tmp_path, monkeypatch, warn=True)
runner = _make_runner()
captured = {}
async def _fake_request_slash_confirm(**kwargs):
captured.update(kwargs)
return kwargs["message"]
runner._request_slash_confirm = _fake_request_slash_confirm
result = await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
assert result is not None
assert "EXPENSIVE MODEL WARNING" in result
# The switch must NOT have been applied yet.
assert runner._session_model_overrides == {}
assert captured["command"] == "model"
@pytest.mark.asyncio
async def test_typed_model_expensive_confirm_once_applies_switch(tmp_path, monkeypatch):
"""Resolving the confirm with "once" applies the switch."""
_setup_isolated_home(tmp_path, monkeypatch, warn=True)
runner = _make_runner()
runner._evict_cached_agent = lambda session_key: None
captured = {}
async def _fake_request_slash_confirm(**kwargs):
captured.update(kwargs)
return None # buttons rendered
runner._request_slash_confirm = _fake_request_slash_confirm
await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
assert runner._session_model_overrides == {}
reply = await captured["handler"]("once")
assert "gpt-5.5-pro" in reply
overrides = list(runner._session_model_overrides.values())
assert len(overrides) == 1
assert overrides[0]["model"] == "openai/gpt-5.5-pro"
@pytest.mark.asyncio
async def test_typed_model_expensive_cancel_keeps_current_model(tmp_path, monkeypatch):
"""Resolving the confirm with "cancel" leaves everything unchanged."""
cfg_path = _setup_isolated_home(tmp_path, monkeypatch, warn=True)
runner = _make_runner()
captured = {}
async def _fake_request_slash_confirm(**kwargs):
captured.update(kwargs)
return None
runner._request_slash_confirm = _fake_request_slash_confirm
await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro --global"))
reply = await captured["handler"]("cancel")
assert "cancelled" in reply.lower()
assert runner._session_model_overrides == {}
# --global must not have persisted the cancelled switch.
written = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert written["model"]["default"] == "old-model"
@pytest.mark.asyncio
async def test_typed_model_cheap_switches_without_prompt(tmp_path, monkeypatch):
"""No warning → switch applies immediately; confirm primitive never invoked."""
_setup_isolated_home(tmp_path, monkeypatch, warn=False)
runner = _make_runner()
runner._evict_cached_agent = lambda session_key: None
async def _fail_request_slash_confirm(**kwargs): # pragma: no cover
raise AssertionError("confirm should not be requested for cheap models")
runner._request_slash_confirm = _fail_request_slash_confirm
result = await runner._handle_model_command(_make_event("/model openai/gpt-5.5-pro"))
assert result is not None
assert "gpt-5.5-pro" in result
overrides = list(runner._session_model_overrides.values())
assert len(overrides) == 1
-49
View File
@@ -9,7 +9,6 @@ from types import SimpleNamespace
import pytest
import gateway.platforms.base as base_platform
from gateway.config import Platform, PlatformConfig, StreamingConfig
from gateway.platforms.base import BasePlatformAdapter, MessageEvent, MessageType, SendResult
from gateway.session import SessionSource
@@ -1077,54 +1076,6 @@ async def test_base_processing_releases_post_delivery_callback_after_main_send()
assert released == [True]
@pytest.mark.asyncio
async def test_base_processing_stops_typing_before_hung_post_delivery_callback(
monkeypatch,
):
"""A stuck post-delivery callback must not keep the typing task alive."""
monkeypatch.setattr(base_platform, "_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS", 0.01)
adapter = ProgressCaptureAdapter()
events = []
async def _handler(event):
return "done"
async def _post_delivery_cb():
events.append("callback-start")
await asyncio.Event().wait()
async def _stop_typing(chat_id):
events.append("typing-stopped")
await ProgressCaptureAdapter.stop_typing(adapter, chat_id)
adapter.set_message_handler(_handler)
adapter.stop_typing = _stop_typing
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="-1001",
chat_type="group",
thread_id="17585",
)
event = MessageEvent(
text="hello",
message_type=MessageType.TEXT,
source=source,
message_id="msg-1",
)
session_key = "agent:main:telegram:group:-1001:17585"
adapter._active_sessions[session_key] = asyncio.Event()
adapter._post_delivery_callbacks[session_key] = _post_delivery_cb
await asyncio.wait_for(
adapter._process_message_background(event, session_key), timeout=1.0
)
assert [call["content"] for call in adapter.sent] == ["done"]
assert events[:2] == ["typing-stopped", "callback-start"]
assert any(call["metadata"] == {"stopped": True} for call in adapter.typing)
@pytest.mark.asyncio
async def test_run_agent_drops_tool_progress_after_generation_invalidation(monkeypatch, tmp_path):
import yaml
-39
View File
@@ -102,45 +102,6 @@ async def test_enrich_message_with_transcription_avoids_bogus_no_provider_messag
assert transcripts == []
@pytest.mark.asyncio
async def test_enrich_message_with_transcription_returns_tuple_for_empty_content_placeholder():
"""A successful transcription whose caption is the empty-content placeholder
must still return the ``(text, transcripts)`` tuple.
The Discord adapter delivers a captionless voice note as the literal
``"(The user sent a message with no text content)"`` placeholder. When STT
succeeds we strip that redundant placeholder and return just the transcript
prefix but the method's contract (and every caller, which unpacks the
result as ``text, transcripts = ...``) requires a 2-tuple. Returning a bare
string here raised ``ValueError: too many values to unpack`` and dropped the
whole voice message on the floor.
"""
from gateway.run import GatewayRunner
runner = GatewayRunner.__new__(GatewayRunner)
runner.config = GatewayConfig(stt_enabled=True)
runner._has_setup_skill = lambda: False
with patch(
"tools.transcription_tools.transcribe_audio",
return_value={
"success": True,
"transcript": "hello from a captionless voice note",
"provider": "local_command",
},
):
result, transcripts = await runner._enrich_message_with_transcription(
"(The user sent a message with no text content)",
["/tmp/voice.ogg"],
)
# The redundant placeholder is stripped, leaving only the transcript prefix.
assert "hello from a captionless voice note" in result
assert "(The user sent a message with no text content)" not in result
# Crucially, the transcripts are still surfaced so callers can echo them.
assert transcripts == ["hello from a captionless voice note"]
@pytest.mark.asyncio
async def test_prepare_inbound_message_text_transcribes_queued_voice_event():
from gateway.run import GatewayRunner
+15 -44
View File
@@ -91,6 +91,10 @@ class TestTelegramModelPicker:
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
update = MagicMock()
update.callback_query = query
context = MagicMock()
await adapter._handle_model_picker_callback(query, "mb", "12345")
edit_kwargs = query.edit_message_text.call_args[1]
@@ -129,11 +133,17 @@ class TestTelegramModelPicker:
await adapter._handle_model_picker_callback(query, "mm:0", "12345")
# The callback was invoked with the selected model
callback.assert_awaited_once()
# edit_message_text MUST be called on the success path (this is the
# regression we're guarding).
query.edit_message_text.assert_awaited()
edit_kwargs = query.edit_message_text.call_args[1]
assert "MARKDOWN_V2" in repr(edit_kwargs["parse_mode"])
# The dynamic result text was routed through format_message
# (backtick code blocks survive escaping).
assert "`gpt-5`" in edit_kwargs["text"]
# State is cleaned up after a successful switch.
assert "12345" not in adapter._model_picker_state
@pytest.mark.asyncio
@@ -174,7 +184,7 @@ class TestTelegramModelPicker:
providers = [
{"slug": "minimax", "name": "MiniMax", "total_models": 2},
{"slug": "minimax-cn", "name": "MiniMax (China)", "total_models": 3},
{"slug": "xai", "name": "xAI", "total_models": 1},
{"slug": "xai", "name": "xAI", "total_models": 1}, # lone group member
]
await adapter.send_model_picker(
@@ -187,11 +197,14 @@ class TestTelegramModelPicker:
metadata=None,
)
# Top-level keyboard: MiniMax family folded into one group button;
# xai (lone member) degraded to a direct provider button.
assert "mpg:minimax" in built
assert "mp:xai" in built
assert "mp:minimax" not in built
assert "mp:minimax-cn" not in built
# Drill into the MiniMax group → members appear as mp: buttons + back.
built.clear()
query = AsyncMock()
query.message = MagicMock()
@@ -203,49 +216,7 @@ class TestTelegramModelPicker:
assert "mp:minimax" in built
assert "mp:minimax-cn" in built
assert "mb" in built
@pytest.mark.asyncio
async def test_expensive_model_requires_confirmation(self, monkeypatch):
adapter = _make_adapter()
callback = AsyncMock(return_value="Switched to `openai/gpt-5.5-pro`")
adapter._model_picker_state["12345"] = {
"providers": [
{"slug": "openrouter", "name": "OpenRouter", "total_models": 1, "is_current": True}
],
"current_model": "model_1",
"current_provider": "openrouter",
"session_key": "s",
"on_model_selected": callback,
"selected_provider": "openrouter",
"model_list": ["openai/gpt-5.5-pro"],
"msg_id": 42,
}
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(
message="!!! EXPENSIVE MODEL WARNING !!!\ndid you mean to select openai/gpt-5.5?"
),
)
query = AsyncMock()
query.message = MagicMock()
query.message.chat_id = 12345
query.answer = AsyncMock()
query.edit_message_text = AsyncMock()
await adapter._handle_model_picker_callback(query, "mm:0", "12345")
callback.assert_not_awaited()
assert "12345" in adapter._model_picker_state
first_edit = query.edit_message_text.call_args[1]
assert "EXPENSIVE MODEL WARNING" in first_edit["text"]
assert first_edit["reply_markup"] is not None
await adapter._handle_model_picker_callback(query, "mc:0", "12345")
callback.assert_awaited_once_with("12345", "openai/gpt-5.5-pro", "openrouter")
assert "12345" not in adapter._model_picker_state
assert "mb" in built # back-to-providers button present
@pytest.mark.asyncio
async def test_retries_without_thread_when_thread_not_found(self):
+1 -70
View File
@@ -415,17 +415,14 @@ class TestSendVoiceReply:
@pytest.mark.asyncio
async def test_calls_tts_and_send_voice(self, runner):
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter.send_voice = AsyncMock()
event = _make_event()
event.source.platform = Platform.TELEGRAM
runner.adapters[event.source.platform] = mock_adapter
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.ogg"})
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result), \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
@@ -433,32 +430,9 @@ class TestSendVoiceReply:
await runner._send_voice_reply(event, "Hello world")
mock_adapter.send_voice.assert_called_once()
assert mock_tts.call_args.kwargs["output_path"].endswith(".ogg")
call_args = mock_adapter.send_voice.call_args
assert call_args.kwargs.get("chat_id") == "123"
@pytest.mark.asyncio
async def test_non_telegram_auto_voice_reply_uses_mp3(self, runner):
from gateway.config import Platform
mock_adapter = AsyncMock()
mock_adapter.send_voice = AsyncMock()
event = _make_event()
event.source.platform = Platform.SLACK
runner.adapters[event.source.platform] = mock_adapter
tts_result = json.dumps({"success": True, "file_path": "/tmp/test.mp3"})
with patch("tools.tts_tool.text_to_speech_tool", return_value=tts_result) as mock_tts, \
patch("tools.tts_tool._strip_markdown_for_tts", side_effect=lambda t: t), \
patch("os.path.isfile", return_value=True), \
patch("os.unlink"), \
patch("os.makedirs"):
await runner._send_voice_reply(event, "Hello world")
mock_adapter.send_voice.assert_called_once()
assert mock_tts.call_args.kwargs["output_path"].endswith(".mp3")
@pytest.mark.asyncio
async def test_auto_voice_reply_uses_thread_metadata_helper(self, runner):
from gateway.config import Platform
@@ -1955,49 +1929,6 @@ class TestVoiceTimeoutCleansRunnerState:
assert 111 not in adapter._voice_clients
@pytest.mark.asyncio
async def test_timeout_skips_disconnect_when_voice_mode_off(self, adapter):
"""Voice-off is deliberate text-only mode, not idle neglect — the
inactivity timer must NOT disconnect or spam the channel (#PanBartosz)."""
disconnect_calls = []
adapter._on_voice_disconnect = lambda chat_id: disconnect_calls.append(chat_id)
adapter._voice_mode_getter = lambda chat_id: "off"
mock_vc = MagicMock()
mock_vc.is_connected.return_value = True
mock_vc.disconnect = AsyncMock()
adapter._voice_clients[111] = mock_vc
adapter._voice_text_channels[111] = 999
adapter._voice_timeout_tasks[111] = MagicMock()
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._voice_timeout_handler(111)
# Still connected, no disconnect callback, no "inactivity timeout" spam.
assert 111 in adapter._voice_clients
assert disconnect_calls == []
mock_vc.disconnect.assert_not_called()
@pytest.mark.asyncio
async def test_timeout_still_disconnects_when_voice_mode_active(self, adapter):
"""A non-off mode still auto-disconnects on genuine inactivity."""
disconnect_calls = []
adapter._on_voice_disconnect = lambda chat_id: disconnect_calls.append(chat_id)
adapter._voice_mode_getter = lambda chat_id: "all"
mock_vc = MagicMock()
mock_vc.is_connected.return_value = True
mock_vc.disconnect = AsyncMock()
adapter._voice_clients[111] = mock_vc
adapter._voice_text_channels[111] = 999
adapter._voice_timeout_tasks[111] = MagicMock()
with patch("asyncio.sleep", new_callable=AsyncMock):
await adapter._voice_timeout_handler(111)
assert 111 not in adapter._voice_clients
assert disconnect_calls == ["999"]
# =====================================================================
# Bug 6: play_in_voice_channel has playback timeout
+2 -44
View File
@@ -465,7 +465,7 @@ def test_xai_loopback_login_manual_paste_missing_code_raises(monkeypatch):
def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
"""Loopback timeout should accept a bare Grok Build code paste."""
"""Loopback timeout should offer the existing manual-paste path."""
monkeypatch.setattr(
auth_mod, "_xai_oauth_discovery",
lambda *_a, **_k: {
@@ -523,7 +523,7 @@ def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
captured["prompt_calls"] += 1
return {
"code": "manual-auth-code",
"state": None,
"state": captured["state"],
"error": None,
"error_description": None,
}
@@ -558,48 +558,6 @@ def test_xai_loopback_login_timeout_falls_back_to_manual_paste(monkeypatch):
assert creds["tokens"]["refresh_token"] == "rt-timeout"
def test_xai_wait_for_callback_accepts_ready_stdin_code(monkeypatch):
"""Users can paste the Grok Build code while Hermes is still waiting."""
class _StubServer:
shutdown_called = False
close_called = False
def shutdown(self):
self.shutdown_called = True
def server_close(self):
self.close_called = True
class _StubThread:
joined = False
def join(self, timeout=None):
self.joined = True
server = _StubServer()
thread = _StubThread()
monkeypatch.setattr(
auth_mod,
"_read_ready_stdin_line",
lambda: "ready-grok-build-code\n",
)
out = auth_mod._xai_wait_for_callback(
server,
thread,
{"code": None, "error": None},
timeout_seconds=5,
manual_paste_redirect_uri="http://127.0.0.1:56121/callback",
)
assert out["code"] == "ready-grok-build-code"
assert out["state"] is None
assert out["_manual_paste"] is True
assert server.shutdown_called is True
assert server.close_called is True
assert thread.joined is True
def test_xai_loopback_login_timeout_noninteractive_reraises(monkeypatch):
"""Non-interactive stdin must keep the original timeout error."""
monkeypatch.setattr(
+3 -3
View File
@@ -133,7 +133,7 @@ def test_model_command_uses_runtime_access_token_for_codex_list(monkeypatch):
captured["access_token"] = access_token
return ["gpt-5.2-codex", "gpt-5.2"]
def _fake_prompt_model_selection(model_ids, current_model="", **_kwargs):
def _fake_prompt_model_selection(model_ids, current_model=""):
captured["model_ids"] = list(model_ids)
captured["current_model"] = current_model
return None
@@ -181,7 +181,7 @@ def test_model_command_prompts_to_reuse_or_reauthenticate_codex_session(monkeypa
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="", **_kwargs: None,
lambda model_ids, current_model="": None,
)
_model_flow_openai_codex({}, current_model="gpt-5.4")
@@ -219,7 +219,7 @@ def test_model_command_uses_existing_codex_session_without_relogin(monkeypatch):
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="", **_kwargs: None,
lambda model_ids, current_model="": None,
)
monkeypatch.setattr(
"hermes_cli.auth._login_openai_codex",
-88
View File
@@ -292,25 +292,6 @@ class TestSaveEnvValueSecure:
env_mode = (tmp_path / ".env").stat().st_mode & 0o777
assert env_mode == 0o600
def test_save_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):
"""Regression for #31518: pre-existing .env mode (e.g. 0640 for a
Docker bind-mount that the operator chose) survives subsequent
writes. Previously _secure_file ran unconditionally after the
mode-restore branch and re-tightened to 0600.
"""
if os.name == "nt":
return
env_path = tmp_path / ".env"
env_path.write_text("EXISTING=value\n")
os.chmod(env_path, 0o640)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
save_env_value("TENOR_API_KEY", "sk-test-secret")
env_mode = env_path.stat().st_mode & 0o777
assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}"
class TestRemoveEnvValue:
def test_removes_key_from_env_file(self, tmp_path):
@@ -354,28 +335,6 @@ class TestRemoveEnvValue:
remove_env_value("ORPHAN_KEY")
assert "ORPHAN_KEY" not in os.environ
def test_remove_env_value_preserves_existing_file_mode_on_posix(self, tmp_path):
"""Regression: pre-existing .env mode (e.g. 0640 for a Docker
bind-mount the operator chose) survives a remove just as it does a
save. Previously _secure_file ran unconditionally after the
mode-restore branch and re-tightened to 0600 the same bug fixed
in save_env_value (#33699), in the sibling remove path.
"""
if os.name == "nt":
return
env_path = tmp_path / ".env"
env_path.write_text("KEEP=value\nDROP=gone\n")
os.chmod(env_path, 0o640)
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path), "DROP": "gone"}):
removed = remove_env_value("DROP")
assert removed is True
assert "DROP" not in env_path.read_text()
env_mode = env_path.stat().st_mode & 0o777
assert env_mode == 0o640, f"expected 0o640, got {oct(env_mode)}"
class TestSaveConfigAtomicity:
"""Verify save_config uses atomic writes (tempfile + os.replace)."""
@@ -1097,50 +1056,3 @@ class TestEnvWriteDenylist:
# But the write path still refuses to update it
with pytest.raises(ValueError, match="denylist"):
save_env_value("LD_PRELOAD", "/tmp/evil.so")
class TestWriteApprovalMigration:
"""Version 28→29 renames memory/skills write_mode → write_approval (bool).
Only an explicit ``approve`` carried gating intent and maps to ``True``;
``on``/``off``/unset map to ``False`` (gate off). The old ``write_mode`` key
is removed. Only a persisted key is rewritten never invented.
"""
def _write(self, tmp_path, body: str):
(tmp_path / "config.yaml").write_text(body)
def test_approve_maps_to_true(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
self._write(tmp_path,
"_config_version: 28\nmemory:\n write_mode: approve\n"
"skills:\n write_mode: approve\n")
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert raw["memory"]["write_approval"] is True
assert raw["skills"]["write_approval"] is True
assert "write_mode" not in raw["memory"]
assert "write_mode" not in raw["skills"]
def test_on_and_off_map_to_false(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
# YAML 1.1 parses bare on/off as bools — write_mode could be either
# the string or the bool; both legacy "not gating" values → False.
self._write(tmp_path,
"_config_version: 28\nmemory:\n write_mode: 'on'\n"
"skills:\n write_mode: 'off'\n")
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
assert raw["memory"]["write_approval"] is False
assert raw["skills"]["write_approval"] is False
def test_unset_key_defaults_to_false(self, tmp_path):
with patch.dict(os.environ, {"HERMES_HOME": str(tmp_path)}):
self._write(tmp_path, "_config_version: 28\nmemory:\n memory_enabled: true\n")
migrate_config(interactive=False, quiet=True)
raw = yaml.safe_load((tmp_path / "config.yaml").read_text())
# No write_mode was persisted, so the rename is a no-op; the missing-
# field pass then seeds the default (False = gate off). Either way the
# gate ends up off and there's no leftover write_mode key.
assert raw["memory"].get("write_approval", False) is False
assert "write_mode" not in raw.get("memory", {})
@@ -191,111 +191,6 @@ def test_full_login_round_trip_unlocks_gated_api(gated_app):
)
def _complete_stub_login(client) -> None:
"""Walk the stub OAuth round trip so ``client`` carries a valid session.
TestClient persists Set-Cookie across calls, so after this returns the
client's cookie jar holds ``hermes_session_at`` / ``hermes_session_rt``
and subsequent gated requests authenticate.
"""
r1 = client.get("/auth/login?provider=stub", follow_redirects=False)
assert r1.status_code == 302
state = r1.headers["location"].split("state=")[1]
r2 = client.get(
f"/auth/callback?code=stub_code&state={state}",
follow_redirects=False,
)
assert r2.status_code == 302
def test_gated_require_token_endpoint_accepts_cookie_session(gated_app):
"""Regression: ``_require_token`` endpoints must work under the OAuth gate.
In gated mode the legacy ``_SESSION_TOKEN`` is NOT injected into the SPA
(it authenticates with the session cookie). Endpoints that call
``_require_token`` directly plugin install/enable/disable,
``/api/dashboard/plugins/hub``, and others used to re-check the absent
token and 401 every cookie-authenticated request, making them permanently
unreachable behind the gate (the dashboard surfaced a
``401: {"detail":"Unauthorized"}`` popup on plugin install). The fix makes
``_require_token`` defer to the gate, which has already verified the cookie
and attached ``request.state.session`` before the handler runs.
We POST a deliberately invalid plugin identifier: a passing auth layer
lets the request reach the handler, which rejects the identifier with a
400. The assertion is simply "not 401" proving auth succeeded without
coupling to the validation message.
"""
_complete_stub_login(gated_app)
r = gated_app.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "definitely not a valid identifier",
"force": False, "enable": False},
)
assert r.status_code != 401, (
"A _require_token endpoint 401'd a cookie-authenticated request under "
f"the OAuth gate (the install-popup bug). Body: {r.text}"
)
# And specifically: it reached the handler's own validation.
assert r.status_code == 400, (
f"Expected the install handler's 400 (bad identifier), got "
f"{r.status_code}: {r.text}"
)
def test_gated_require_token_endpoint_still_rejects_no_cookie(gated_app):
"""The gate must still 401 a ``_require_token`` endpoint with no session.
The fix defers to the gate it does not make these endpoints public. A
request with no cookie is rejected by ``gated_auth_middleware`` before the
handler runs, so the install endpoint stays protected.
"""
r = gated_app.post(
"/api/dashboard/agent-plugins/install",
json={"identifier": "owner/repo", "force": False, "enable": False},
)
assert r.status_code == 401, (
f"Expected 401 for an unauthenticated install POST under the gate, "
f"got {r.status_code}: {r.text}"
)
# A representative spread of the OTHER ``_require_token`` endpoints (there are
# 14 in total). The install popup was just the reported symptom; the same bug
# made API-key reveal, provider validation, the OAuth-provider connect flow,
# and the rest of plugin management unreachable behind the gate. Each entry is
# (method, path, json_body); we assert only that a logged-in request is NOT
# 401'd — i.e. it cleared the auth layer and reached the handler. The
# handler's own status (400/404/429/etc.) is route-specific and not asserted.
_GATED_REQUIRE_TOKEN_ROUTES = [
("get", "/api/dashboard/plugins/hub", None),
("post", "/api/env/reveal", {"key": "NONEXISTENT_ENV_VAR_FOR_TEST"}),
("post", "/api/providers/validate", {"key": "OPENAI_API_KEY", "value": ""}),
("delete", "/api/providers/oauth/__not_a_real_provider__", None),
("post", "/api/dashboard/agent-plugins/__nope__/enable", None),
]
@pytest.mark.parametrize("method,path,body", _GATED_REQUIRE_TOKEN_ROUTES)
def test_gated_require_token_routes_accept_cookie_session(
gated_app, method, path, body
):
"""Every ``_require_token`` route must clear auth for a logged-in caller.
Same root cause and fix as
``test_gated_require_token_endpoint_accepts_cookie_session`` this just
proves the fix covers the whole class, not only ``agent-plugins/install``.
"""
_complete_stub_login(gated_app)
kwargs = {"json": body} if body is not None else {}
r = gated_app.request(method.upper(), path, **kwargs)
assert r.status_code != 401, (
f"{method.upper()} {path} 401'd a cookie-authenticated request under "
f"the OAuth gate — _require_token still rejecting a valid session. "
f"Body: {r.text}"
)
def test_login_unknown_provider_returns_404(gated_app):
r = gated_app.get("/auth/login?provider=nonexistent", follow_redirects=False)
assert r.status_code == 404
-179
View File
@@ -1,179 +0,0 @@
"""Regression tests for hermes_cli._ensure_utf8().
Covers the crash class where the setup wizard (and other banner-printing
commands) emit box-drawing characters and the glyph, which raise
UnicodeEncodeError when stdout/stderr are bound to a non-UTF-8 codec.
Historically the repair was gated on ``sys.platform == "win32"`` and only
caught the Windows cp1252 case. Linux hosts with a latin-1 / C / POSIX locale
(common on minimal Debian installs and Raspberry Pi) hit the identical crash
in ``hermes setup`` because the repair returned early. See the Raspberry Pi
report: latin-1 locale UnicodeEncodeError before the wizard could start.
"""
import io
import os
import sys
import hermes_cli
# The exact glyphs the setup wizard / banners print (setup.py ~line 2962+).
_BANNER = "┌─────┐\n│ ⚕ Hermes │\n└─────┘"
class _FakeStream:
"""Minimal text stream backed by an in-memory byte buffer with a codec.
Mirrors how CPython binds sys.stdout to the locale encoding: writes that
can't be encoded raise UnicodeEncodeError, just like a real latin-1 TTY.
"""
def __init__(self, encoding, *, supports_reconfigure=True):
self.encoding = encoding
self._supports_reconfigure = supports_reconfigure
self.errors = "strict"
self._buf = io.BytesIO()
def write(self, s):
self._buf.write(s.encode(self.encoding, self.errors))
return len(s)
def flush(self):
pass
def reconfigure(self, *, encoding=None, errors=None):
if not self._supports_reconfigure:
raise AttributeError("reconfigure")
if encoding is not None:
self.encoding = encoding
if errors is not None:
self.errors = errors
def getvalue(self):
return self._buf.getvalue()
def _run_with_streams(monkeypatch, out, err):
monkeypatch.setattr(sys, "stdout", out, raising=False)
monkeypatch.setattr(sys, "stderr", err, raising=False)
hermes_cli._ensure_utf8()
def test_latin1_stdout_is_repaired_to_utf8(monkeypatch):
"""A latin-1 stdout (the Raspberry Pi case) becomes UTF-8 capable."""
out = _FakeStream("latin-1")
err = _FakeStream("latin-1")
# Sanity: before the fix, the banner cannot be encoded.
try:
out.write(_BANNER)
pre_fix_crashes = False
except UnicodeEncodeError:
pre_fix_crashes = True
assert pre_fix_crashes, "fixture should reproduce the original crash"
out = _FakeStream("latin-1")
err = _FakeStream("latin-1")
_run_with_streams(monkeypatch, out, err)
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
assert sys.stderr.encoding.lower().replace("-", "") == "utf8"
# The banner now encodes without raising.
sys.stdout.write(_BANNER)
assert "".encode("utf-8") in sys.stdout.getvalue()
def test_ascii_posix_locale_is_repaired(monkeypatch):
"""C/POSIX locale resolves to ascii stdout — also must be repaired."""
out = _FakeStream("ascii")
err = _FakeStream("ascii")
_run_with_streams(monkeypatch, out, err)
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
sys.stdout.write(_BANNER) # no raise
def test_utf8_stream_left_untouched(monkeypatch):
"""Already-UTF-8 streams are a no-op: object identity preserved AND the
process environment is left untouched (no PYTHONUTF8/PYTHONIOENCODING
burned in on a healthy UTF-8 host)."""
out = _FakeStream("utf-8")
err = _FakeStream("utf-8")
sentinel_out, sentinel_err = out, err
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
_run_with_streams(monkeypatch, out, err)
assert sys.stdout is sentinel_out
assert sys.stderr is sentinel_err
# Healthy UTF-8 host: no environment mutation (minimal footprint).
assert "PYTHONUTF8" not in os.environ
assert "PYTHONIOENCODING" not in os.environ
def test_repair_sets_child_process_env(monkeypatch):
"""When a real repair happens, child-process UTF-8 hints are set."""
monkeypatch.delenv("PYTHONUTF8", raising=False)
monkeypatch.delenv("PYTHONIOENCODING", raising=False)
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
assert os.environ.get("PYTHONUTF8") == "1"
assert os.environ.get("PYTHONIOENCODING") == "utf-8"
def test_repair_does_not_override_explicit_env(monkeypatch):
"""A user's explicit PYTHONIOENCODING is respected (setdefault, not set)."""
monkeypatch.setenv("PYTHONIOENCODING", "utf-16")
monkeypatch.delenv("PYTHONUTF8", raising=False)
_run_with_streams(monkeypatch, _FakeStream("latin-1"), _FakeStream("latin-1"))
assert os.environ["PYTHONIOENCODING"] == "utf-16"
def test_fallback_when_reconfigure_unavailable(monkeypatch, tmp_path):
"""Streams without reconfigure() fall back to reopening the fd as UTF-8."""
real_path = tmp_path / "out.txt"
fh = open(real_path, "w", encoding="latin-1")
class _NoReconfigure:
"""latin-1 stream exposing a real fileno() but no reconfigure()."""
encoding = "latin-1"
def fileno(self):
return fh.fileno()
stream = _NoReconfigure()
monkeypatch.setattr(sys, "stdout", stream, raising=False)
monkeypatch.setattr(sys, "stderr", stream, raising=False)
hermes_cli._ensure_utf8()
# Replaced with a new UTF-8 stream object (not reconfigured in place).
assert sys.stdout is not stream
assert sys.stdout.encoding.lower().replace("-", "") == "utf8"
sys.stdout.write(_BANNER)
sys.stdout.flush()
fh.close()
assert "".encode("utf-8") in real_path.read_bytes()
def test_broken_stream_does_not_raise(monkeypatch):
"""A stream whose repair raises must be swallowed, never crash import."""
class _Hostile:
encoding = "latin-1"
def reconfigure(self, *a, **k):
raise OSError("nope")
def fileno(self):
raise OSError("no fd")
monkeypatch.setattr(sys, "stdout", _Hostile(), raising=False)
monkeypatch.setattr(sys, "stderr", _Hostile(), raising=False)
# Must not propagate.
hermes_cli._ensure_utf8()
def test_none_streams_do_not_raise(monkeypatch):
"""pythonw / detached streams (sys.stdout is None) must be tolerated."""
monkeypatch.setattr(sys, "stdout", None, raising=False)
monkeypatch.setattr(sys, "stderr", None, raising=False)
hermes_cli._ensure_utf8()
-97
View File
@@ -1,97 +0,0 @@
from decimal import Decimal
from agent.models_dev import ModelInfo
from agent.usage_pricing import PricingEntry
from hermes_cli.model_cost_guard import expensive_model_warning
def test_no_warning_when_known_prices_are_at_threshold():
info = ModelInfo(
id="edge/model",
name="edge/model",
family="",
provider_id="test",
cost_input=20.0,
cost_output=100.0,
)
assert expensive_model_warning("edge/model", provider="test", model_info=info) is None
def test_warns_when_models_dev_input_price_exceeds_threshold():
info = ModelInfo(
id="expensive/input",
name="expensive/input",
family="",
provider_id="test",
cost_input=20.01,
cost_output=1.0,
)
warning = expensive_model_warning(
"expensive/input",
provider="test",
model_info=info,
)
assert warning is not None
assert warning.input_cost_per_million == Decimal("20.01")
assert "EXPENSIVE MODEL WARNING" in warning.message
assert "$20/M input" in warning.message
def test_warns_when_pricing_entry_output_price_exceeds_threshold(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.get_pricing_entry",
lambda *_args, **_kwargs: PricingEntry(
input_cost_per_million=Decimal("1.00"),
output_cost_per_million=Decimal("100.01"),
source="provider_models_api",
),
)
warning = expensive_model_warning("provider/expensive-output", provider="openrouter")
assert warning is not None
assert warning.output_cost_per_million == Decimal("100.01")
assert "$100.01/M" in warning.message
def test_openai_gpt55_pro_adds_suggestion(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.get_pricing_entry",
lambda *_args, **_kwargs: PricingEntry(
input_cost_per_million=Decimal("25"),
output_cost_per_million=Decimal("125"),
source="provider_models_api",
),
)
warning = expensive_model_warning("openai/gpt-5.5-pro", provider="openrouter")
assert warning is not None
assert "did you mean to select openai/gpt-5.5?" in warning.message
def test_openai_gpt55_pro_warns_for_nous_portal_pricing(monkeypatch):
monkeypatch.setattr("agent.models_dev.get_model_info", lambda *_args, **_kwargs: None)
monkeypatch.setattr(
"agent.usage_pricing.fetch_endpoint_model_metadata",
lambda base_url, api_key="": {
"openai/gpt-5.5-pro": {
"pricing": {
"prompt": "0.000025",
"completion": "0.000125",
}
}
},
)
warning = expensive_model_warning("openai/gpt-5.5-pro", provider="nous")
assert warning is not None
assert warning.input_cost_per_million == Decimal("25.000000")
assert warning.output_cost_per_million == Decimal("125.000000")
assert "did you mean to select openai/gpt-5.5?" in warning.message
@@ -1,64 +0,0 @@
from types import SimpleNamespace
from hermes_cli.model_switch import ModelSwitchResult
def _bound(fn, instance):
return fn.__get__(instance, type(instance))
def test_prompt_toolkit_model_picker_defers_confirmation_off_key_handler(monkeypatch):
import cli as cli_mod
result = ModelSwitchResult(
success=True,
new_model="openai/gpt-5.5-pro",
target_provider="nous",
)
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model",
lambda **_kwargs: result,
)
captured = {}
class _Thread:
def __init__(self, *, target, args, daemon):
captured["target"] = target
captured["args"] = args
captured["daemon"] = daemon
def start(self):
captured["started"] = True
monkeypatch.setattr(cli_mod.threading, "Thread", _Thread)
self_ = SimpleNamespace(
_app=object(),
_model_picker_state={
"stage": "model",
"provider_data": {"slug": "nous"},
"model_list": ["openai/gpt-5.5-pro"],
"selected": 0,
"user_provs": None,
"custom_provs": None,
},
provider="nous",
model="openai/gpt-5.5",
base_url="",
api_key="",
_restore_modal_input_snapshot=lambda: None,
_invalidate=lambda **_kwargs: None,
)
self_._close_model_picker = _bound(cli_mod.HermesCLI._close_model_picker, self_)
self_._confirm_and_apply_model_switch_result = (
lambda *_args: captured.setdefault("ran_inline", True)
)
_bound(cli_mod.HermesCLI._handle_model_picker_selection, self_)()
assert self_._model_picker_state is None
assert captured["started"] is True
assert captured["daemon"] is True
assert captured["args"] == (result, False)
assert "ran_inline" not in captured
-38
View File
@@ -653,44 +653,6 @@ def test_browse_skills_dedup_uses_identifier_not_name(monkeypatch):
)
def test_do_browse_reports_live_per_source_progress():
"""do_browse must pass an on_source_done callback so the status line ticks
off each source as it resolves, instead of showing a frozen spinner while
a slow source blocks. The page is still rendered once, after the full
result set is merged and trust-sorted."""
from hermes_cli.skills_hub import do_browse
from tools.skills_hub import SkillMeta
meta = SkillMeta(
name="demo", description="d", source="official",
identifier="official/demo", trust_level="builtin",
)
captured = {}
def fake_parallel(sources, query="", per_source_limits=None,
source_filter="all", overall_timeout=30,
on_source_done=None):
# Simulate two sources completing — the callback must be wired through.
assert on_source_done is not None, "do_browse must pass on_source_done"
on_source_done("official", 1)
on_source_done("clawhub", 0)
captured["called"] = True
return [meta], {"official": 1, "clawhub": 0}, []
sink = StringIO()
console = Console(file=sink, force_terminal=False, color_system=None, width=120)
with patch("tools.skills_hub.create_source_router", return_value=[]), \
patch("tools.skills_hub.GitHubAuth"), \
patch("tools.skills_hub.parallel_search_sources", side_effect=fake_parallel):
do_browse(page=1, page_size=20, console=console)
assert captured.get("called"), "parallel_search_sources was not invoked"
# The rendered page still shows the (single) merged result.
assert "demo" in sink.getvalue()
# ---------------------------------------------------------------------------
# Regression: full identifier must be recoverable from `hermes skills search`
# even when the slug is too long to fit the terminal width (issue #33674).
@@ -2,7 +2,6 @@
cannot initialize (e.g. non-TTY, curses unavailable, terminal error)."""
import subprocess
from types import SimpleNamespace
from hermes_cli.config import load_config, save_config
@@ -25,46 +24,6 @@ def test_prompt_model_selection_falls_back_on_menu_runtime_error(monkeypatch):
assert selected == "model-b"
def test_prompt_model_selection_requires_expensive_confirmation(monkeypatch, capsys):
from hermes_cli.auth import _prompt_model_selection
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
responses = iter(["1", "n"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
selected = _prompt_model_selection(
["openai/gpt-5.5-pro"],
confirm_provider="nous",
)
out = capsys.readouterr().out
assert selected is None
assert "EXPENSIVE MODEL WARNING" in out
def test_prompt_model_selection_allows_confirmed_expensive_model(monkeypatch):
from hermes_cli.auth import _prompt_model_selection
monkeypatch.setattr("hermes_cli.curses_ui.curses_radiolist", _raise_menu)
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
responses = iter(["1", "y"])
monkeypatch.setattr("builtins.input", lambda _prompt="": next(responses))
selected = _prompt_model_selection(
["openai/gpt-5.5-pro"],
confirm_provider="nous",
)
assert selected == "openai/gpt-5.5-pro"
def test_prompt_reasoning_effort_falls_back_on_menu_runtime_error(monkeypatch):
from hermes_cli.main import _prompt_reasoning_effort_selection
@@ -1,218 +0,0 @@
"""Tests for interrupted-install self-heal (the ``.update-incomplete`` marker).
Covers the breadcrumb lifecycle and the launch-time recovery guard added so a
``hermes update`` killed mid-install (Ctrl-C, terminal close, WSL OOM) gets
finished automatically on the next launch instead of leaving a half-built venv.
"""
from __future__ import annotations
from pathlib import Path
import hermes_cli.main as m
def test_marker_round_trip(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
marker = m._update_marker_path()
assert marker == tmp_path / ".update-incomplete"
assert not marker.exists()
m._write_update_incomplete_marker()
assert marker.exists()
body = marker.read_text()
assert "started=" in body
assert "pid=" in body
m._clear_update_incomplete_marker()
assert not marker.exists()
def test_clear_when_absent_is_noop(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
# Must not raise when the marker was never written.
m._clear_update_incomplete_marker()
assert not m._update_marker_path().exists()
def test_recovery_noop_without_marker(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
called = {"install": False}
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: called.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert called["install"] is False, "recovery must not install when no marker"
def test_recovery_clears_stray_marker_without_pyproject(tmp_path, monkeypatch):
# No pyproject.toml (PyPI/Docker install) — a stray marker is not ours to
# act on; recovery should just clear it without trying to install.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
m._write_update_incomplete_marker()
called = {"install": False}
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: called.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert called["install"] is False
assert not m._update_marker_path().exists()
def test_recovery_runs_install_and_clears_marker(tmp_path, monkeypatch):
# Source-tree install (pyproject present) with marker set → recovery should
# run the dep install and clear the marker on success.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"ensurepip": False, "install": False}
def fake_run(cmd, *a, **k):
if "ensurepip" in cmd:
seen["ensurepip"] = True
class R:
returncode = 0
return R()
monkeypatch.setattr(m.subprocess, "run", fake_run)
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: seen.__setitem__("install", True),
)
m._recover_from_interrupted_install()
assert seen["ensurepip"] is True, "ensurepip must run unconditionally first"
assert seen["install"] is True, "dep install must run"
assert not m._update_marker_path().exists(), "marker cleared on success"
def test_recovery_keeps_marker_on_failure(tmp_path, monkeypatch):
# If the install itself blows up, the marker must survive so the next
# launch retries — and recovery must not raise.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
class R:
returncode = 0
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
def boom(*a, **k):
raise RuntimeError("install died")
monkeypatch.setattr(
m, "_install_python_dependencies_with_optional_fallback", boom
)
# Must not raise.
m._recover_from_interrupted_install()
assert m._update_marker_path().exists(), "marker preserved for retry on failure"
def _stub_install_env(monkeypatch, m, seen):
"""Common stubs so recovery's install path is inert and observable."""
class R:
returncode = 0
monkeypatch.setattr(m.subprocess, "run", lambda *a, **k: R())
monkeypatch.setattr(m, "_is_termux_env", lambda *a, **k: False)
monkeypatch.setattr("hermes_cli.managed_uv.ensure_uv", lambda: None)
monkeypatch.setattr(
m,
"_install_python_dependencies_with_optional_fallback",
lambda *a, **k: seen.__setitem__("install", True),
)
def test_recovery_skips_when_lock_held(tmp_path, monkeypatch):
# Another process is mid-recovery (fresh lockfile) — this launch must skip
# the install entirely and leave both marker and lock untouched.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
lock = tmp_path / ".update-incomplete.lock"
lock.write_text("12345\n")
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert seen["install"] is False, "must not install while another holds the lock"
assert m._update_marker_path().exists(), "marker left for the lock holder"
assert lock.exists(), "fresh lock must not be broken"
def test_recovery_breaks_stale_lock(tmp_path, monkeypatch):
# A lock older than an hour is from a crashed holder — it gets removed so
# the NEXT launch can recover (this launch still skips).
import os as _os
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
lock = tmp_path / ".update-incomplete.lock"
lock.write_text("12345\n")
stale = m._time.time() - 7200
_os.utime(lock, (stale, stale))
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert not lock.exists(), "stale lock must be broken"
assert m._update_marker_path().exists()
# Next launch proceeds normally.
m._recover_from_interrupted_install()
assert seen["install"] is True
assert not m._update_marker_path().exists()
assert not lock.exists(), "lock released after recovery"
def test_recovery_releases_lock_after_run(tmp_path, monkeypatch):
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
assert seen["install"] is True
assert not (tmp_path / ".update-incomplete.lock").exists()
def test_recovery_output_goes_to_stderr(tmp_path, monkeypatch, capfd):
# ACP speaks JSON-RPC on stdout — recovery output (including the streamed
# install, which inherits fd 1) must land on stderr only.
monkeypatch.setattr(m, "PROJECT_ROOT", tmp_path)
(tmp_path / "pyproject.toml").write_text("[project]\nname='x'\n")
m._write_update_incomplete_marker()
seen = {"install": False}
_stub_install_env(monkeypatch, m, seen)
m._recover_from_interrupted_install()
out, err = capfd.readouterr()
assert "interrupted mid-install" not in out
assert "interrupted mid-install" in err
assert "recovered" in err
+1 -173
View File
@@ -4,7 +4,6 @@ import os
import json
import shutil
from pathlib import Path
from types import SimpleNamespace
from unittest.mock import patch, MagicMock
import pytest
@@ -1070,41 +1069,6 @@ class TestWebServerEndpoints:
assert "GATEWAY_PROXY_URL" not in managed
assert "GATEWAY_PROXY_URL" in _MESSAGING_KEYS_PAGE_KEYS
def test_model_set_requires_confirmation_for_expensive_model(self, monkeypatch):
monkeypatch.setattr(
"hermes_cli.model_cost_guard.expensive_model_warning",
lambda *_args, **_kwargs: SimpleNamespace(message="EXPENSIVE MODEL WARNING"),
)
resp = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "nous",
"model": "openai/gpt-5.5-pro",
},
)
assert resp.status_code == 200
data = resp.json()
assert data["ok"] is False
assert data["confirm_required"] is True
assert data["confirm_message"] == "EXPENSIVE MODEL WARNING"
confirmed = self.client.post(
"/api/model/set",
json={
"scope": "main",
"provider": "nous",
"model": "openai/gpt-5.5-pro",
"confirm_expensive_model": True,
},
)
assert confirmed.status_code == 200
assert confirmed.json()["ok"] is True
def test_reveal_env_var(self, tmp_path):
"""POST /api/env/reveal should return the real unredacted value."""
from hermes_cli.config import save_env_value
@@ -1399,17 +1363,6 @@ class TestWebServerEndpoints:
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
ws._ACTION_PROCS.pop("gateway-restart", None)
restart_calls = []
class FakeRestartProc:
pid = 4242
def fake_spawn_action(subcommand, name):
restart_calls.append((subcommand, name))
return FakeRestartProc()
monkeypatch.setattr(ws, "_spawn_hermes_action", fake_spawn_action)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
@@ -1431,138 +1384,13 @@ class TestWebServerEndpoints:
"ok": True,
"platform": "telegram",
"bot_username": "hermes_pair_ready_bot",
"needs_restart": False,
"restart_started": True,
"restart_action": "gateway-restart",
"restart_pid": 4242,
"needs_restart": True,
}
assert restart_calls == [(["gateway", "restart"], "gateway-restart")]
env = load_env()
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
assert load_config()["platforms"]["telegram"]["enabled"] is True
def test_telegram_onboarding_apply_reports_restart_failure_after_save(
self, monkeypatch
):
import hermes_cli.web_server as ws
from hermes_cli.config import load_config, load_env
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
if method == "POST":
return {
"pairing_id": "pair-restart-fails",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_restart_fails_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_restart_fails_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
assert method == "GET"
assert path == "/v1/telegram/pairings/pair-restart-fails"
assert bearer_token == "poll-secret"
return {
"status": "ready",
"bot_username": "hermes_pair_restart_fails_bot",
"owner_user_id": 123456789,
"token": "123456:SECRET",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
ws._ACTION_PROCS.pop("gateway-restart", None)
def fail_spawn_action(subcommand, name):
assert subcommand == ["gateway", "restart"]
assert name == "gateway-restart"
raise RuntimeError("supervisor unavailable")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
ready = self.client.get("/api/messaging/telegram/onboarding/pair-restart-fails")
assert ready.status_code == 200
assert ready.json()["status"] == "ready"
applied = self.client.post(
"/api/messaging/telegram/onboarding/pair-restart-fails/apply",
json={"allowed_user_ids": ["123456789"]},
)
assert applied.status_code == 200
applied_data = applied.json()
assert applied_data["ok"] is True
assert applied_data["needs_restart"] is True
assert applied_data["restart_started"] is False
assert "supervisor unavailable" in applied_data["restart_error"]
assert "token" not in applied_data
env = load_env()
assert env["TELEGRAM_BOT_TOKEN"] == "123456:SECRET"
assert env["TELEGRAM_ALLOWED_USERS"] == "123456789"
assert load_config()["platforms"]["telegram"]["enabled"] is True
def test_telegram_onboarding_apply_reuses_inflight_gateway_restart(
self, monkeypatch
):
"""A live in-flight gateway restart is reused instead of spawning a
second racing ``hermes gateway restart`` child (e.g. when a stale
cached frontend also fires its own restart call)."""
import hermes_cli.web_server as ws
with ws._telegram_onboarding_lock:
ws._telegram_onboarding_pairings.clear()
def fake_request(method, path, *, body=None, bearer_token=None):
if method == "POST":
return {
"pairing_id": "pair-reuse",
"poll_token": "poll-secret",
"suggested_username": "hermes_pair_reuse_bot",
"deep_link": "https://t.me/newbot/HermesSetupBot/hermes_pair_reuse_bot",
"qr_payload": "https://t.me/newbot/HermesSetupBot/hermes_pair_reuse_bot",
"expires_at": "2027-05-18T00:00:00.000Z",
}
return {
"status": "ready",
"bot_username": "hermes_pair_reuse_bot",
"owner_user_id": 123456789,
"token": "123456:SECRET",
}
monkeypatch.setattr(ws, "_telegram_onboarding_request_sync", fake_request)
class FakeRunningProc:
pid = 5151
def poll(self):
return None # still running
monkeypatch.setitem(ws._ACTION_PROCS, "gateway-restart", FakeRunningProc())
def fail_spawn_action(subcommand, name):
raise AssertionError("must not spawn a second concurrent restart")
monkeypatch.setattr(ws, "_spawn_hermes_action", fail_spawn_action)
start = self.client.post("/api/messaging/telegram/onboarding/start", json={})
assert start.status_code == 200
ready = self.client.get("/api/messaging/telegram/onboarding/pair-reuse")
assert ready.status_code == 200
applied = self.client.post(
"/api/messaging/telegram/onboarding/pair-reuse/apply",
json={"allowed_user_ids": ["123456789"]},
)
assert applied.status_code == 200
applied_data = applied.json()
assert applied_data["needs_restart"] is False
assert applied_data["restart_started"] is True
assert applied_data["restart_pid"] == 5151
def test_telegram_onboarding_apply_requires_ready_pairing(self, monkeypatch):
import hermes_cli.web_server as ws
-78
View File
@@ -1,78 +0,0 @@
import argparse
def test_xai_model_flow_reauth_uses_standard_radio_prompt(monkeypatch):
from hermes_cli import main as main_mod
captured = {"login_calls": 0}
monkeypatch.setattr(
"hermes_cli.auth.get_xai_oauth_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.setup._curses_prompt_choice",
lambda title, choices, default, description=None: 1,
)
def _fake_login(args, provider, force_new_login=False):
captured["login_calls"] += 1
captured["force_new_login"] = force_new_login
captured["args"] = args
monkeypatch.setattr("hermes_cli.auth._login_xai_oauth", _fake_login)
monkeypatch.setattr(
"hermes_cli.auth.resolve_xai_oauth_runtime_credentials",
lambda *args, **kwargs: {"base_url": "https://api.x.ai/v1"},
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda model_ids, current_model="": None,
)
main_mod._model_flow_xai_oauth(
{},
current_model="grok-build-0.1",
args=argparse.Namespace(manual_paste=True, no_browser=True, timeout=3),
)
assert captured["login_calls"] == 1
assert captured["force_new_login"] is True
assert captured["args"].manual_paste is True
assert captured["args"].no_browser is True
assert captured["args"].timeout == 3
def test_xai_model_flow_cancel_skips_reauth(monkeypatch):
from hermes_cli import main as main_mod
monkeypatch.setattr(
"hermes_cli.auth.get_xai_oauth_auth_status",
lambda: {"logged_in": True},
)
monkeypatch.setattr(
"hermes_cli.setup._curses_prompt_choice",
lambda title, choices, default, description=None: 2,
)
monkeypatch.setattr(
"hermes_cli.auth._login_xai_oauth",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not reauthenticate")),
)
monkeypatch.setattr(
"hermes_cli.auth._prompt_model_selection",
lambda *args, **kwargs: (_ for _ in ()).throw(AssertionError("should not pick a model")),
)
main_mod._model_flow_xai_oauth({}, current_model="grok-build-0.1")
def test_auth_credentials_choice_falls_back_to_numbered_prompt(monkeypatch):
from hermes_cli import main as main_mod
monkeypatch.setattr(
"hermes_cli.setup._curses_prompt_choice",
lambda title, choices, default, description=None: -1,
)
monkeypatch.setattr("builtins.input", lambda prompt="": "2")
assert main_mod._prompt_auth_credentials_choice("Credentials:") == "reauth"
-34
View File
@@ -171,40 +171,6 @@ class TestHooksInert:
mod.on_post_tool_call(tool_name="read_file", args={}, result="ok", task_id="t", session_id="s")
class TestPayloadSanitization:
def test_safe_value_redacts_base64_data_uri_instead_of_truncating(self):
sys.modules.pop("plugins.observability.langfuse", None)
import importlib
mod = importlib.import_module("plugins.observability.langfuse")
payload = "data:image/png;base64," + ("a" * 20000)
result = mod._safe_value(payload)
assert result == {
"type": "data_uri",
"media_type": "image/png",
"omitted": True,
"length": len(payload),
}
def test_serialize_messages_redacts_data_uri_parts(self):
sys.modules.pop("plugins.observability.langfuse", None)
import importlib
mod = importlib.import_module("plugins.observability.langfuse")
payload = "data:image/jpeg;base64," + ("b" * 20000)
serialized = mod._serialize_messages([
{"role": "user", "content": [{"type": "image_url", "image_url": {"url": payload}}]}
])
assert serialized[0]["content"][0]["image_url"]["url"] == {
"type": "data_uri",
"media_type": "image/jpeg",
"omitted": True,
"length": len(payload),
}
# ---------------------------------------------------------------------------
# Placeholder-credential guard (#23823).
#
-115
View File
@@ -291,121 +291,6 @@ class TestOpenRouterProfile:
assert eb["reasoning"] == {"enabled": True, "effort": "high"}
assert tl["extra_headers"]["x-grok-conv-id"] == "sess-123"
# --- reasoning-mandatory Anthropic effort → top-level verbosity (#43432) ---
#
# These models (Claude 4.6+ / fable / mythos-class) ignore
# ``reasoning.effort`` and use adaptive thinking. OpenRouter honors the
# requested effort on the top-level ``verbosity`` field instead (maps to
# Anthropic ``output_config.effort``). The profile must route the existing
# ``reasoning_config["effort"]`` there while still NEVER emitting a
# ``reasoning`` field (which would 400 — see #42991). Gate every fixture on
# the real predicate so this stays a behavior contract, not a name snapshot.
@staticmethod
def _is_mandatory(model):
import inspect
p = get_provider_profile("openrouter")
mod = inspect.getmodule(type(p))
return mod._anthropic_reasoning_is_mandatory(model)
def test_mandatory_anthropic_effort_routes_to_verbosity(self):
"""effort set + reasoning enabled → top-level verbosity == effort,
and NO reasoning field in extra_body.
Covers the full real config range produced by
``hermes_constants.parse_reasoning_effort``
``VALID_REASONING_EFFORTS = (minimal, low, medium, high, xhigh)``.
"""
p = get_provider_profile("openrouter")
model = "anthropic/claude-fable-5"
assert self._is_mandatory(model) # fixture really is mandatory
for effort in ("minimal", "low", "medium", "high", "xhigh"):
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
supports_reasoning=True,
model=model,
)
assert tl["verbosity"] == effort, (effort, tl)
assert "reasoning" not in eb, (effort, eb)
def test_mandatory_anthropic_effort_without_enabled_key_routes(self):
"""effort present without an explicit ``enabled`` key still routes to
verbosity (enabled defaults to True)."""
p = get_provider_profile("openrouter")
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"effort": "xhigh"},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl["verbosity"] == "xhigh"
assert "reasoning" not in eb
def test_mandatory_anthropic_verbosity_is_value_agnostic_passthrough(self):
"""The mapping passes the effort value through verbatim — it must NOT
clamp or whitelist. ``xhigh`` is a real config value; ``max`` is not
producible by ``parse_reasoning_effort`` today but OpenRouter accepts it
for Claude (live-proven in #43432), so a forward value must survive
rather than be silently dropped. The OpenAI SDK type only literals
``low|medium|high`` but it's a TypedDict (no runtime validation), so the
extended scale reaches the wire untouched."""
p = get_provider_profile("openrouter")
for effort in ("xhigh", "max"):
_, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": effort},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl["verbosity"] == effort
def test_mandatory_anthropic_no_verbosity_when_effort_absent(self):
"""No effort / none / disabled → no verbosity emitted, so the model
keeps its own adaptive default. Still no reasoning field."""
p = get_provider_profile("openrouter")
model = "anthropic/claude-fable-5"
for cfg in (
None,
{},
{"enabled": True},
{"effort": "none"},
{"enabled": True, "effort": "none"},
{"enabled": False, "effort": "high"}, # explicitly disabled wins
):
eb, tl = p.build_api_kwargs_extras(
reasoning_config=cfg,
supports_reasoning=True,
model=model,
)
assert "verbosity" not in tl, (cfg, tl)
assert "reasoning" not in eb, (cfg, eb)
def test_non_mandatory_reasoning_model_unchanged_no_verbosity(self):
"""Non-mandatory reasoning models (DeepSeek, Qwen, GPT) keep getting
``reasoning`` in extra_body and never get a ``verbosity`` field the
new path must not touch them."""
p = get_provider_profile("openrouter")
for model in ("deepseek/deepseek-chat", "qwen/qwen3-max", "openai/gpt-5.4"):
assert not self._is_mandatory(model) # fixture really is non-mandatory
eb, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
supports_reasoning=True,
model=model,
)
assert eb["reasoning"] == {"enabled": True, "effort": "high"}, (model, eb)
assert "verbosity" not in tl, (model, tl)
def test_mandatory_anthropic_verbosity_coexists_with_grok_header(self):
"""A reasoning-mandatory Anthropic model is never a Grok model, but the
top-level dict must remain a single merged dict verify the verbosity
path doesn't clobber the extra_headers slot used by Grok affinity."""
p = get_provider_profile("openrouter")
# mandatory anthropic + effort → verbosity, no extra_headers
_, tl = p.build_api_kwargs_extras(
reasoning_config={"enabled": True, "effort": "high"},
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert tl == {"verbosity": "high"}
class TestNousProfile:
def test_tags(self):
-35
View File
@@ -5063,41 +5063,6 @@ class TestMaxTokensParam:
result = agent._max_tokens_param(4096)
assert result == {"max_completion_tokens": 4096}
# ── Model-name fallback for non-openai.com endpoints serving newer families ──
def test_returns_max_completion_tokens_for_gpt5_on_custom_endpoint(self, agent):
"""Custom OpenAI-compatible endpoint serving gpt-5.x must also use
max_completion_tokens otherwise the server 400s on max_tokens."""
agent.base_url = "https://my-gateway.example.com/v1"
agent.model = "gpt-5.4"
result = agent._max_tokens_param(4096)
assert result == {"max_completion_tokens": 4096}
def test_returns_max_completion_tokens_for_gpt4o_on_openrouter(self, agent):
agent.base_url = "https://openrouter.ai/api/v1"
agent.model = "openai/gpt-4o-mini"
result = agent._max_tokens_param(4096)
assert result == {"max_completion_tokens": 4096}
def test_returns_max_completion_tokens_for_o1_on_custom_endpoint(self, agent):
agent.base_url = "https://custom.example.com/v1"
agent.model = "o1-preview"
result = agent._max_tokens_param(4096)
assert result == {"max_completion_tokens": 4096}
def test_returns_max_tokens_for_classic_gpt4_on_openrouter(self, agent):
"""Classic gpt-4 (non-omni) still uses max_tokens. Don't over-match."""
agent.base_url = "https://openrouter.ai/api/v1"
agent.model = "openai/gpt-4-turbo"
result = agent._max_tokens_param(4096)
assert result == {"max_tokens": 4096}
def test_returns_max_tokens_for_llama_on_local(self, agent):
agent.base_url = "http://localhost:11434/v1"
agent.model = "llama3"
result = agent._max_tokens_param(4096)
assert result == {"max_tokens": 4096}
class TestGpt5ApiModeRouting:
"""Verify provider-specific GPT-5 API-mode routing."""
+222
View File
@@ -0,0 +1,222 @@
"""Tests for the desktop "new session in a worktree" feature.
Covers the three backend pieces:
* ``SessionDB.set_session_worktree`` persistence + read-back (hermes_state).
* ``tui_gateway.server._git_is_repo`` / ``_create_session_worktree`` worktree
creation wired into ``session.create``, with eager DB-row persistence.
* ``hermes_cli.web_server._cleanup_session_worktree`` archive-time cleanup
that honours the unpushed-commits guard.
The worktree machinery itself lives in ``cli.py`` and is exercised here against
a real temporary git repo (no mocks) so the create/cleanup round-trip is proven
end-to-end, not just in unit isolation.
"""
import os
import subprocess
import pytest
from hermes_state import SessionDB
def _init_git_repo(path: str, *, with_commit: bool = True, with_remote: bool = False) -> None:
"""Initialise a real git repo at *path* for worktree tests."""
subprocess.run(["git", "init", "-q", path], check=True)
subprocess.run(["git", "-C", path, "config", "user.email", "t@example.com"], check=True)
subprocess.run(["git", "-C", path, "config", "user.name", "Tester"], check=True)
if with_commit:
readme = os.path.join(path, "README.md")
with open(readme, "w", encoding="utf-8") as f:
f.write("hello\n")
subprocess.run(["git", "-C", path, "add", "."], check=True)
subprocess.run(["git", "-C", path, "commit", "-qm", "init"], check=True)
if with_remote:
# A bare "remote" so unpushed-commit detection has a baseline to compare
# against (without a remote, _worktree_has_unpushed_commits treats the
# worktree as having nothing unpushed).
remote = path + "-remote.git"
subprocess.run(["git", "init", "-q", "--bare", remote], check=True)
subprocess.run(["git", "-C", path, "remote", "add", "origin", remote], check=True)
subprocess.run(["git", "-C", path, "push", "-q", "origin", "HEAD"], check=True)
class TestSetSessionWorktree:
def test_persists_and_reads_back(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("s1", source="tui", cwd="/repo/.worktrees/hermes-abc")
assert db.set_session_worktree(
"s1", "/repo/.worktrees/hermes-abc", "hermes/hermes-abc", "/repo"
)
row = db.get_session("s1")
assert row["worktree_path"] == "/repo/.worktrees/hermes-abc"
assert row["worktree_branch"] == "hermes/hermes-abc"
assert row["worktree_repo_root"] == "/repo"
finally:
db.close()
def test_clear_mapping(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("s1", source="tui")
db.set_session_worktree("s1", "/wt", "hermes/x", "/repo")
assert db.set_session_worktree("s1", None)
row = db.get_session("s1")
assert row["worktree_path"] is None
assert row["worktree_branch"] is None
assert row["worktree_repo_root"] is None
finally:
db.close()
def test_missing_row_returns_false(self, tmp_path):
db = SessionDB(db_path=tmp_path / "state.db")
try:
assert db.set_session_worktree("nope", "/wt") is False
finally:
db.close()
class TestGitIsRepo:
def test_true_for_repo(self, tmp_path):
from tui_gateway import server
repo = str(tmp_path / "repo")
_init_git_repo(repo)
assert server._git_is_repo(repo) is True
def test_true_for_repo_without_commits(self, tmp_path):
# A freshly `git init`-ed repo (no commits, no current branch) is still a
# repo — this is the case a branch-name probe would get wrong.
from tui_gateway import server
repo = str(tmp_path / "fresh")
_init_git_repo(repo, with_commit=False)
assert server._git_is_repo(repo) is True
def test_false_for_plain_dir(self, tmp_path):
from tui_gateway import server
plain = str(tmp_path / "plain")
os.makedirs(plain)
assert server._git_is_repo(plain) is False
def test_false_for_empty_or_missing(self, tmp_path):
from tui_gateway import server
assert server._git_is_repo("") is False
assert server._git_is_repo(str(tmp_path / "does-not-exist")) is False
class TestCreateSessionWorktree:
def test_creates_worktree_and_persists_row(self, tmp_path, monkeypatch):
from tui_gateway import server
repo = str(tmp_path / "repo")
_init_git_repo(repo)
db = SessionDB(db_path=tmp_path / "state.db")
monkeypatch.setattr(server, "_db", db)
try:
session = {"session_key": "KEY1", "cwd": repo, "explicit_cwd": True}
info = server._create_session_worktree(session, repo)
assert info is not None
# cwd repointed into the worktree, on a hermes/ branch.
assert ".worktrees" in info["path"]
assert info["branch"].startswith("hermes/")
assert os.path.isdir(info["path"])
assert session["cwd"] == info["path"]
assert session["worktree"] == info
# Row persisted EAGERLY (not lazily) with the worktree mapping.
row = db.get_session("KEY1")
assert row is not None
assert row["cwd"] == info["path"]
assert row["worktree_path"] == info["path"]
assert row["worktree_branch"] == info["branch"]
finally:
db.close()
def test_non_repo_returns_none(self, tmp_path, monkeypatch):
from tui_gateway import server
plain = str(tmp_path / "plain")
os.makedirs(plain)
db = SessionDB(db_path=tmp_path / "state.db")
monkeypatch.setattr(server, "_db", db)
try:
session = {"session_key": "KEY2", "cwd": plain, "explicit_cwd": True}
assert server._create_session_worktree(session, plain) is None
# cwd unchanged, no row stamped with a worktree.
assert session["cwd"] == plain
assert "worktree" not in session
finally:
db.close()
class TestCleanupSessionWorktree:
def test_removes_clean_worktree_and_clears_mapping(self, tmp_path, monkeypatch):
from tui_gateway import server
from hermes_cli import web_server
repo = str(tmp_path / "repo")
_init_git_repo(repo, with_remote=True) # remote => unpushed detection active
db = SessionDB(db_path=tmp_path / "state.db")
monkeypatch.setattr(server, "_db", db)
try:
session = {"session_key": "KEY3", "cwd": repo, "explicit_cwd": True}
info = server._create_session_worktree(session, repo)
assert info and os.path.isdir(info["path"])
# No commits made in the worktree => nothing unpushed => removable.
preserved = web_server._cleanup_session_worktree(db, "KEY3")
assert preserved is False
assert not os.path.isdir(info["path"])
# Mapping cleared so a repeat archive is a no-op.
row = db.get_session("KEY3")
assert row["worktree_path"] is None
finally:
db.close()
def test_preserves_worktree_with_unpushed_commits(self, tmp_path, monkeypatch):
from tui_gateway import server
from hermes_cli import web_server
repo = str(tmp_path / "repo")
_init_git_repo(repo, with_remote=True)
db = SessionDB(db_path=tmp_path / "state.db")
monkeypatch.setattr(server, "_db", db)
try:
session = {"session_key": "KEY4", "cwd": repo, "explicit_cwd": True}
info = server._create_session_worktree(session, repo)
assert info and os.path.isdir(info["path"])
# Make an unpushed commit on the worktree branch — the guard must
# then refuse to delete it.
wt = info["path"]
with open(os.path.join(wt, "work.txt"), "w", encoding="utf-8") as f:
f.write("unpushed work\n")
subprocess.run(["git", "-C", wt, "add", "."], check=True)
subprocess.run(["git", "-C", wt, "commit", "-qm", "wip"], check=True)
preserved = web_server._cleanup_session_worktree(db, "KEY4")
assert preserved is True
assert os.path.isdir(wt) # still there — guard kept it
# Mapping retained so a later (post-push) archive can retry.
row = db.get_session("KEY4")
assert row["worktree_path"] == wt
finally:
db.close()
def test_no_worktree_returns_none(self, tmp_path):
from hermes_cli import web_server
db = SessionDB(db_path=tmp_path / "state.db")
try:
db.create_session("plain", source="tui")
assert web_server._cleanup_session_worktree(db, "plain") is None
finally:
db.close()
@@ -6,7 +6,6 @@ from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
DASHBOARD_RUN = REPO_ROOT / "docker" / "s6-rc.d" / "dashboard" / "run"
MAIN_WRAPPER = REPO_ROOT / "docker" / "main-wrapper.sh"
STAGE2_HOOK = REPO_ROOT / "docker" / "stage2-hook.sh"
def test_main_wrapper_preserves_docker_workdir() -> None:
@@ -78,14 +77,3 @@ def test_dashboard_run_does_not_derive_insecure_from_bind_host() -> None:
assert truthy in text, (
f"HERMES_DASHBOARD_INSECURE should accept truthy value {truthy!r}"
)
def test_stage2_hook_repairs_profiles_and_cron_ownership_on_every_boot() -> None:
"""profiles/ and cron/ must both be reclaimed after root-context writes."""
text = STAGE2_HOOK.read_text(encoding="utf-8")
assert 'if [ -d "$HERMES_HOME/profiles" ]; then' in text
assert 'chown -R hermes:hermes "$HERMES_HOME/profiles" 2>/dev/null || true' in text
assert 'if [ -d "$HERMES_HOME/cron" ]; then' in text
assert 'chown -R hermes:hermes "$HERMES_HOME/cron" 2>/dev/null || true' in text
@@ -1,137 +0,0 @@
"""Targeted tests for ``utils.model_forces_max_completion_tokens``.
This helper decides whether a given model name requires the newer
``max_completion_tokens`` kwarg (rather than the legacy ``max_tokens``) on
``/v1/chat/completions``. It protects against the 400 ``unsupported_parameter``
error seen when third-party OpenAI-compatible endpoints serve gpt-4o / 4.1 /
5.x / o-series models by name and the caller only checks the URL host.
"""
from __future__ import annotations
from utils import model_forces_max_completion_tokens
# ─── Positive cases: families that require max_completion_tokens ────────────
class TestPositiveCases:
def test_gpt_5_bare(self):
assert model_forces_max_completion_tokens("gpt-5") is True
def test_gpt_5_point_release(self):
# The case the user actually hit — gpt-5.4 on a custom OpenAI-compatible
# endpoint was being sent max_tokens and getting 400 back.
assert model_forces_max_completion_tokens("gpt-5.4") is True
def test_gpt_5_mini(self):
assert model_forces_max_completion_tokens("gpt-5-mini") is True
def test_gpt_5_nano(self):
assert model_forces_max_completion_tokens("gpt-5-nano") is True
def test_gpt_4o(self):
assert model_forces_max_completion_tokens("gpt-4o") is True
def test_gpt_4o_mini(self):
assert model_forces_max_completion_tokens("gpt-4o-mini") is True
def test_gpt_4_1(self):
assert model_forces_max_completion_tokens("gpt-4.1") is True
def test_gpt_4_1_mini(self):
assert model_forces_max_completion_tokens("gpt-4.1-mini") is True
def test_o1(self):
assert model_forces_max_completion_tokens("o1") is True
def test_o1_preview(self):
assert model_forces_max_completion_tokens("o1-preview") is True
def test_o1_mini(self):
assert model_forces_max_completion_tokens("o1-mini") is True
def test_o3(self):
assert model_forces_max_completion_tokens("o3") is True
def test_o3_mini(self):
assert model_forces_max_completion_tokens("o3-mini") is True
def test_o4_mini(self):
# Future-proofing — o4 is already listed publicly.
assert model_forces_max_completion_tokens("o4-mini") is True
# ─── Negative cases: older or non-OpenAI families still use max_tokens ──────
class TestNegativeCases:
def test_gpt_3_5_turbo(self):
assert model_forces_max_completion_tokens("gpt-3.5-turbo") is False
def test_gpt_4(self):
# Classic gpt-4 (non-omni) still uses max_tokens on chat completions.
assert model_forces_max_completion_tokens("gpt-4") is False
def test_gpt_4_turbo(self):
assert model_forces_max_completion_tokens("gpt-4-turbo") is False
def test_claude_family(self):
assert model_forces_max_completion_tokens("claude-3-opus") is False
assert model_forces_max_completion_tokens("claude-sonnet-4-6") is False
def test_llama_family(self):
assert model_forces_max_completion_tokens("llama3") is False
assert model_forces_max_completion_tokens("llama-3-70b-instruct") is False
def test_mistral_family(self):
assert model_forces_max_completion_tokens("mistral-7b-instruct") is False
def test_qwen_family(self):
assert model_forces_max_completion_tokens("qwen2.5-72b") is False
def test_deepseek_family(self):
assert model_forces_max_completion_tokens("deepseek-chat") is False
# ─── Edge cases ─────────────────────────────────────────────────────────────
class TestEdgeCases:
def test_empty_string(self):
assert model_forces_max_completion_tokens("") is False
def test_none(self):
assert model_forces_max_completion_tokens(None) is False # type: ignore[arg-type]
def test_whitespace_only(self):
assert model_forces_max_completion_tokens(" ") is False
def test_case_insensitive(self):
assert model_forces_max_completion_tokens("GPT-5.4") is True
assert model_forces_max_completion_tokens("Gpt-4o-Mini") is True
assert model_forces_max_completion_tokens("O3-MINI") is True
def test_leading_trailing_whitespace(self):
assert model_forces_max_completion_tokens(" gpt-5 ") is True
def test_vendor_prefix_stripped(self):
# OpenRouter-style "vendor/model" names should match the tail.
assert model_forces_max_completion_tokens("openai/gpt-5.4") is True
assert model_forces_max_completion_tokens("openai/gpt-4o-mini") is True
assert model_forces_max_completion_tokens("openai/o3-mini") is True
def test_vendor_prefix_with_non_matching_tail(self):
assert model_forces_max_completion_tokens("openai/gpt-3.5-turbo") is False
assert model_forces_max_completion_tokens("anthropic/claude-3-opus") is False
def test_fake_prefix_not_matched(self):
# "o-series-but-not-really" doesn't start with o1/o3/o4.
assert model_forces_max_completion_tokens("omni-chat") is False
# "ox" isn't an o-series model, and "olive" / "opus" shouldn't collide.
assert model_forces_max_completion_tokens("ox-large") is False
assert model_forces_max_completion_tokens("opus-3") is False
def test_gpt_5_substring_in_middle_not_matched(self):
# Only a prefix should match — "local-gpt-5-clone" is a different model.
assert model_forces_max_completion_tokens("local-gpt-5-clone") is False
+4 -72
View File
@@ -2397,7 +2397,7 @@ def test_config_set_model_waits_for_lazy_agent_before_switch(monkeypatch):
target["agent"] = agent
agent_ready.set()
def fake_apply(sid, target, raw, **kwargs):
def fake_apply(sid, target, raw):
calls.append(("apply", sid, target.get("agent"), raw))
if target.get("agent") is not agent:
raise AssertionError("model switch ran before lazy agent was ready")
@@ -2424,7 +2424,7 @@ def test_config_set_model_uses_live_switch_path(monkeypatch):
server._sessions["sid"] = _session()
seen = {}
def _fake_apply(sid, session, raw, **_kwargs):
def _fake_apply(sid, session, raw):
seen["args"] = (sid, session["session_key"], raw)
return {"value": "new/model", "warning": "catalog unreachable"}
@@ -2442,74 +2442,6 @@ def test_config_set_model_uses_live_switch_path(monkeypatch):
assert seen["args"] == ("sid", "session-key", "new/model")
def test_config_set_model_requires_confirmation_for_expensive_model(monkeypatch):
class _Agent:
provider = "openrouter"
model = "old/model"
base_url = ""
api_key = "sk-or"
switched = False
def switch_model(self, **_kwargs):
self.switched = True
result = types.SimpleNamespace(
success=True,
new_model="openai/gpt-5.5-pro",
target_provider="openrouter",
api_key="sk-or",
base_url="https://openrouter.ai/api/v1",
api_mode="chat_completions",
warning_message="",
model_info=types.SimpleNamespace(
has_cost_data=lambda: True,
cost_input=25.0,
cost_output=125.0,
),
)
agent = _Agent()
server._sessions["sid"] = _session(agent=agent)
monkeypatch.setattr(
"hermes_cli.model_switch.switch_model", lambda **_kwargs: result
)
monkeypatch.setattr(server, "_restart_slash_worker", lambda sid, session: None)
monkeypatch.setattr(server, "_emit", lambda *args, **kwargs: None)
resp = server.handle_request(
{
"id": "1",
"method": "config.set",
"params": {
"session_id": "sid",
"key": "model",
"value": "openai/gpt-5.5-pro --provider openrouter",
},
}
)
assert resp["result"]["confirm_required"] is True
assert "did you mean to select openai/gpt-5.5?" in resp["result"]["confirm_message"]
assert agent.switched is False
confirmed = server.handle_request(
{
"id": "2",
"method": "config.set",
"params": {
"session_id": "sid",
"key": "model",
"value": "openai/gpt-5.5-pro --provider openrouter",
"confirm_expensive_model": True,
},
}
)
assert confirmed["result"]["confirm_required"] is False
assert confirmed["result"]["value"] == "openai/gpt-5.5-pro"
assert agent.switched is True
def test_config_set_model_global_persists(monkeypatch):
class _Agent:
provider = "openrouter"
@@ -4012,7 +3944,7 @@ def test_config_set_model_rejects_while_running(monkeypatch):
"""/model via config.set must reject during an in-flight turn."""
seen = {"called": False}
def _fake_apply(sid, session, raw, **_kwargs):
def _fake_apply(sid, session, raw):
seen["called"] = True
return {"value": raw, "warning": ""}
@@ -4046,7 +3978,7 @@ def test_config_set_model_allowed_when_idle(monkeypatch):
"""Regression guard: idle sessions can still switch models."""
seen = {"called": False}
def _fake_apply(sid, session, raw, **_kwargs):
def _fake_apply(sid, session, raw):
seen["called"] = True
return {"value": "newmodel", "warning": ""}
+2 -32
View File
@@ -338,7 +338,7 @@ class TestCaptureResponse:
from tools.computer_use.backend import CaptureResult
from tools.computer_use import tool as cu_tool
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nGNgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
class FakeBackend:
def start(self): pass
@@ -372,41 +372,11 @@ class TestCaptureResponse:
assert any(p.get("type") == "image_url" for p in out["content"])
assert any(p.get("type") == "text" for p in out["content"])
def test_capture_tiny_image_returns_text_json(self):
"""Providers can reject <8px images, so placeholders must be omitted."""
from tools.computer_use.backend import CaptureResult, UIElement
from tools.computer_use import tool as cu_tool
tiny_png = "iVBORw0KGgoAAAANSUhEUgAAAAIAAAACCAYAAABytg0kAAAAC0lEQVR4nGNgQAcAABIAAXfx+gAAAAAASUVORK5CYII="
cap = CaptureResult(
mode="som",
width=0,
height=0,
png_b64=tiny_png,
elements=[
UIElement(index=1, role="AXButton", label="Continue", bounds=(10, 20, 30, 30)),
],
app="Safari",
window_title="Example",
png_bytes_len=68,
)
with patch.object(cu_tool, "_should_route_through_aux_vision",
return_value=False):
out = cu_tool._capture_response(cap)
parsed = json.loads(out)
assert parsed["width"] == 2
assert parsed["height"] == 2
assert "screenshot omitted" in parsed["summary"]
assert parsed["elements"][0]["label"] == "Continue"
def test_capture_som_with_elements_formats_index(self):
from tools.computer_use.backend import CaptureResult, UIElement
from tools.computer_use import tool as cu_tool
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nGNgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
fake_png = "iVBORw0KGgo="
class FakeBackend:
def start(self): pass
@@ -33,10 +33,10 @@ import pytest
# Fixtures / helpers
# ---------------------------------------------------------------------------
# 8×8 PNG (transparent) — minimal provider-acceptable bytes that decode cleanly.
# 1×1 PNG (transparent) — minimal bytes that decode cleanly.
_PNG_B64 = (
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nG"
"NgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42m"
"NkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
)
# 1×1 JPEG — used to verify mime detection works for either stream type.
@@ -172,31 +172,6 @@ def test_dockerfile_preinstalls_gateway_messaging_dependencies(dockerfile_text):
)
def test_dockerfile_preinstalls_matrix_dependencies(dockerfile_text):
sync_steps = [
step for step in _run_steps(dockerfile_text)
if "uv sync" in step and "--no-install-project" in step
]
assert sync_steps, "Dockerfile must install Python dependencies with uv sync"
assert any("--extra matrix" in step for step in sync_steps), (
"Published Docker images must preload the [matrix] extra so the "
"Matrix gateway has mautrix[encryption]/python-olm available at "
"runtime instead of relying on first-boot lazy installation into "
"the container venv (#30399)."
)
def test_dockerfile_installs_matrix_native_build_dependencies(dockerfile_text):
instructions = _instruction_text(dockerfile_text)
for package in ("libolm-dev", "cmake", "g++", "make"):
assert package in instructions, (
"Docker image must include native build dependencies needed by "
f"python-olm when preinstalling the [matrix] extra (#30399): {package}"
)
def test_dockerfile_preinstalls_hindsight_memory_dependency(dockerfile_text):
sync_steps = [
step for step in _run_steps(dockerfile_text)
+2 -9
View File
@@ -321,19 +321,12 @@ class TestStdioPgroupReaping:
psutil = pytest.importorskip("psutil")
# Grandchild: sleep forever, write its pid then wait. The pid file
# is written to a temp path and os.replace()d into place so the
# polling reader below can never observe a created-but-empty file
# (CI flake: int('') ValueError when the reader won the race between
# open('w') creating the file and write() filling it).
# Grandchild: sleep forever, write its pid then wait.
grandchild_pid_file = tmp_path / "grandchild.pid"
grandchild_script = tmp_path / "grandchild.py"
grandchild_script.write_text(
"import os, sys, time\n"
f"tmp = {str(grandchild_pid_file)!r} + '.tmp'\n"
"with open(tmp, 'w') as f:\n"
" f.write(str(os.getpid()))\n"
f"os.replace(tmp, {str(grandchild_pid_file)!r})\n"
f"open({str(grandchild_pid_file)!r}, 'w').write(str(os.getpid()))\n"
"while True:\n"
" time.sleep(0.5)\n"
)
+1 -82
View File
@@ -1,8 +1,6 @@
"""Tests for tools/skills_hub.py — source adapters, lock file, taps, dedup logic."""
import json
import time
from typing import List, Optional
from unittest.mock import patch, MagicMock
import httpx
@@ -16,15 +14,13 @@ from tools.skills_hub import (
UrlSource,
WellKnownSkillSource,
OptionalSkillSource,
SkillSource,
SkillBundle,
SkillMeta,
SkillBundle,
HubLockFile,
TapsManager,
bundle_content_hash,
check_for_skill_updates,
create_source_router,
parallel_search_sources,
unified_search,
append_audit_log,
_skill_meta_to_dict,
@@ -2205,80 +2201,3 @@ class TestInstallPathSafety:
assert not (skills_dir / "bad-skill" / "leak.txt").exists()
assert secret.read_text() == "data exfiltration payload\n"
# ---------------------------------------------------------------------------
# parallel_search_sources — overall_timeout must be honoured even when a
# source blocks for far longer than the budget (regression: the executor used
# `with ... as pool`, whose __exit__ calls shutdown(wait=True) and blocked the
# caller on the slow worker, making overall_timeout a no-op).
# ---------------------------------------------------------------------------
class _FakeSource(SkillSource):
def __init__(self, sid: str, sleep: float = 0.0, results=None):
self._sid = sid
self._sleep = sleep
self._results = results or []
def source_id(self) -> str:
return self._sid
def search(self, query: str, limit: int = 10) -> List[SkillMeta]:
if self._sleep:
time.sleep(self._sleep)
return list(self._results)
def fetch(self, identifier: str) -> Optional[SkillBundle]:
return None
def inspect(self, identifier: str) -> Optional[SkillMeta]:
return None
class TestParallelSearchSourcesTimeout:
def _meta(self, sid: str) -> SkillMeta:
return SkillMeta(
name=f"{sid}-skill",
description="x",
source=sid,
identifier=f"{sid}/x",
trust_level="community",
)
def test_slow_source_does_not_block_caller(self):
"""A source sleeping well past overall_timeout must not stall the
return. Before the fix the executor's `with` block waited on the slow
worker (~5s); now the call returns promptly and reports the source as
timed out."""
fast = _FakeSource("fast", sleep=0.0, results=[self._meta("fast")])
slow = _FakeSource("slow", sleep=5.0, results=[self._meta("slow")])
start = time.monotonic()
all_results, source_counts, timed_out_ids = parallel_search_sources(
[fast, slow], query="q", overall_timeout=0.3,
)
elapsed = time.monotonic() - start
# Must return long before the slow source's 5s sleep finishes.
assert elapsed < 2.0, f"call blocked for {elapsed:.2f}s (timeout not honoured)"
assert "slow" in timed_out_ids
# Fast source still delivered its result and is not flagged timed out.
assert source_counts.get("fast") == 1
assert "fast" not in timed_out_ids
assert any(r.source == "fast" for r in all_results)
def test_all_fast_sources_complete_without_timeout(self):
"""Happy path: when every source finishes within budget, none are
flagged and all results are collected."""
a = _FakeSource("a", results=[self._meta("a")])
b = _FakeSource("b", results=[self._meta("b")])
all_results, source_counts, timed_out_ids = parallel_search_sources(
[a, b], query="q", overall_timeout=5.0,
)
assert timed_out_ids == []
assert source_counts.get("a") == 1
assert source_counts.get("b") == 1
assert len(all_results) == 2
-178
View File
@@ -350,184 +350,6 @@ class TestClawHubSource(unittest.TestCase):
self.assertIn("b-skill-199", identifiers)
self.assertIn("c-skill-49", identifiers)
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_catalog_walk_aborts_on_budget_and_does_not_poison_cache(
self, mock_get, _mock_read_cache, mock_write_cache
):
"""A walk truncated by the wall-clock budget must stop early and must
NOT write the (partial) result to the cache. Before the budget guard
the walk ran up to 750 pages and cached unconditionally a truncated
walk poisoned the cache with incomplete catalog data."""
page_calls = {"n": 0}
def side_effect(url, *args, **kwargs):
if url.endswith("/skills"):
idx = page_calls["n"]
page_calls["n"] += 1
# Always advertise another page so the walk would never stop
# on its own — only the budget can break it.
return _MockResponse(
status_code=200,
json_data={
"items": [
{"slug": f"skill-{idx}", "displayName": f"Skill {idx}"}
],
"nextCursor": f"cursor-{idx + 1}",
},
)
return _MockResponse(status_code=404, json_data={})
mock_get.side_effect = side_effect
# Force the deadline to be in the past immediately.
with patch.object(ClawHubSource, "CATALOG_WALK_BUDGET_SECONDS", -1):
results = self.src._load_catalog_index()
# Walk broke well before the 750-page cap.
self.assertLess(page_calls["n"], 750)
# Truncated walk must not poison the cache.
mock_write_cache.assert_not_called()
# Whatever was gathered is still returned to the caller.
self.assertIsInstance(results, list)
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_catalog_walk_caches_when_terminating_naturally_within_budget(
self, mock_get, _mock_read_cache, mock_write_cache
):
"""Happy path: a walk that exhausts the cursor within the budget DOES
write the cache."""
def side_effect(url, *args, **kwargs):
if url.endswith("/skills"):
return _MockResponse(
status_code=200,
json_data={
"items": [
{"slug": "only-skill", "displayName": "Only Skill"}
],
# No nextCursor -> natural termination.
},
)
return _MockResponse(status_code=404, json_data={})
mock_get.side_effect = side_effect
results = self.src._load_catalog_index()
self.assertEqual(len(results), 1)
self.assertEqual(results[0].identifier, "only-skill")
mock_write_cache.assert_called_once()
class TestClawHubCatalogWalkBounded(unittest.TestCase):
"""max_items bounds the walk so browse's cold-start fallback renders one
page without walking the entire 50k+ catalog. The offline index builder
keeps max_items=0 (unbounded) and walks to exhaustion."""
def setUp(self):
self.src = ClawHubSource()
self._safe_patcher = patch("tools.skills_hub.is_safe_url", return_value=True)
self._policy_patcher = patch("tools.skills_hub.check_website_access", return_value=None)
self._safe_patcher.start()
self._policy_patcher.start()
def tearDown(self):
self._policy_patcher.stop()
self._safe_patcher.stop()
def _infinite_pages(self, page_calls):
"""A side_effect that always advertises another cursor — the walk would
never stop on its own, so only max_items / budget can break it."""
def side_effect(url, *args, **kwargs):
if url.endswith("/skills"):
idx = page_calls["n"]
page_calls["n"] += 1
return _MockResponse(
status_code=200,
json_data={
"items": [
{"slug": f"skill-{idx}", "displayName": f"Skill {idx}"}
],
"nextCursor": f"cursor-{idx + 1}",
},
)
return _MockResponse(status_code=404, json_data={})
return side_effect
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_max_items_stops_walk_early_and_does_not_cache(
self, mock_get, _mock_read_cache, mock_write_cache
):
"""A bounded walk stops as soon as it has >= max_items skills and must
NOT poison the shared full-catalog cache with the partial slice."""
page_calls = {"n": 0}
mock_get.side_effect = self._infinite_pages(page_calls)
results = self.src._load_catalog_index(max_items=5)
# Each mocked page yields exactly 1 item, so ~5 pages cover the bound.
self.assertGreaterEqual(len(results), 5)
self.assertLess(page_calls["n"], 750, "bounded walk should stop well before the cap")
self.assertLess(page_calls["n"], 20, "should stop within a few pages of the bound")
# Partial (bounded) walk must not be cached.
mock_write_cache.assert_not_called()
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_max_items_zero_is_unbounded_and_caches(
self, mock_get, _mock_read_cache, mock_write_cache
):
"""max_items=0 (the index builder's path) walks to natural termination
and DOES cache the complete catalog."""
def side_effect(url, *args, **kwargs):
if url.endswith("/skills"):
return _MockResponse(
status_code=200,
json_data={
"items": [
{"slug": "a", "displayName": "A"},
{"slug": "b", "displayName": "B"},
{"slug": "c", "displayName": "C"},
],
# No nextCursor -> natural termination.
},
)
return _MockResponse(status_code=404, json_data={})
mock_get.side_effect = side_effect
results = self.src._load_catalog_index(max_items=0)
self.assertEqual(len(results), 3)
mock_write_cache.assert_called_once()
@patch("tools.skills_hub._write_index_cache")
@patch("tools.skills_hub._read_index_cache", return_value=None)
@patch("tools.skills_hub.httpx.get")
def test_empty_query_browse_bounds_walk_to_limit(
self, mock_get, _mock_read_cache, _mock_write_cache
):
"""search("", limit=N) is the browse cold-start path — it must bound the
catalog walk to N rather than walking the whole 50k+ catalog."""
page_calls = {"n": 0}
mock_get.side_effect = self._infinite_pages(page_calls)
results = self.src.search("", limit=10)
self.assertEqual(len(results), 10, "browse page should be exactly `limit` items")
# Walk stopped near the bound, not at the 750-page cap.
self.assertLess(page_calls["n"], 30)
if __name__ == "__main__":
unittest.main()
-20
View File
@@ -373,26 +373,6 @@ class TestSkillView:
assert result["name"] == "my-skill"
assert "Step 1" in result["content"]
def test_view_skill_by_frontmatter_name_when_dir_differs(self, tmp_path):
# The on-disk directory ("alias-dir") differs from the skill's
# frontmatter name ("real-skill-name"). skills_list() exposes the
# frontmatter name, so skill_view(name) must resolve it too.
skill_dir = tmp_path / "alias-dir"
skill_dir.mkdir(parents=True, exist_ok=True)
(skill_dir / "SKILL.md").write_text(
"---\n"
"name: real-skill-name\n"
"description: A skill whose directory name differs from its name.\n"
"---\n\n"
"# real-skill-name\n\n"
"Step 1: Do the thing.\n"
)
with patch("tools.skills_tool.SKILLS_DIR", tmp_path):
raw = skill_view("real-skill-name")
result = json.loads(raw)
assert result["success"] is True
assert "Step 1" in result["content"]
def test_skill_view_applies_template_vars(self, tmp_path):
with (
patch("tools.skills_tool.SKILLS_DIR", tmp_path),

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