Compare commits
47
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a23bbe3348 | ||
|
|
a72bb03757 | ||
|
|
47e77ae166 | ||
|
|
4c797d0e23 | ||
|
|
189ffe7362 | ||
|
|
2c19208224 | ||
|
|
5718811de0 | ||
|
|
af3c8b80b5 | ||
|
|
70d5d7e39b | ||
|
|
a5c32cdf30 | ||
|
|
15813336cc | ||
|
|
183d86b3e0 | ||
|
|
cd9a9cd8e5 | ||
|
|
5d8c44a393 | ||
|
|
2f19512341 | ||
|
|
f222bd26e7 | ||
|
|
38273676ea | ||
|
|
c1308ebf3f | ||
|
|
fa32af886f | ||
|
|
984e69ff62 | ||
|
|
e80754647c | ||
|
|
298bb93d39 | ||
|
|
eee1da45f0 | ||
|
|
6a30cfca82 | ||
|
|
888bf96025 | ||
|
|
383d44bc9a | ||
|
|
243cada157 | ||
|
|
af978ecb17 | ||
|
|
4eadef18a9 | ||
|
|
099146fedd | ||
|
|
e5580f43c2 | ||
|
|
5a4297a11a | ||
|
|
aea0b7397b | ||
|
|
311900842e | ||
|
|
105625d650 | ||
|
|
2ce3ae3d16 | ||
|
|
19c07c4037 | ||
|
|
ab55008631 | ||
|
|
1c055a4c58 | ||
|
|
095f526b11 | ||
|
|
9ca9697342 | ||
|
|
63a421d4c0 | ||
|
|
e4a1b35a39 | ||
|
|
ea7981eba7 | ||
|
|
f1b8519670 | ||
|
|
f8fd30942c | ||
|
|
4642762289 |
@@ -63,3 +63,45 @@ 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
|
||||
|
||||
@@ -114,6 +114,12 @@ 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/
|
||||
|
||||
+20
-9
@@ -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 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 g++ make cmake 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), 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 — 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`:
|
||||
# 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,19 +164,30 @@ 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
|
||||
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
|
||||
|
||||
# ---------- 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.
|
||||
|
||||
@@ -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, normalize_proxy_env_vars
|
||||
from utils import base_url_host_matches, base_url_hostname, model_forces_max_completion_tokens, normalize_proxy_env_vars
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -4300,13 +4300,15 @@ def get_auxiliary_extra_body() -> dict:
|
||||
return _nous_extra_body() if auxiliary_is_nous else {}
|
||||
|
||||
|
||||
def auxiliary_max_tokens_param(value: int) -> dict:
|
||||
def auxiliary_max_tokens_param(value: int, *, model: Optional[str] = None) -> 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, o-series, gpt-5+) requires 'max_completion_tokens'.
|
||||
models (gpt-4o, gpt-4.1, gpt-5+, o-series) requires 'max_completion_tokens'.
|
||||
The Codex adapter translates max_tokens internally, so we use max_tokens
|
||||
for it as well.
|
||||
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``.
|
||||
"""
|
||||
custom_base = _current_custom_base_url()
|
||||
or_key = os.getenv("OPENROUTER_API_KEY")
|
||||
@@ -4316,6 +4318,9 @@ def auxiliary_max_tokens_param(value: int) -> dict:
|
||||
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}
|
||||
|
||||
|
||||
|
||||
+2
-15
@@ -25,7 +25,6 @@ import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
import threading
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@@ -33,6 +32,7 @@ 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,20 +97,7 @@ def load_state() -> Dict[str, Any]:
|
||||
def save_state(data: Dict[str, Any]) -> None:
|
||||
path = _state_file()
|
||||
try:
|
||||
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
|
||||
atomic_json_write(path, data, indent=2, sort_keys=True)
|
||||
except Exception as e:
|
||||
logger.debug("Failed to save curator state: %s", e, exc_info=True)
|
||||
|
||||
|
||||
@@ -966,6 +966,34 @@ 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(
|
||||
|
||||
@@ -1838,6 +1838,17 @@ 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.
|
||||
|
||||
@@ -13,6 +13,7 @@ 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[
|
||||
@@ -570,6 +571,8 @@ 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":
|
||||
|
||||
@@ -40,6 +40,15 @@ 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
|
||||
@@ -284,7 +293,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, {
|
||||
const child = spawn(ps, fullArgs, hiddenWindowsChildOptions({
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -292,7 +301,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 = ''
|
||||
|
||||
@@ -107,6 +107,13 @@ 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
|
||||
@@ -1106,7 +1113,7 @@ function findSystemPython() {
|
||||
const out = execFileSync(
|
||||
'reg',
|
||||
['query', `${hive}\\SOFTWARE\\Python\\PythonCore\\${version}\\InstallPath`, '/ve', '/reg:64'],
|
||||
{ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] }
|
||||
hiddenWindowsChildOptions({ encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
)
|
||||
// Output format: " (Default) REG_SZ C:\Path\To\Python\"
|
||||
const match = out.match(/REG_SZ\s+(.+?)\s*$/m)
|
||||
@@ -1142,10 +1149,10 @@ function findSystemPython() {
|
||||
if (pyExe) {
|
||||
for (const version of SUPPORTED_VERSIONS) {
|
||||
try {
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], {
|
||||
const out = execFileSync(pyExe, [`-${version}`, '-c', 'import sys; print(sys.executable)'], hiddenWindowsChildOptions({
|
||||
encoding: 'utf8',
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
}))
|
||||
const candidate = out.trim()
|
||||
if (candidate && fileExists(candidate)) return candidate
|
||||
} catch {
|
||||
@@ -1280,11 +1287,11 @@ function resolveUpdateRoot() {
|
||||
|
||||
function runGit(args, options = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, {
|
||||
const child = spawn(resolveGitBinary(), IS_WINDOWS ? ['-c', 'windows.appendAtomically=false', ...args] : args, hiddenWindowsChildOptions({
|
||||
cwd: options.cwd,
|
||||
env: { ...process.env, ...(options.env || {}), GIT_TERMINAL_PROMPT: '0' },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}))
|
||||
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
@@ -1494,7 +1501,7 @@ function forceKillProcessTree(pid) {
|
||||
if (!IS_WINDOWS) return
|
||||
if (!Number.isInteger(pid) || pid <= 0) return
|
||||
try {
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' })
|
||||
execFileSync('taskkill', ['/PID', String(pid), '/T', '/F'], hiddenWindowsChildOptions({ stdio: 'ignore' }))
|
||||
} catch {
|
||||
// Already gone, or no permission — best effort; the unlock wait below is
|
||||
// the real gate.
|
||||
@@ -1680,11 +1687,11 @@ function runStreamedUpdate(command, args, { cwd, env, stage } = {}) {
|
||||
return new Promise(resolve => {
|
||||
let child
|
||||
try {
|
||||
child = spawn(command, args, {
|
||||
child = spawn(command, args, hiddenWindowsChildOptions({
|
||||
cwd,
|
||||
env: { ...process.env, ...(env || {}) },
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}))
|
||||
} catch (err) {
|
||||
resolve({ code: 1, error: err.message })
|
||||
return
|
||||
@@ -2671,7 +2678,7 @@ function fetchHtmlTitleWithCurl(rawUrl) {
|
||||
'--raw',
|
||||
url
|
||||
]
|
||||
const child = spawn('curl', args, { stdio: ['ignore', 'pipe', 'ignore'] })
|
||||
const child = spawn('curl', args, hiddenWindowsChildOptions({ stdio: ['ignore', 'pipe', 'ignore'] }))
|
||||
const chunks = []
|
||||
let bytes = 0
|
||||
|
||||
@@ -4491,7 +4498,7 @@ async function spawnPoolBackend(profile, entry) {
|
||||
|
||||
rememberLog(`Starting Hermes backend for profile "${profile}" via ${backend.label}`)
|
||||
|
||||
const child = spawn(backend.command, backend.args, {
|
||||
const child = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -4509,7 +4516,7 @@ async function spawnPoolBackend(profile, entry) {
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}))
|
||||
entry.process = child
|
||||
entry.port = port
|
||||
entry.token = token
|
||||
@@ -4691,7 +4698,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, {
|
||||
hermesProcess = spawn(backend.command, backend.args, hiddenWindowsChildOptions({
|
||||
cwd: hermesCwd,
|
||||
env: {
|
||||
...process.env,
|
||||
@@ -4714,7 +4721,7 @@ async function startHermes() {
|
||||
},
|
||||
shell: backend.shell,
|
||||
stdio: ['ignore', 'pipe', 'pipe']
|
||||
})
|
||||
}))
|
||||
|
||||
hermesProcess.stdout.on('data', rememberLog)
|
||||
hermesProcess.stderr.on('data', rememberLog)
|
||||
@@ -5986,11 +5993,11 @@ async function getUninstallSummary() {
|
||||
resolve(value)
|
||||
}
|
||||
try {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], {
|
||||
const child = spawn(py, ['-m', 'hermes_cli.main', 'uninstall', '--gui-summary'], hiddenWindowsChildOptions({
|
||||
cwd: agentRoot,
|
||||
env: { ...process.env, HERMES_HOME, NO_COLOR: '1' },
|
||||
stdio: ['ignore', 'pipe', 'ignore']
|
||||
})
|
||||
}))
|
||||
child.stdout.on('data', chunk => {
|
||||
stdout += chunk.toString()
|
||||
})
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
'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')
|
||||
})
|
||||
@@ -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",
|
||||
"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",
|
||||
"type-check": "tsc -b",
|
||||
"lint": "eslint src/ electron/",
|
||||
"lint:fix": "eslint src/ electron/ --fix",
|
||||
|
||||
@@ -38,6 +38,7 @@ 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'
|
||||
@@ -111,8 +112,7 @@ 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[] =
|
||||
typeof navigator !== 'undefined' && navigator.platform.toLowerCase().includes('mac') ? ['⌘', 'N'] : ['Ctrl', 'N']
|
||||
const NEW_SESSION_KBD: readonly string[] =normalizeCombo('mod+n')
|
||||
|
||||
const SIDEBAR_NAV: SidebarNavItem[] = [
|
||||
{
|
||||
|
||||
@@ -10,6 +10,7 @@ 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'
|
||||
@@ -133,11 +134,11 @@ export function SidebarSessionRow({
|
||||
return
|
||||
}
|
||||
|
||||
// ⌘-click (mac) / ⌃-click (win/linux) pops the chat into its own
|
||||
// ⌘-click (mac) / Ctrl-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.metaKey || event.ctrlKey) && canOpenSessionWindow()) {
|
||||
if (event[modKey] && canOpenSessionWindow()) {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
triggerHaptic('selection')
|
||||
|
||||
@@ -91,6 +91,7 @@ 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'
|
||||
@@ -271,7 +272,7 @@ export function DesktopController() {
|
||||
return
|
||||
}
|
||||
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
if (event[modKey] && !event.altKey && !event.shiftKey && event.key.toLowerCase() === 'w') {
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
closeActiveRightRailTab()
|
||||
|
||||
@@ -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,6 +1,7 @@
|
||||
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
|
||||
@@ -97,12 +98,10 @@ export function resolveSurfaceColor(fallback: string): string {
|
||||
return resolved && resolved !== 'rgba(0, 0, 0, 0)' ? resolved : fallback
|
||||
}
|
||||
|
||||
export const isMacPlatform = () => navigator.platform.toLowerCase().includes('mac')
|
||||
|
||||
export const addSelectionShortcutLabel = () => (isMacPlatform() ? '⌘L' : 'Ctrl+L')
|
||||
export const addSelectionShortcutLabel = formatCombo('mod+l')
|
||||
|
||||
export function isAddSelectionShortcut(event: KeyboardEvent) {
|
||||
const mod = isMacPlatform() ? event.metaKey : event.ctrlKey
|
||||
const mod = event[modKey]
|
||||
|
||||
return mod && !event.shiftKey && event.key.toLowerCase() === 'l'
|
||||
}
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
type KeybindActionMeta,
|
||||
type KeybindReadonly
|
||||
} from '@/lib/keybinds/actions'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
import { formatCombo, formatFakeCombo } 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}>
|
||||
{formatCombo(key)}
|
||||
{formatFakeCombo(key)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -722,8 +722,14 @@ 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'
|
||||
'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]'
|
||||
|
||||
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,6 +16,7 @@ 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'
|
||||
@@ -50,8 +51,6 @@ 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
|
||||
@@ -127,7 +126,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">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{formatCombo('mod+enter')}</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 ? (
|
||||
<Codicon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||
<ToolIcon className="text-(--ui-text-tertiary)" name={icon} size="0.875rem" />
|
||||
) : null
|
||||
|
||||
return node ? <span className={TOOL_HEADER_GLYPH_WRAP_CLASS}>{node}</span> : null
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
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()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,65 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { FIELD_DESCRIPTIONS, FIELD_LABELS } from '@/app/settings/constants'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
@@ -518,7 +519,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. Ctrl/⌘-click a chat in the sidebar to archive it.',
|
||||
`Archived chats are hidden from the sidebar but keep all their messages. ${formatCombo('mod')}-click a chat in the sidebar to archive it.`,
|
||||
emptyArchivedTitle: 'Nothing archived',
|
||||
emptyArchivedDesc: 'Archive a chat to hide it here.',
|
||||
unarchive: 'Unarchive',
|
||||
@@ -529,7 +530,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 (Ctrl/⌘+N) for it to take effect',
|
||||
defaultDirUpdated: `Default project directory updated — start a new chat (${formatCombo('mod+n')}) for it to take effect`,
|
||||
defaultsTo: label => `Defaults to ${label}.`,
|
||||
change: 'Change',
|
||||
choose: 'Choose',
|
||||
@@ -1677,7 +1678,7 @@ export const en: Translations = {
|
||||
loadingQuestion: 'Loading question…',
|
||||
other: 'Other (type your answer)',
|
||||
placeholder: 'Type your answer…',
|
||||
shortcut: '⌘/Ctrl + Enter to send',
|
||||
shortcut: `${formatCombo('mod+enter')} to send`,
|
||||
back: 'Back',
|
||||
skip: 'Skip',
|
||||
send: 'Send'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
@@ -642,7 +643,7 @@ export const ja = defineLocale({
|
||||
loading: 'アーカイブ済みセッションを読み込み中…',
|
||||
archivedTitle: 'アーカイブ済みセッション',
|
||||
archivedIntro:
|
||||
'アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを Ctrl/⌘ クリックするとアーカイブできます。',
|
||||
`アーカイブ済みチャットはサイドバーでは非表示になりますが、すべてのメッセージは保持されます。サイドバーのチャットを ${formatCombo('mod')} クリックするとアーカイブできます。`,
|
||||
emptyArchivedTitle: 'アーカイブがありません',
|
||||
emptyArchivedDesc: 'チャットをアーカイブするとここに表示されます。',
|
||||
unarchive: 'アーカイブを解除',
|
||||
@@ -1811,7 +1812,7 @@ export const ja = defineLocale({
|
||||
loadingQuestion: '質問を読み込み中…',
|
||||
other: 'その他(回答を入力)',
|
||||
placeholder: '回答を入力…',
|
||||
shortcut: '⌘/Ctrl + Enter で送信',
|
||||
shortcut: `${formatCombo('mod+enter')} で送信`,
|
||||
back: '戻る',
|
||||
skip: 'スキップ',
|
||||
send: '送信'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import { defineLocale } from './define-locale'
|
||||
|
||||
@@ -627,7 +628,7 @@ export const zhHant = defineLocale({
|
||||
loading: '正在載入已封存工作階段…',
|
||||
archivedTitle: '已封存工作階段',
|
||||
archivedIntro:
|
||||
'已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 Ctrl/⌘ 點擊聊天即可封存。',
|
||||
`已封存的聊天會從側邊欄隱藏,但保留全部訊息。在側邊欄 ${formatCombo('mod')} 點擊聊天即可封存。`,
|
||||
emptyArchivedTitle: '暫無封存',
|
||||
emptyArchivedDesc: '封存一個聊天後會顯示在這裡。',
|
||||
unarchive: '取消封存',
|
||||
@@ -1772,7 +1773,7 @@ export const zhHant = defineLocale({
|
||||
loadingQuestion: '正在載入問題…',
|
||||
other: '其他(輸入您的答案)',
|
||||
placeholder: '輸入您的答案…',
|
||||
shortcut: '⌘/Ctrl + Enter 傳送',
|
||||
shortcut: `${formatCombo('mod+enter')} 傳送`,
|
||||
back: '返回',
|
||||
skip: '略過',
|
||||
send: '傳送'
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { defineFieldCopy } from '@/app/settings/field-copy'
|
||||
import { formatCombo } from '@/lib/keybinds/combo'
|
||||
|
||||
import type { Translations } from './types'
|
||||
|
||||
@@ -712,7 +713,7 @@ export const zh: Translations = {
|
||||
sessions: {
|
||||
loading: '正在加载已归档会话…',
|
||||
archivedTitle: '已归档会话',
|
||||
archivedIntro: '已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 Ctrl/⌘ 点击对话即可归档。',
|
||||
archivedIntro: `已归档对话会从侧边栏隐藏,但会保留全部消息。在侧边栏 ${formatCombo('mod')} 点击对话即可归档。`,
|
||||
emptyArchivedTitle: '暂无归档',
|
||||
emptyArchivedDesc: '归档一个对话后会显示在这里。',
|
||||
unarchive: '取消归档',
|
||||
@@ -1856,7 +1857,7 @@ export const zh: Translations = {
|
||||
loadingQuestion: '正在加载问题…',
|
||||
other: '其他 (输入你的答案)',
|
||||
placeholder: '输入你的答案…',
|
||||
shortcut: '⌘/Ctrl + Enter 发送',
|
||||
shortcut: `${formatCombo('mod+enter')} 发送`,
|
||||
back: '返回',
|
||||
skip: '跳过',
|
||||
send: '发送'
|
||||
|
||||
@@ -5,6 +5,9 @@
|
||||
// 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
|
||||
@@ -27,15 +30,16 @@ export interface KeybindActionMeta {
|
||||
// `profile.default`) — ⌘` is macOS-reserved (window cycling) and ⌘0 is reset-zoom.
|
||||
export const PROFILE_SLOT_COUNT = 18
|
||||
|
||||
function comboForSlot(slot: number): string {
|
||||
return slot <= 9 ? `mod+${slot}` : `mod+alt+${slot - 9}`
|
||||
}
|
||||
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
|
||||
|
||||
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)]
|
||||
}))
|
||||
return ({
|
||||
id: `profile.switch.${i + 1}`,
|
||||
category: 'profiles' as const,
|
||||
defaults: [combo]
|
||||
})
|
||||
})
|
||||
|
||||
// ⌘` on macOS / Ctrl+` elsewhere (the `~` key), plus the Shift/tilde variant.
|
||||
// `mod` keeps one binding cross-platform; on macOS this shadows the system
|
||||
@@ -104,10 +108,12 @@ export function keybindAction(id: string): KeybindActionMeta | undefined {
|
||||
return ACTION_BY_ID.get(id)
|
||||
}
|
||||
|
||||
export type KeybindBindings = Record<string, string[]>
|
||||
export type KeybindBindings = Record<string, Combo[]>
|
||||
|
||||
export function defaultBindings(): KeybindBindings {
|
||||
return Object.fromEntries(KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults]]))
|
||||
return Object.fromEntries<string, Combo[]>(
|
||||
KEYBIND_ACTIONS.map(action => [action.id, [...action.defaults] as Combo[]])
|
||||
)
|
||||
}
|
||||
|
||||
// Fixed, non-rebindable shortcuts surfaced read-only in the panel so the map is
|
||||
@@ -117,7 +123,7 @@ export function defaultBindings(): KeybindBindings {
|
||||
export interface KeybindReadonly {
|
||||
id: string
|
||||
category: KeybindCategory
|
||||
keys: readonly string[]
|
||||
keys: readonly FakeCombo[]
|
||||
}
|
||||
|
||||
export const KEYBIND_READONLY: readonly KeybindReadonly[] = [
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
// Control+Tab. Off macOS, Control already *is* `mod`, so `canonicalizeCombo`
|
||||
// folds `ctrl` → `mod`.
|
||||
|
||||
export const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
const IS_MAC = typeof navigator !== 'undefined' && /mac/i.test(navigator.platform || navigator.userAgent || '')
|
||||
|
||||
export const modKey = IS_MAC ? 'metaKey' as const : 'ctrlKey' as const
|
||||
|
||||
// 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: Record<string, string> = {
|
||||
const CODE_TO_KEY = {
|
||||
Backquote: '`',
|
||||
Backslash: '\\',
|
||||
BracketLeft: '[',
|
||||
@@ -35,8 +37,50 @@ const CODE_TO_KEY: Record<string, string> = {
|
||||
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',
|
||||
@@ -48,42 +92,20 @@ 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): string | null {
|
||||
export function comboFromEvent(event: KeyboardEvent): Combo | null {
|
||||
if (MODIFIER_CODES.has(event.code)) {
|
||||
return null
|
||||
}
|
||||
|
||||
const base = baseKeyFromCode(event.code)
|
||||
const base = baseKeyFromCode(event.code as KeyCode)
|
||||
|
||||
if (!base) {
|
||||
return null
|
||||
}
|
||||
|
||||
const parts: string[] = []
|
||||
const parts: Combo[] = []
|
||||
|
||||
// macOS reports Cmd (`mod`) and Control (`ctrl`) separately; elsewhere
|
||||
// Control IS the accelerator, so it folds into `mod`.
|
||||
@@ -105,7 +127,7 @@ export function comboFromEvent(event: KeyboardEvent): string | null {
|
||||
|
||||
parts.push(base)
|
||||
|
||||
return parts.join('+')
|
||||
return parts.join('+') as Combo
|
||||
}
|
||||
|
||||
// Rewrites a binding to the form `comboFromEvent` emits, so it indexes under
|
||||
@@ -115,7 +137,14 @@ export function canonicalizeCombo(combo: string): string {
|
||||
return IS_MAC ? combo : combo.replace(/\bctrl\b/g, 'mod')
|
||||
}
|
||||
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
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 = {
|
||||
enter: '↵',
|
||||
escape: 'Esc',
|
||||
backspace: '⌫',
|
||||
@@ -124,39 +153,47 @@ const TOKEN_LABELS: Record<string, string> = {
|
||||
up: '↑',
|
||||
down: '↓',
|
||||
left: '←',
|
||||
right: '→'
|
||||
right: '→',
|
||||
} as const
|
||||
|
||||
const TOKEN_LABELS: Record<string, string> = {
|
||||
...MOD_LABELS,
|
||||
...FANCY_KEY_LABELS
|
||||
}
|
||||
|
||||
function labelForBase(base: string): string {
|
||||
if (TOKEN_LABELS[base]) {
|
||||
return TOKEN_LABELS[base]
|
||||
function labelForToken(token: string): string {
|
||||
if (TOKEN_LABELS[token]) {
|
||||
return TOKEN_LABELS[token]
|
||||
}
|
||||
|
||||
if (/^f\d{1,2}$/.test(base)) {
|
||||
return base.toUpperCase()
|
||||
if (/^f\d{1,2}$/.test(token)) {
|
||||
return token.toUpperCase()
|
||||
}
|
||||
|
||||
return base.length === 1 ? base.toUpperCase() : base
|
||||
return token.length === 1 ? token.toUpperCase() : token
|
||||
}
|
||||
|
||||
function labelForMod(mod: string): string {
|
||||
if (mod === 'mod') {
|
||||
return IS_MAC ? '⌘' : 'Ctrl'
|
||||
}
|
||||
//
|
||||
|
||||
if (mod === 'ctrl') {
|
||||
return IS_MAC ? '⌃' : 'Ctrl'
|
||||
}
|
||||
type ModKey = keyof typeof MOD_LABELS
|
||||
|
||||
if (mod === 'alt') {
|
||||
return IS_MAC ? '⌥' : 'Alt'
|
||||
}
|
||||
type ModPrefix = `${'mod+'|''}${'alt+'|''}${'shift+'|''}`
|
||||
|
||||
if (mod === 'shift') {
|
||||
return IS_MAC ? '⇧' : 'Shift'
|
||||
}
|
||||
type ModPrefixedCombo<Suffix extends string> =
|
||||
| `${ModPrefix}${Suffix}`
|
||||
| ModKey
|
||||
| 'mod+alt' | 'mod+shift' | 'alt+shift' | 'mod+alt+shift'
|
||||
| 'ctrl+tab' | 'ctrl+shift+tab'
|
||||
| `ctrl+${Digit}`
|
||||
|
||||
return mod
|
||||
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()))
|
||||
}
|
||||
|
||||
// Per-key display tokens, e.g. ["⌘", "K"] on macOS, ["Ctrl", "K"] elsewhere —
|
||||
@@ -165,14 +202,18 @@ export function comboTokens(combo: string): string[] {
|
||||
const parts = combo.split('+')
|
||||
const base = parts.pop() ?? ''
|
||||
|
||||
return [...parts.map(labelForMod), labelForBase(base)]
|
||||
return [...parts.map(labelForToken), labelForToken(base)]
|
||||
}
|
||||
|
||||
// Human-readable label, e.g. "⌘⇧K" on macOS, "Ctrl+Shift+K" elsewhere.
|
||||
export function formatCombo(combo: string): string {
|
||||
const tokens = comboTokens(combo)
|
||||
// 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 ? '' : '+')
|
||||
}
|
||||
|
||||
return IS_MAC ? tokens.join('') : tokens.join('+')
|
||||
|
||||
// like `formatCombo` but allows any input like `@`
|
||||
export function formatFakeCombo(combo: FakeCombo): string {
|
||||
return normalizeCombo(combo as Combo).join(IS_MAC ? '' : '+')
|
||||
}
|
||||
|
||||
// True when focus is in a text-entry surface, so bare-key shortcuts don't fire
|
||||
@@ -190,6 +231,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: string): boolean {
|
||||
export function comboAllowedInInput(combo: Combo): boolean {
|
||||
return /^(?:mod|ctrl)(?:\+|$)/.test(combo)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ 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'
|
||||
@@ -28,7 +29,7 @@ function loadBindings(): KeybindBindings {
|
||||
const value = parsed[id]
|
||||
|
||||
if (Array.isArray(value)) {
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string')
|
||||
base[id] = value.filter((combo): combo is string => typeof combo === 'string') as Combo[]
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
@@ -78,7 +79,7 @@ export const $comboIndex = computed($bindings, bindings => {
|
||||
return index
|
||||
})
|
||||
|
||||
export function setBinding(actionId: string, combos: string[]): void {
|
||||
export function setBinding(actionId: string, combos: Combo[]): void {
|
||||
if (!keybindAction(actionId)) {
|
||||
return
|
||||
}
|
||||
@@ -101,7 +102,7 @@ export function resetAllBindings(): void {
|
||||
}
|
||||
|
||||
// Other actions that already use `combo` (excluding `actionId` itself).
|
||||
export function conflictsFor(actionId: string, combo: string): string[] {
|
||||
export function conflictsFor(actionId: string, combo: Combo): string[] {
|
||||
const bindings = $bindings.get()
|
||||
|
||||
return KEYBIND_ACTION_IDS.filter(id => id !== actionId && (bindings[id] ?? []).includes(combo))
|
||||
|
||||
@@ -1218,23 +1218,6 @@ def main(
|
||||
# List available distributions
|
||||
python batch_runner.py --list_distributions
|
||||
"""
|
||||
# Cross-process hook delivery: start the forwarder if a dashboard
|
||||
# is reachable. No-op when no dashboard is running or when
|
||||
# ``HERMES_HOOK_FORWARDER=0`` is set. Currently the batch runner
|
||||
# emits no hooks directly (only the gateway and TUI do today), but
|
||||
# wiring here future-proofs batch-spawned agent runs that may emit
|
||||
# hooks via tools. Forwarder workers spawned by ``multiprocessing.Pool``
|
||||
# below would each need their own; that's deferred to a follow-up.
|
||||
# See gateway/hook_forwarder.py + DESIGN-cross-process-hooks.md.
|
||||
try:
|
||||
from gateway import hook_forwarder
|
||||
from gateway.hooks import get_default_registry
|
||||
hook_forwarder.start_if_dashboard_available(
|
||||
get_default_registry(), src="batch"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Handle list distributions
|
||||
if list_distributions:
|
||||
from toolset_distributions import print_distribution_info
|
||||
|
||||
+24
-1
@@ -415,7 +415,8 @@ prompt_caching:
|
||||
# Auxiliary Models (Advanced — Experimental)
|
||||
# =============================================================================
|
||||
# Hermes uses lightweight "auxiliary" models for side tasks: image analysis,
|
||||
# browser screenshot analysis, web page summarization, and context compression.
|
||||
# browser screenshot analysis, web page summarization, TTS audio-tag insertion,
|
||||
# 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
|
||||
@@ -460,6 +461,12 @@ 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"
|
||||
@@ -835,6 +842,22 @@ 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)
|
||||
# =============================================================================
|
||||
|
||||
@@ -890,6 +890,10 @@ 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
|
||||
@@ -989,11 +993,13 @@ 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:
|
||||
_notify_session_finalize(
|
||||
session_id=_active_agent_ref.session_id if _active_agent_ref else None,
|
||||
platform="cli",
|
||||
reason="shutdown",
|
||||
)
|
||||
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",
|
||||
)
|
||||
try:
|
||||
if _active_agent_ref and hasattr(_active_agent_ref, 'shutdown_memory_provider'):
|
||||
# Forward the agent's own transcript so memory providers'
|
||||
@@ -1011,6 +1017,14 @@ 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,
|
||||
@@ -1068,11 +1082,17 @@ 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)
|
||||
_notify_session_finalize(
|
||||
session_id=session_id,
|
||||
platform=getattr(agent, "platform", None) or "cli",
|
||||
reason=reason,
|
||||
)
|
||||
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)
|
||||
|
||||
|
||||
def _finalize_single_query(cli) -> None:
|
||||
@@ -6496,6 +6516,47 @@ 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()
|
||||
@@ -6672,7 +6733,14 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
custom_providers=state.get("custom_provs"),
|
||||
)
|
||||
self._close_model_picker()
|
||||
self._apply_model_switch_result(result, persist_global)
|
||||
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)
|
||||
return
|
||||
self._close_model_picker()
|
||||
|
||||
@@ -6773,6 +6841,10 @@ 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).
|
||||
@@ -10614,22 +10686,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
if not self._claim_active_session("cli"):
|
||||
return
|
||||
|
||||
# Cross-process hook delivery: start the forwarder if a
|
||||
# dashboard is reachable. No-op when no dashboard is running
|
||||
# or when ``HERMES_HOOK_FORWARDER=0`` is set. Currently the
|
||||
# CLI process emits no hooks directly (only the gateway and
|
||||
# TUI do today), but wiring here future-proofs CLI-spawned
|
||||
# agent runs that may emit hooks via tools. See
|
||||
# gateway/hook_forwarder.py + DESIGN-cross-process-hooks.md.
|
||||
try:
|
||||
from gateway import hook_forwarder
|
||||
from gateway.hooks import get_default_registry
|
||||
hook_forwarder.start_if_dashboard_available(
|
||||
get_default_registry(), src="cli"
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Detect light/dark terminal mode now (before pt grabs the tty).
|
||||
# Caches the result so subsequent _hex_to_ansi / style calls
|
||||
# don't risk re-querying mid-render.
|
||||
|
||||
@@ -207,6 +207,14 @@ 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"}:
|
||||
|
||||
@@ -1,450 +0,0 @@
|
||||
"""Cross-process hook forwarder.
|
||||
|
||||
Subscribes to a fixed set of namespaces on a process-local
|
||||
:class:`gateway.hooks.HookRegistry` and POSTs each fired event to the
|
||||
dashboard's ``/api/hooks/ingest`` endpoint. The dashboard republishes
|
||||
the event on its own default registry so plugins running in the
|
||||
dashboard process see events that originated in the gateway, TUI,
|
||||
subagent, or batch-runner processes.
|
||||
|
||||
Design constraints (see ``DESIGN-cross-process-hooks.md``):
|
||||
|
||||
* Never blocks the publisher. Handler enqueues onto a bounded queue
|
||||
and returns immediately; a daemon worker thread does the HTTP POSTs.
|
||||
* Bounded queue drops *oldest* on overflow. Observability events are
|
||||
best-effort; recency beats history when the dashboard is slow.
|
||||
* Loop prevention. Events whose context carries ``_forwarded=True``
|
||||
are skipped — those came from the ingest endpoint and must not be
|
||||
shipped back.
|
||||
* No-op when no dashboard is available. Discovery file absent ⇒ no
|
||||
registration, no thread. Probe re-checks every 30s so a dashboard
|
||||
that starts later auto-attaches.
|
||||
* ``HERMES_HOOK_FORWARDER=0`` short-circuits everything for paranoid
|
||||
security postures, or for tests that don't want the daemon thread.
|
||||
|
||||
The forwarder is wired in by long-lived non-dashboard processes
|
||||
(gateway, TUI, subagents, batch runners) via
|
||||
:func:`start_if_dashboard_available`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from queue import Empty, Full, Queue
|
||||
from typing import Any, Callable, Optional
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Namespaces the forwarder ships to the dashboard. Picked to cover every
|
||||
# event type the registry emits today. Adding a new namespace is a
|
||||
# one-line append here. Wildcards use the existing registry semantics
|
||||
# (``<namespace>:*`` matches every ``<namespace>:<anything>`` event).
|
||||
_FORWARDED_NAMESPACES = (
|
||||
"tui:*",
|
||||
"agent:*",
|
||||
"session:*",
|
||||
"command:*",
|
||||
"gateway:*",
|
||||
)
|
||||
|
||||
# Bounded queue size per source process. At peak (~70 events/sec, see the
|
||||
# design doc's "Performance" section) this is ~14 seconds of backlog
|
||||
# before drop-oldest kicks in. Generous for a purely-observability feed.
|
||||
_QUEUE_MAX = 1024
|
||||
|
||||
# Probe cadence. When the dashboard isn't running we re-check the
|
||||
# discovery file every 30s so a delayed ``hermes dashboard`` startup
|
||||
# eventually attaches. Cheap (one stat + one health GET) so this can
|
||||
# run forever without overhead.
|
||||
_PROBE_INTERVAL_S = 30.0
|
||||
|
||||
# HTTP timeouts. Connection is loopback in the common case so anything
|
||||
# beyond 2s probably means the dashboard is wedged; better to drop the
|
||||
# frame than queue up retries that won't help.
|
||||
_HTTP_TIMEOUT_S = 2.0
|
||||
|
||||
# Error-logging cadence. POST failures get logged once per minute, not
|
||||
# per failure, so a downed dashboard doesn't spam ``agent.log``.
|
||||
_ERROR_LOG_INTERVAL_S = 60.0
|
||||
|
||||
|
||||
def _dashboard_discovery_path() -> Path:
|
||||
"""Return the path the dashboard writes its discovery JSON to.
|
||||
|
||||
Always under ``$HERMES_HOME``; no ``/tmp`` fallback. Config
|
||||
consistency across processes is a precondition, not something the
|
||||
forwarder patches over.
|
||||
"""
|
||||
return get_hermes_home() / "dashboard.json"
|
||||
|
||||
|
||||
def _read_discovery_file() -> Optional[dict]:
|
||||
"""Read and parse ``dashboard.json``.
|
||||
|
||||
Returns ``None`` when the file is absent, unreadable, malformed, or
|
||||
missing required keys. Never raises — callers treat a ``None``
|
||||
result as "no dashboard available" and re-probe on the next cycle.
|
||||
"""
|
||||
path = _dashboard_discovery_path()
|
||||
try:
|
||||
if not path.exists():
|
||||
return None
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return None
|
||||
if not isinstance(data, dict):
|
||||
return None
|
||||
if "url" not in data or "hooks_ingest_token" not in data:
|
||||
return None
|
||||
return data
|
||||
|
||||
|
||||
def _disabled_by_env() -> bool:
|
||||
"""``HERMES_HOOK_FORWARDER=0`` (or ``false``/``no``) short-circuits."""
|
||||
val = os.environ.get("HERMES_HOOK_FORWARDER", "").strip().lower()
|
||||
return val in ("0", "false", "no", "off")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HookForwarder — per-process singleton-ish
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _HookForwarder:
|
||||
"""The actual forwarder.
|
||||
|
||||
Tracks the registry it's attached to, the unregister callables (so
|
||||
:meth:`stop` can clean up), and the worker thread state.
|
||||
|
||||
Multiple instances are technically supported but in practice each
|
||||
process holds at most one — see :func:`start_if_dashboard_available`
|
||||
and the module-level ``_active`` reference.
|
||||
"""
|
||||
|
||||
def __init__(self, src: str) -> None:
|
||||
self.src = src
|
||||
self._queue: Queue[dict] = Queue(maxsize=_QUEUE_MAX)
|
||||
self._unregisters: list[Callable[[], None]] = []
|
||||
self._stop = threading.Event()
|
||||
self._worker: Optional[threading.Thread] = None
|
||||
# Discovery state — refreshed by the worker thread before each
|
||||
# POST so a dashboard restart (with a new token) auto-recovers.
|
||||
self._discovery: Optional[dict] = None
|
||||
self._discovery_lock = threading.Lock()
|
||||
# Error-log rate limit state.
|
||||
self._last_error_log_at: float = 0.0
|
||||
self._error_count_since_log: int = 0
|
||||
|
||||
# -- registry side ---------------------------------------------------
|
||||
|
||||
def _handler(self, event_type: str, context: dict) -> None:
|
||||
"""Sync hook handler — enqueues the event for the worker thread."""
|
||||
# Loop prevention: events forwarded *into* this process must not
|
||||
# be shipped back. The dashboard's ingest endpoint stamps every
|
||||
# republished context with ``_forwarded=True``.
|
||||
if context.get("_forwarded") is True:
|
||||
return
|
||||
try:
|
||||
self._queue.put_nowait(
|
||||
{"event_type": event_type, "context": context, "src": self.src}
|
||||
)
|
||||
except Full:
|
||||
# Drop oldest, enqueue newest. Observability is best-effort.
|
||||
try:
|
||||
self._queue.get_nowait()
|
||||
except Empty:
|
||||
pass
|
||||
try:
|
||||
self._queue.put_nowait(
|
||||
{"event_type": event_type, "context": context, "src": self.src}
|
||||
)
|
||||
except Full:
|
||||
# Pathological — worker isn't draining at all. Give up
|
||||
# silently; next event will overwrite this slot.
|
||||
pass
|
||||
|
||||
def register(self, registry: "object") -> None:
|
||||
"""Register the handler for every forwarded namespace."""
|
||||
for pattern in _FORWARDED_NAMESPACES:
|
||||
unreg = registry.register( # type: ignore[union-attr]
|
||||
pattern,
|
||||
self._handler,
|
||||
name=f"hook_forwarder({pattern}→dashboard)",
|
||||
)
|
||||
self._unregisters.append(unreg)
|
||||
|
||||
def unregister(self) -> None:
|
||||
"""Remove every handler the forwarder installed. Idempotent."""
|
||||
while self._unregisters:
|
||||
try:
|
||||
self._unregisters.pop()()
|
||||
except Exception: # pragma: no cover — defensive
|
||||
pass
|
||||
|
||||
# -- worker thread ---------------------------------------------------
|
||||
|
||||
def start_worker(self) -> None:
|
||||
"""Spawn the daemon worker thread that drains the queue."""
|
||||
if self._worker is not None and self._worker.is_alive():
|
||||
return
|
||||
self._stop.clear()
|
||||
self._worker = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
name=f"hook-forwarder-{self.src}",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker.start()
|
||||
|
||||
def stop_worker(self, *, join_timeout: float = 1.0) -> None:
|
||||
"""Signal the worker to exit and wait briefly for it to do so.
|
||||
|
||||
Idempotent. Safe to call from any thread. Daemon threads die
|
||||
with the process anyway; the join is only there so tests don't
|
||||
race on lingering threads between cases.
|
||||
"""
|
||||
self._stop.set()
|
||||
if self._worker is not None:
|
||||
try:
|
||||
self._worker.join(timeout=join_timeout)
|
||||
except RuntimeError:
|
||||
pass
|
||||
|
||||
def _worker_loop(self) -> None:
|
||||
"""Drain the queue, POSTing each frame, until ``_stop`` is set."""
|
||||
# httpx is imported lazily so processes that never produce events
|
||||
# don't pay the import cost. In practice the gateway and TUI
|
||||
# both have httpx loaded by the time we get here, but defer
|
||||
# anyway to be polite.
|
||||
import httpx
|
||||
|
||||
next_probe_at: float = 0.0
|
||||
|
||||
with httpx.Client(timeout=_HTTP_TIMEOUT_S) as client:
|
||||
while not self._stop.is_set():
|
||||
# Probe the discovery file periodically so a dashboard
|
||||
# that comes up later (or restarts with a new token)
|
||||
# auto-attaches.
|
||||
now = time.monotonic()
|
||||
if now >= next_probe_at:
|
||||
next_probe_at = now + _PROBE_INTERVAL_S
|
||||
self._refresh_discovery(client)
|
||||
|
||||
# Block on the queue. Short timeout so we re-check the
|
||||
# stop flag and probe interval often enough.
|
||||
try:
|
||||
frame = self._queue.get(timeout=1.0)
|
||||
except Empty:
|
||||
continue
|
||||
|
||||
with self._discovery_lock:
|
||||
discovery = self._discovery
|
||||
|
||||
if discovery is None:
|
||||
# No dashboard available right now. Drop the frame;
|
||||
# the next probe will re-acquire discovery, and any
|
||||
# events fired between now and then are lost (the
|
||||
# design accepts best-effort delivery).
|
||||
continue
|
||||
|
||||
self._post_frame(client, discovery, frame)
|
||||
|
||||
def _refresh_discovery(self, client: "Any") -> None: # client: httpx.Client
|
||||
"""Reload the discovery file and probe the dashboard's health.
|
||||
|
||||
Updates ``self._discovery`` to the new value (or ``None`` if the
|
||||
dashboard is unreachable). Called from the worker thread.
|
||||
"""
|
||||
data = _read_discovery_file()
|
||||
if data is None:
|
||||
with self._discovery_lock:
|
||||
self._discovery = None
|
||||
return
|
||||
|
||||
url = data["url"].rstrip("/")
|
||||
try:
|
||||
resp = client.get(f"{url}/api/hooks/health", timeout=_HTTP_TIMEOUT_S)
|
||||
if resp.status_code != 200:
|
||||
with self._discovery_lock:
|
||||
self._discovery = None
|
||||
return
|
||||
except Exception:
|
||||
with self._discovery_lock:
|
||||
self._discovery = None
|
||||
return
|
||||
|
||||
with self._discovery_lock:
|
||||
self._discovery = data
|
||||
|
||||
def _post_frame(
|
||||
self,
|
||||
client: "Any", # httpx.Client
|
||||
discovery: dict,
|
||||
frame: dict,
|
||||
) -> None:
|
||||
"""POST one frame to ``/api/hooks/ingest``.
|
||||
|
||||
Errors are logged at most once per minute, regardless of how
|
||||
many failures accumulate. A 401 invalidates the cached
|
||||
discovery so the next probe re-reads the token (which may have
|
||||
rotated on a dashboard restart).
|
||||
"""
|
||||
url = discovery["url"].rstrip("/") + "/api/hooks/ingest"
|
||||
token = discovery["hooks_ingest_token"]
|
||||
# Filter the context: ``_forwarded`` etc. are added by the
|
||||
# dashboard on republish. Source-side contexts are passed as-is,
|
||||
# but they should never carry ``_forwarded=True`` (that's how
|
||||
# loop prevention works above).
|
||||
try:
|
||||
resp = client.post(
|
||||
url,
|
||||
json={
|
||||
"event_type": frame["event_type"],
|
||||
"context": frame["context"],
|
||||
"src": frame["src"],
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
timeout=_HTTP_TIMEOUT_S,
|
||||
)
|
||||
except Exception as exc:
|
||||
self._log_error(f"POST {url} failed: {exc}")
|
||||
return
|
||||
|
||||
if resp.status_code == 401:
|
||||
# Token rotated (dashboard restarted with a new one).
|
||||
# Invalidate the cached discovery; the next probe will
|
||||
# re-read the file and pick up the new token.
|
||||
self._log_error(
|
||||
"POST /api/hooks/ingest returned 401 — token rotated; "
|
||||
"invalidating discovery cache"
|
||||
)
|
||||
with self._discovery_lock:
|
||||
self._discovery = None
|
||||
return
|
||||
if resp.status_code >= 400:
|
||||
self._log_error(
|
||||
f"POST /api/hooks/ingest returned {resp.status_code}: "
|
||||
f"{resp.text[:200]!r}"
|
||||
)
|
||||
|
||||
def _log_error(self, message: str) -> None:
|
||||
"""Rate-limited error logging — once per minute per process."""
|
||||
self._error_count_since_log += 1
|
||||
now = time.monotonic()
|
||||
if now - self._last_error_log_at < _ERROR_LOG_INTERVAL_S:
|
||||
return
|
||||
suppressed = self._error_count_since_log - 1
|
||||
suffix = f" ({suppressed} similar errors suppressed)" if suppressed else ""
|
||||
_log.warning("[hook_forwarder] %s%s", message, suffix)
|
||||
self._last_error_log_at = now
|
||||
self._error_count_since_log = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Module-level singleton
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Per-process forwarder. Multiple ``start_if_dashboard_available`` calls
|
||||
# in the same process are idempotent (they re-use this instance).
|
||||
_active: Optional[_HookForwarder] = None
|
||||
_active_lock = threading.Lock()
|
||||
|
||||
|
||||
def start_if_dashboard_available(
|
||||
registry: "object",
|
||||
*,
|
||||
src: str = "unknown",
|
||||
) -> Optional[_HookForwarder]:
|
||||
"""Wire the forwarder into ``registry`` if a dashboard is reachable.
|
||||
|
||||
Idempotent: repeated calls in the same process are no-ops. Returns
|
||||
the active forwarder if one is running (the same instance on every
|
||||
subsequent call within the process), or ``None`` if the forwarder
|
||||
was suppressed (no dashboard, or ``HERMES_HOOK_FORWARDER=0``).
|
||||
|
||||
The ``src`` argument is a short tag identifying the source process
|
||||
(``"gateway"``, ``"tui"``, ``"subagent"``, ``"batch"``). It's
|
||||
included in the wire frame for the dashboard's diagnostic logging
|
||||
and the republished context's ``_forwarded_from`` field, so a
|
||||
subscriber can tell which process originated each event.
|
||||
|
||||
Args:
|
||||
registry: The :class:`gateway.hooks.HookRegistry` whose events
|
||||
should be forwarded. Normally
|
||||
:func:`gateway.hooks.get_default_registry`.
|
||||
src: Source-process tag. See above.
|
||||
|
||||
Returns:
|
||||
The active forwarder, or ``None`` if forwarding is suppressed.
|
||||
"""
|
||||
global _active
|
||||
|
||||
if _disabled_by_env():
|
||||
_log.debug(
|
||||
"[hook_forwarder] HERMES_HOOK_FORWARDER=0 — skipping start"
|
||||
)
|
||||
return None
|
||||
|
||||
discovery = _read_discovery_file()
|
||||
if discovery is None:
|
||||
_log.debug(
|
||||
"[hook_forwarder] no dashboard.json — forwarder not started"
|
||||
)
|
||||
return None
|
||||
|
||||
with _active_lock:
|
||||
if _active is not None:
|
||||
# Already started in this process; nothing to do.
|
||||
return _active
|
||||
|
||||
fwd = _HookForwarder(src=src)
|
||||
try:
|
||||
fwd.register(registry)
|
||||
except Exception as e:
|
||||
_log.warning(
|
||||
"[hook_forwarder] failed to register handlers: %s", e
|
||||
)
|
||||
return None
|
||||
|
||||
fwd.start_worker()
|
||||
_active = fwd
|
||||
_log.info(
|
||||
"[hook_forwarder] started for src=%s, forwarding %d namespaces",
|
||||
src,
|
||||
len(_FORWARDED_NAMESPACES),
|
||||
)
|
||||
return fwd
|
||||
|
||||
|
||||
def stop() -> None:
|
||||
"""Tear down the active forwarder. Idempotent.
|
||||
|
||||
Primarily for tests; production processes just rely on the daemon
|
||||
thread dying with the process.
|
||||
"""
|
||||
global _active
|
||||
with _active_lock:
|
||||
if _active is None:
|
||||
return
|
||||
_active.unregister()
|
||||
_active.stop_worker()
|
||||
_active = None
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Test helper — clears active state without preserving its
|
||||
side-effects (registered handlers etc.)."""
|
||||
stop()
|
||||
|
||||
|
||||
def is_active() -> bool:
|
||||
"""Return whether a forwarder is currently registered in this process."""
|
||||
return _active is not None
|
||||
+12
-236
@@ -2,40 +2,19 @@
|
||||
Event Hook System
|
||||
|
||||
A lightweight event-driven system that fires handlers at key lifecycle points.
|
||||
Hooks are discovered from ~/.hermes/hooks/ directories, each containing:
|
||||
- HOOK.yaml (metadata: name, description, events list)
|
||||
- handler.py (Python handler with async def handle(event_type, context))
|
||||
|
||||
There are two ways to register a handler:
|
||||
|
||||
1. **File-system discovery** — drop a directory into ``~/.hermes/hooks/``
|
||||
containing ``HOOK.yaml`` (metadata: name, description, events list) and
|
||||
``handler.py`` (with ``def handle(event_type, context)``, sync or async).
|
||||
These are loaded by :meth:`HookRegistry.discover_and_load` at gateway
|
||||
startup.
|
||||
|
||||
2. **Programmatic registration** — call :meth:`HookRegistry.register` from
|
||||
inside the process. Useful for plugins that ship their own bundled hooks
|
||||
without expecting the user to maintain a ``~/.hermes/hooks/`` entry. Pairs
|
||||
with :func:`get_default_registry` so plugins don't have to hold a registry
|
||||
reference threaded through every call site.
|
||||
|
||||
Events fired today:
|
||||
|
||||
- ``gateway:startup`` — Gateway process starts
|
||||
- ``session:start`` — New session created (first message of a new session)
|
||||
- ``session:end`` — Session ends (user ran /new or /reset)
|
||||
- ``session:reset`` — Session reset completed (new session entry created)
|
||||
- ``agent:start`` — Agent begins processing a message
|
||||
- ``agent:step`` — Each turn in the tool-calling loop
|
||||
- ``agent:end`` — Agent finishes processing
|
||||
- ``command:*`` — Any slash command executed (wildcard match)
|
||||
- ``tui:<sub-event>`` — Any TUI gateway dispatch event mirrored to the
|
||||
bus (``tui:tool.start``, ``tui:message.delta``,
|
||||
``tui:reasoning.available``, etc.). Subscribe
|
||||
with the full name for one event, or
|
||||
``tui:*`` for all of them.
|
||||
|
||||
Wildcards match one colon-separated namespace level: a handler registered for
|
||||
``foo:*`` fires for every ``foo:<anything>`` event, but not for
|
||||
``bar:something``.
|
||||
Events:
|
||||
- gateway:startup -- Gateway process starts
|
||||
- session:start -- New session created (first message of a new session)
|
||||
- session:end -- Session ends (user ran /new or /reset)
|
||||
- session:reset -- Session reset completed (new session entry created)
|
||||
- agent:start -- Agent begins processing a message
|
||||
- agent:step -- Each turn in the tool-calling loop
|
||||
- agent:end -- Agent finishes processing
|
||||
- command:* -- Any slash command executed (wildcard match)
|
||||
|
||||
Errors in hooks are caught and logged but never block the main pipeline.
|
||||
|
||||
@@ -70,12 +49,6 @@ from hermes_cli.config import get_hermes_home
|
||||
HOOKS_DIR = get_hermes_home() / "hooks"
|
||||
|
||||
|
||||
# Tracks handler functions we've already warned about for emit_sync's
|
||||
# async-without-loop case, so each bad combination only logs once per
|
||||
# process instead of flooding stderr on every event.
|
||||
_ASYNC_NO_LOOP_WARNED: "set[int]" = set()
|
||||
|
||||
|
||||
class HookRegistry:
|
||||
"""
|
||||
Discovers, loads, and fires event hooks.
|
||||
@@ -96,60 +69,6 @@ class HookRegistry:
|
||||
"""Return metadata about all loaded hooks."""
|
||||
return list(self._loaded_hooks)
|
||||
|
||||
def register(
|
||||
self,
|
||||
event_type: str,
|
||||
handler: Callable,
|
||||
*,
|
||||
name: Optional[str] = None,
|
||||
) -> Callable[[], None]:
|
||||
"""Programmatically register a handler for ``event_type``.
|
||||
|
||||
Intended for in-process plugins, tests, and built-in hooks. Pairs with
|
||||
the file-system discovery path (HOOK.yaml + handler.py) — both share
|
||||
the same dispatch and wildcard rules.
|
||||
|
||||
The handler signature matches discovered hooks: ``handle(event_type,
|
||||
context)`` where ``handler`` may be sync or async.
|
||||
|
||||
Returns a callable that, when invoked, removes this specific handler
|
||||
registration from the registry. Other handlers for the same event are
|
||||
unaffected.
|
||||
|
||||
Args:
|
||||
event_type: Event identifier such as ``agent:start`` or
|
||||
``tui:tool.start``. May also be a wildcard like ``command:*``.
|
||||
handler: Function or coroutine function to invoke when the
|
||||
event fires.
|
||||
name: Optional friendly name recorded alongside the
|
||||
registration metadata for listing/debugging. Defaults to the
|
||||
handler's ``__name__``.
|
||||
|
||||
Returns:
|
||||
A no-arg callable that unregisters this handler when called.
|
||||
"""
|
||||
self._handlers.setdefault(event_type, []).append(handler)
|
||||
|
||||
meta = {
|
||||
"name": name or getattr(handler, "__name__", "<anonymous>"),
|
||||
"description": "(registered programmatically)",
|
||||
"events": [event_type],
|
||||
"path": "<programmatic>",
|
||||
}
|
||||
self._loaded_hooks.append(meta)
|
||||
|
||||
def _unregister() -> None:
|
||||
try:
|
||||
self._handlers.get(event_type, []).remove(handler)
|
||||
except ValueError:
|
||||
pass
|
||||
try:
|
||||
self._loaded_hooks.remove(meta)
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
return _unregister
|
||||
|
||||
def _register_builtin_hooks(self) -> None:
|
||||
"""Register built-in hooks that are always active.
|
||||
|
||||
@@ -306,146 +225,3 @@ class HookRegistry:
|
||||
except Exception as e:
|
||||
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
|
||||
return results
|
||||
|
||||
def emit_sync(
|
||||
self,
|
||||
event_type: str,
|
||||
context: Optional[Dict[str, Any]] = None,
|
||||
) -> None:
|
||||
"""Fire handlers from a synchronous caller.
|
||||
|
||||
Companion to :meth:`emit` for hot-path callers that cannot await — most
|
||||
notably ``tui_gateway/server.py:_emit``, which serves both async dispatch
|
||||
paths and sync callback paths and must remain ``def`` (not ``async
|
||||
def``).
|
||||
|
||||
Behavior:
|
||||
|
||||
- Sync handlers run immediately, in registration order. Exceptions are
|
||||
caught and logged so a buggy handler can't break the host pipeline.
|
||||
- Async handlers (coroutine functions) are scheduled via
|
||||
``asyncio.ensure_future`` if a running event loop is available in the
|
||||
current thread. If no loop is running, the handler is **skipped** and
|
||||
a one-time warning is logged per handler — async handlers in a
|
||||
purely sync process don't have a way to make forward progress.
|
||||
|
||||
Like :meth:`emit`, never raises and never blocks waiting on async
|
||||
handlers — fire-and-forget for the async case.
|
||||
|
||||
Args:
|
||||
event_type: The event identifier (e.g. ``tui:tool.start``).
|
||||
context: Optional dict with event-specific data.
|
||||
"""
|
||||
if context is None:
|
||||
context = {}
|
||||
|
||||
for fn in self._resolve_handlers(event_type):
|
||||
try:
|
||||
result = fn(event_type, context)
|
||||
except Exception as e:
|
||||
print(f"[hooks] Error in handler for '{event_type}': {e}", flush=True)
|
||||
continue
|
||||
|
||||
if not asyncio.iscoroutine(result):
|
||||
continue
|
||||
|
||||
# Coroutine returned — needs a loop to make progress.
|
||||
try:
|
||||
loop = asyncio.get_running_loop()
|
||||
except RuntimeError:
|
||||
# No running loop in this thread.
|
||||
handler_id = id(fn)
|
||||
if handler_id not in _ASYNC_NO_LOOP_WARNED:
|
||||
_ASYNC_NO_LOOP_WARNED.add(handler_id)
|
||||
handler_name = getattr(fn, "__name__", "<anonymous>")
|
||||
print(
|
||||
f"[hooks] Skipping async handler {handler_name!r} for "
|
||||
f"'{event_type}' — emit_sync called with no running "
|
||||
f"event loop. Subsequent skips for this handler are "
|
||||
f"silent.",
|
||||
flush=True,
|
||||
)
|
||||
# Close the coroutine to suppress "coroutine was never
|
||||
# awaited" RuntimeWarning noise.
|
||||
try:
|
||||
result.close()
|
||||
except Exception:
|
||||
pass
|
||||
continue
|
||||
|
||||
try:
|
||||
# ensure_future schedules the coroutine on the loop and
|
||||
# returns immediately. Exceptions inside the coroutine
|
||||
# surface via the task's done callback (or asyncio's
|
||||
# default exception handler) — we don't await here.
|
||||
task = asyncio.ensure_future(result, loop=loop)
|
||||
task.add_done_callback(_log_task_exception)
|
||||
except Exception as e:
|
||||
print(
|
||||
f"[hooks] Failed to schedule async handler for "
|
||||
f"'{event_type}': {e}",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
|
||||
def _log_task_exception(task: "asyncio.Task[Any]") -> None:
|
||||
"""Surface exceptions from scheduled async hook handlers.
|
||||
|
||||
Without this callback, an exception inside a fire-and-forget handler
|
||||
coroutine becomes "Task exception was never retrieved" noise from
|
||||
asyncio's default exception handler at GC time. Logging it explicitly
|
||||
keeps the failure mode visible and consistent with the sync path.
|
||||
"""
|
||||
if task.cancelled():
|
||||
return
|
||||
exc = task.exception()
|
||||
if exc is not None:
|
||||
print(f"[hooks] Async handler raised: {exc}", flush=True)
|
||||
|
||||
|
||||
# ── Module-level default registry ──────────────────────────────────
|
||||
#
|
||||
# Plugins and in-process callers (TUI gateway's ``_emit`` etc.) need a
|
||||
# stable place to find "the" registry without threading a reference
|
||||
# through every API. The gateway process installs its own
|
||||
# ``self.hooks`` instance as the default during startup so file-system
|
||||
# discovery and built-in hooks share state with programmatic
|
||||
# registrations. Other processes (TUI) lazily get their own default on
|
||||
# first access and run ``discover_and_load()`` themselves.
|
||||
|
||||
_default_registry: Optional["HookRegistry"] = None
|
||||
|
||||
|
||||
def get_default_registry() -> "HookRegistry":
|
||||
"""Return the process-wide default :class:`HookRegistry`.
|
||||
|
||||
Lazily creates one (without auto-running discovery) on first call. Callers
|
||||
that need file-system hook discovery should invoke
|
||||
:meth:`HookRegistry.discover_and_load` themselves after first access — the
|
||||
gateway already does this for the registry it installs as the default.
|
||||
"""
|
||||
global _default_registry
|
||||
if _default_registry is None:
|
||||
_default_registry = HookRegistry()
|
||||
return _default_registry
|
||||
|
||||
|
||||
def install_as_default(registry: "HookRegistry") -> None:
|
||||
"""Install ``registry`` as the process-wide default.
|
||||
|
||||
Intended for the gateway and other long-lived hosts that want their own
|
||||
:class:`HookRegistry` instance to be visible to in-process plugins through
|
||||
:func:`get_default_registry`. Idempotent — installing the same registry
|
||||
twice is a no-op; installing a different registry replaces the previous
|
||||
default.
|
||||
"""
|
||||
global _default_registry
|
||||
_default_registry = registry
|
||||
|
||||
|
||||
def _reset_default_registry_for_tests() -> None:
|
||||
"""Test helper — clears the cached default so each test starts fresh."""
|
||||
global _default_registry
|
||||
_default_registry = None
|
||||
_ASYNC_NO_LOOP_WARNED.clear()
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ _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:
|
||||
@@ -1803,6 +1804,18 @@ 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
|
||||
@@ -4462,6 +4475,15 @@ 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).
|
||||
#
|
||||
@@ -4489,11 +4511,12 @@ class BasePlatformAdapter(ABC):
|
||||
try:
|
||||
_post_result = _post_cb()
|
||||
if inspect.isawaitable(_post_result):
|
||||
await _post_result
|
||||
except Exception:
|
||||
await asyncio.wait_for(
|
||||
_post_result,
|
||||
timeout=_POST_DELIVERY_CALLBACK_TIMEOUT_SECONDS,
|
||||
)
|
||||
except (asyncio.TimeoutError, 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:
|
||||
@@ -4651,6 +4674,7 @@ 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
|
||||
@@ -4671,6 +4695,7 @@ 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
|
||||
|
||||
@@ -422,6 +422,11 @@ 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.
|
||||
@@ -1350,11 +1355,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)
|
||||
|
||||
@@ -318,6 +318,11 @@ 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)
|
||||
@@ -2692,19 +2697,26 @@ 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":warning: *Command Approval Required*\n"
|
||||
f"```{cmd_preview}```\n"
|
||||
f"Reason: {description}"
|
||||
),
|
||||
"text": f"{header}```{cmd_preview}```\n{reason}",
|
||||
},
|
||||
},
|
||||
{
|
||||
@@ -2772,8 +2784,13 @@ 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}"
|
||||
@@ -2783,7 +2800,7 @@ class SlackAdapter(BasePlatformAdapter):
|
||||
"type": "section",
|
||||
"text": {
|
||||
"type": "mrkdwn",
|
||||
"text": f"*{title or 'Confirm'}*\n\n{body}",
|
||||
"text": f"*{_title}*\n\n{body}",
|
||||
},
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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:/mb:/mx:/mg:)."""
|
||||
"""Handle model picker inline keyboard callbacks (mp:/mm:/mc:/mb:/mx:/mg:)."""
|
||||
state = self._model_picker_state.get(chat_id)
|
||||
if not state:
|
||||
await query.answer(text="Picker expired — use /model again.")
|
||||
@@ -3115,6 +3115,55 @@ 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:
|
||||
@@ -3136,11 +3185,43 @@ 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:
|
||||
@@ -3159,7 +3240,9 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
await query.answer(text="Model switched!")
|
||||
await query.answer(
|
||||
text="Switch failed." if switch_failed else "Model switched!"
|
||||
)
|
||||
|
||||
# Clean up state
|
||||
self._model_picker_state.pop(chat_id, None)
|
||||
@@ -3260,7 +3343,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
query_user_name = getattr(query.from_user, "first_name", None)
|
||||
|
||||
# --- Model picker callbacks ---
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mb", "mx", "mg:")):
|
||||
if data.startswith(("mp:", "mpg:", "mm:", "mc:", "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)
|
||||
|
||||
+31
-25
@@ -2144,23 +2144,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
self.pairing_store = PairingStore()
|
||||
|
||||
# Event hook system
|
||||
from gateway.hooks import HookRegistry, install_as_default
|
||||
from gateway.hooks import HookRegistry
|
||||
self.hooks = HookRegistry()
|
||||
# Expose this registry as the process-wide default so in-process
|
||||
# plugins (and any other component that uses ``get_default_registry()``)
|
||||
# share state with file-system-discovered hooks loaded into ``self.hooks``.
|
||||
install_as_default(self.hooks)
|
||||
# Cross-process delivery: start the hook forwarder if a
|
||||
# dashboard is reachable. No-op when the dashboard isn't
|
||||
# running, or when ``HERMES_HOOK_FORWARDER=0`` is set. See
|
||||
# gateway/hook_forwarder.py + DESIGN-cross-process-hooks.md.
|
||||
try:
|
||||
from gateway import hook_forwarder
|
||||
hook_forwarder.start_if_dashboard_available(self.hooks, src="gateway")
|
||||
except Exception as e: # pragma: no cover — defensive
|
||||
# Forwarder failure must never break gateway startup. Log
|
||||
# and move on; dashboard plugins just won't see gateway events.
|
||||
print(f"[gateway] hook forwarder start failed: {e}", flush=True)
|
||||
|
||||
# Per-chat voice reply mode: "off" | "voice_only" | "all"
|
||||
self._voice_mode: Dict[str, str] = self._load_voice_modes()
|
||||
@@ -6488,6 +6473,12 @@ 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"}:
|
||||
@@ -6496,11 +6487,11 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_confirm_choice = "always"
|
||||
elif _cmd_reply in {"cancel", "no", "deny", "nevermind"}:
|
||||
_confirm_choice = "cancel"
|
||||
elif _raw_reply.lower() in {"approve", "approve once", "once"}:
|
||||
elif _norm_reply in {"approve", "approve once", "once"}:
|
||||
_confirm_choice = "once"
|
||||
elif _raw_reply.lower() in {"always", "always approve"}:
|
||||
elif _norm_reply in {"always", "always approve"}:
|
||||
_confirm_choice = "always"
|
||||
elif _raw_reply.lower() in {"cancel", "nevermind", "no"}:
|
||||
elif _norm_reply in {"cancel", "nevermind", "no"}:
|
||||
_confirm_choice = "cancel"
|
||||
if _confirm_choice is not None:
|
||||
_resolved = await _slash_confirm_mod.resolve(
|
||||
@@ -7074,6 +7065,9 @@ 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)
|
||||
|
||||
@@ -9382,6 +9376,12 @@ 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,6 +10637,7 @@ 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"
|
||||
@@ -10644,7 +10645,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"
|
||||
"_Text fallback: reply `/approve`, `/always`, or `/cancel`._"
|
||||
f"_Text fallback: reply `{_p}approve`, `{_p}always`, or `{_p}cancel`._"
|
||||
)
|
||||
return await self._request_slash_confirm(
|
||||
event=event,
|
||||
@@ -11039,11 +11040,12 @@ 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 `/approve` (yes) or `/deny` (no), "
|
||||
f"Reply `{_p}approve` (yes) or `{_p}deny` (no), "
|
||||
f"or type your answer directly.",
|
||||
metadata=metadata,
|
||||
)
|
||||
@@ -11553,7 +11555,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
|
||||
return prefix, successful_transcripts
|
||||
if user_text:
|
||||
return f"{prefix}\n\n{user_text}", successful_transcripts
|
||||
return prefix, successful_transcripts
|
||||
@@ -14110,14 +14112,18 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"Button-based approval failed, falling back to text: %s", _e
|
||||
)
|
||||
|
||||
# Fallback: plain text approval prompt
|
||||
# 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", "/")
|
||||
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 `/approve` to execute, `/approve session` to approve this pattern "
|
||||
f"for the session, `/approve always` to approve permanently, or `/deny` to cancel."
|
||||
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."
|
||||
)
|
||||
try:
|
||||
_approval_send_fut = safe_schedule_threadsafe(
|
||||
|
||||
@@ -91,6 +91,7 @@ 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:
|
||||
|
||||
+261
-141
@@ -47,6 +47,19 @@ 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
|
||||
@@ -1146,149 +1159,198 @@ class GatewaySlashCommandsMixin:
|
||||
if not result.success:
|
||||
return t("gateway.model.error_prefix", error=result.error_message)
|
||||
|
||||
# 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)
|
||||
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 cached_entry and cached_entry[0] is not None:
|
||||
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
|
||||
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)
|
||||
_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()))
|
||||
|
||||
# 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
|
||||
)
|
||||
# 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"))
|
||||
|
||||
# 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 result.warning_message:
|
||||
lines.append(t("gateway.model.warning_prefix", warning=result.warning_message))
|
||||
|
||||
# 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,
|
||||
}
|
||||
if persist_global:
|
||||
lines.append(t("gateway.model.saved_global"))
|
||||
else:
|
||||
lines.append(t("gateway.model.session_only_hint"))
|
||||
|
||||
# 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)
|
||||
return "\n".join(lines)
|
||||
|
||||
# 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
|
||||
# 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
|
||||
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)
|
||||
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,
|
||||
)
|
||||
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()))
|
||||
_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()
|
||||
|
||||
# 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"))
|
||||
_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,
|
||||
)
|
||||
|
||||
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)
|
||||
return await _finish_switch()
|
||||
|
||||
async def _handle_codex_runtime_command(self, event: MessageEvent) -> str:
|
||||
"""Handle /codex-runtime command in the gateway.
|
||||
@@ -1955,12 +2017,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 + set write mode.
|
||||
"""Handle /memory — review pending memory writes + toggle the approval gate.
|
||||
|
||||
Memory entries are small enough to review inline in a chat bubble, so
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
from gateway.run import _hermes_home
|
||||
from hermes_cli.write_approval_commands import handle_pending_subcommand
|
||||
@@ -1972,15 +2034,15 @@ class GatewaySlashCommandsMixin:
|
||||
session_key = self._session_key_for_source(event.source)
|
||||
config_path = _hermes_home / "config.yaml"
|
||||
|
||||
def _set_mode(mode: str):
|
||||
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("memory", {})["write_mode"] = mode
|
||||
user_config.setdefault("memory", {})["write_approval"] = bool(enabled)
|
||||
atomic_yaml_write(config_path, user_config)
|
||||
# New write_mode must take effect next message → drop cached agent.
|
||||
# New setting 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
|
||||
@@ -1989,11 +2051,69 @@ class GatewaySlashCommandsMixin:
|
||||
store.load_from_disk()
|
||||
|
||||
out = handle_pending_subcommand(
|
||||
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_mode,
|
||||
wa.MEMORY, args, memory_store=store, set_mode_fn=_set_approval,
|
||||
)
|
||||
if out is None:
|
||||
out = ("Unknown /memory subcommand. Use: pending, approve <id>, "
|
||||
"reject <id>, mode <on|off|approve>.")
|
||||
"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)")
|
||||
return out
|
||||
|
||||
async def _handle_fast_command(self, event: MessageEvent) -> str:
|
||||
|
||||
+60
-15
@@ -19,29 +19,74 @@ __release_date__ = "2026.6.5"
|
||||
|
||||
|
||||
def _ensure_utf8():
|
||||
"""Force UTF-8 stdout/stderr on Windows to prevent UnicodeEncodeError.
|
||||
"""Force UTF-8 stdout/stderr to prevent UnicodeEncodeError crashes.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
if sys.platform != "win32":
|
||||
return
|
||||
os.environ.setdefault("PYTHONUTF8", "1")
|
||||
os.environ.setdefault("PYTHONIOENCODING", "utf-8")
|
||||
repaired = False
|
||||
|
||||
for stream_name in ("stdout", "stderr"):
|
||||
stream = getattr(sys, stream_name, None)
|
||||
if stream is None:
|
||||
continue
|
||||
try:
|
||||
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):
|
||||
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):
|
||||
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()
|
||||
|
||||
+91
-7
@@ -2665,12 +2665,23 @@ 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()
|
||||
@@ -2694,6 +2705,21 @@ 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],
|
||||
*,
|
||||
@@ -6149,6 +6175,40 @@ 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 = "",
|
||||
@@ -6156,6 +6216,9 @@ 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.
|
||||
|
||||
@@ -6169,6 +6232,18 @@ 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:
|
||||
@@ -6284,13 +6359,13 @@ def _prompt_model_selection(
|
||||
return None
|
||||
print()
|
||||
if idx < len(ordered):
|
||||
return ordered[idx]
|
||||
return _confirmed_selection(ordered[idx])
|
||||
elif idx == len(ordered):
|
||||
try:
|
||||
custom = input("Enter model name: ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return None
|
||||
return custom if custom else None
|
||||
return _confirmed_selection(custom) if custom else None
|
||||
return None
|
||||
except (ImportError, NotImplementedError, OSError, subprocess.SubprocessError):
|
||||
pass
|
||||
@@ -6322,10 +6397,10 @@ def _prompt_model_selection(
|
||||
return None
|
||||
idx = int(choice)
|
||||
if 1 <= idx <= n:
|
||||
return ordered[idx - 1]
|
||||
return _confirmed_selection(ordered[idx - 1])
|
||||
elif idx == n + 1:
|
||||
custom = input("Enter model name: ").strip()
|
||||
return custom if custom else None
|
||||
return _confirmed_selection(custom) if custom else None
|
||||
elif idx == n + 2:
|
||||
return None
|
||||
print(f"Please enter 1-{n + 2}")
|
||||
@@ -6669,6 +6744,7 @@ 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
|
||||
@@ -6695,6 +6771,7 @@ 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:
|
||||
@@ -6734,6 +6811,7 @@ 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 (
|
||||
@@ -6750,6 +6828,7 @@ 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()
|
||||
@@ -6770,7 +6849,7 @@ def _xai_oauth_loopback_login(
|
||||
code="xai_authorization_failed",
|
||||
)
|
||||
callback_state = callback.get("state")
|
||||
# Manual-paste bare-code path: when a user pastes only the opaque
|
||||
# Manual bare-code paths: 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
|
||||
@@ -6778,10 +6857,12 @@ 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 path — we substitute the locally generated state to keep
|
||||
# bare-code paths — 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_state is None and manual_paste:
|
||||
if callback.get("_manual_paste"):
|
||||
allow_missing_state = True
|
||||
if callback_state is None and (manual_paste or allow_missing_state):
|
||||
callback_state = state
|
||||
if callback_state != state:
|
||||
raise AuthError(
|
||||
@@ -7698,6 +7779,9 @@ 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("/")
|
||||
|
||||
@@ -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", "mode"}:
|
||||
"deny", "drop", "diff", "approval", "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 m: self._save_write_mode("skills", m),
|
||||
set_mode_fn=lambda enabled: self._save_write_approval("skills", enabled),
|
||||
)
|
||||
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 + write-mode control."""
|
||||
"""Handle /memory slash command — pending review + approval-gate toggle."""
|
||||
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 m: self._save_write_mode("memory", m),
|
||||
set_mode_fn=lambda enabled: self._save_write_approval("memory", enabled),
|
||||
)
|
||||
if out is None:
|
||||
out = ("Unknown /memory subcommand. "
|
||||
"Use: pending, approve <id>, reject <id>, mode <on|off|approve>.")
|
||||
"Use: pending, approve <id>, reject <id>, approval <on|off>.")
|
||||
print(out)
|
||||
|
||||
def _save_write_mode(self, subsystem: str, mode: str):
|
||||
"""Persist <subsystem>.write_mode to config (for /memory|/skills mode)."""
|
||||
def _save_write_approval(self, subsystem: str, enabled: bool):
|
||||
"""Persist <subsystem>.write_approval to config (for /memory|/skills approval)."""
|
||||
from cli import save_config_value
|
||||
save_config_value(f"{subsystem}.write_mode", mode)
|
||||
save_config_value(f"{subsystem}.write_approval", bool(enabled))
|
||||
|
||||
def _handle_background_command(self, cmd: str):
|
||||
"""Handle /background <prompt> — run a prompt in a separate background session.
|
||||
|
||||
@@ -167,12 +167,13 @@ 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", "mode")),
|
||||
CommandDef("memory", "Review pending memory writes / set write mode",
|
||||
"pending", "approve", "reject", "diff", "approval")),
|
||||
CommandDef("memory", "Review pending memory writes / toggle the approval gate",
|
||||
"Tools & Skills",
|
||||
args_hint="[pending|approve|reject|mode] [id|on|off|approve]",
|
||||
subcommands=("pending", "approve", "reject", "mode")),
|
||||
args_hint="[pending|approve|reject|approval] [id|on|off]",
|
||||
subcommands=("pending", "approve", "reject", "approval")),
|
||||
CommandDef("bundles", "List skill bundles (aliases /<name> for multiple skills)",
|
||||
"Tools & Skills"),
|
||||
CommandDef("cron", "Manage scheduled tasks", "Tools & Skills",
|
||||
|
||||
+83
-26
@@ -1290,6 +1290,14 @@ 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
|
||||
@@ -1556,7 +1564,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 5000, Edge 5000, Mistral 4000, NeuTTS/KittenTTS 2000).
|
||||
# Gemini 32000, 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": {
|
||||
@@ -1572,6 +1580,19 @@ 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",
|
||||
@@ -1653,18 +1674,19 @@ DEFAULT_CONFIG = {
|
||||
"memory": {
|
||||
"memory_enabled": True,
|
||||
"user_profile_enabled": True,
|
||||
# Write gate for the memory tool (add/replace/remove), applied to BOTH
|
||||
# Approval gate for memory writes (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):
|
||||
# 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",
|
||||
# (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,
|
||||
"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).
|
||||
@@ -1769,17 +1791,18 @@ DEFAULT_CONFIG = {
|
||||
# External hub installs (trusted/community sources) are always
|
||||
# scanned regardless of this setting.
|
||||
"guard_agent_created": False,
|
||||
# Write gate for skill_manage (create/edit/patch/write_file/delete/
|
||||
# Approval 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:
|
||||
# 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",
|
||||
# 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,
|
||||
},
|
||||
|
||||
# Curator — background skill maintenance.
|
||||
@@ -2463,7 +2486,7 @@ DEFAULT_CONFIG = {
|
||||
|
||||
|
||||
# Config schema version - bump this when adding new required fields
|
||||
"_config_version": 28,
|
||||
"_config_version": 29,
|
||||
}
|
||||
|
||||
# =============================================================================
|
||||
@@ -4734,6 +4757,34 @@ 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}")
|
||||
|
||||
@@ -5719,19 +5770,21 @@ def save_env_value(key: str, value: str):
|
||||
f.flush()
|
||||
os.fsync(f.fileno())
|
||||
atomic_replace(tmp_path, env_path)
|
||||
# Restore original permissions before _secure_file may tighten them.
|
||||
# Preserve the original file mode (e.g. 0640 for Docker volume mounts)
|
||||
# instead of letting _secure_file unconditionally tighten to 0600.
|
||||
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()
|
||||
@@ -5776,18 +5829,22 @@ 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()
|
||||
|
||||
@@ -47,12 +47,6 @@ _GATE_PUBLIC_PREFIXES: tuple[str, ...] = (
|
||||
"/ds-assets/",
|
||||
"/fonts/",
|
||||
"/fonts-terminal/",
|
||||
# Cross-process hook delivery — bearer-token authenticated independently
|
||||
# of the OAuth gate (see hermes_cli/hook_ingest.py + DESIGN-cross-
|
||||
# process-hooks.md). Forwarders running outside any logged-in browser
|
||||
# session need to reach these endpoints; the bearer token is the
|
||||
# security boundary.
|
||||
"/api/hooks/",
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -46,14 +46,4 @@ PUBLIC_API_PATHS: frozenset[str] = frozenset({
|
||||
# Read-only theme + plugin manifests for the dashboard skin engine.
|
||||
"/api/dashboard/themes",
|
||||
"/api/dashboard/plugins",
|
||||
# Cross-process hook delivery. These bypass the dashboard auth gates
|
||||
# because they carry their OWN auth model: a bearer token minted fresh
|
||||
# into ``dashboard.json`` each dashboard startup, verified inside the
|
||||
# route handlers themselves (gateway/hook_forwarder.py posts to them
|
||||
# without a session cookie). They are NOT unauthenticated — the
|
||||
# handler rejects a missing/wrong bearer — so they're safe to expose
|
||||
# to the three audiences above (the handler 401s anyone without the
|
||||
# token). See gateway/hook_forwarder.py + hermes_cli/hook_ingest.py.
|
||||
"/api/hooks/health",
|
||||
"/api/hooks/ingest",
|
||||
})
|
||||
|
||||
@@ -1,282 +0,0 @@
|
||||
"""Hook registry cross-process delivery — dashboard-side endpoints.
|
||||
|
||||
The companion to ``gateway/hook_forwarder.py``. Provides:
|
||||
|
||||
* ``write_dashboard_discovery_file()`` / ``remove_dashboard_discovery_file()`` —
|
||||
drops/cleans ``$HERMES_HOME/dashboard.json`` with the bound dashboard URL and
|
||||
a freshly-generated bearer token. Long-lived source processes (gateway,
|
||||
TUI, subagents) read this file on startup to find the dashboard and
|
||||
authenticate ingest POSTs.
|
||||
|
||||
* :func:`build_hook_router` — returns a FastAPI router with two endpoints:
|
||||
|
||||
- ``GET /api/hooks/health`` — unauthenticated reachability probe. The
|
||||
forwarder GETs this before each round of POSTs so a downed dashboard
|
||||
fails fast without spamming the ingest endpoint with retries.
|
||||
|
||||
- ``POST /api/hooks/ingest`` — accepts ``{event_type, context, src}``
|
||||
frames from forwarders and republishes them via
|
||||
``get_default_registry().emit_sync(event_type, context)``. Bearer
|
||||
token from ``dashboard.json:hooks_ingest_token`` is the security
|
||||
boundary, independent of the dashboard's session token / OAuth
|
||||
gate. Loop-prevention: the republished context is stamped with
|
||||
``_forwarded=True`` and ``_forwarded_from=<src>`` so source-side
|
||||
forwarders skip these events if they ever round-trip back.
|
||||
|
||||
See ``DESIGN-cross-process-hooks.md`` for the full design rationale.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import secrets
|
||||
import stat
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Request
|
||||
|
||||
from hermes_cli.config import get_hermes_home
|
||||
|
||||
|
||||
_log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery file
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# The dashboard writes this file on startup and removes it on shutdown.
|
||||
# Forwarders running in non-dashboard processes read it to discover the
|
||||
# dashboard URL + bearer token. Lives under HERMES_HOME so it's
|
||||
# profile-aware; no /tmp fallback.
|
||||
|
||||
_DISCOVERY_FILENAME = "dashboard.json"
|
||||
|
||||
# Token used by forwarders to authenticate POST /api/hooks/ingest.
|
||||
# Generated fresh on each dashboard startup; lives only in memory + the
|
||||
# 0600-mode discovery file. Stored at module level so the ingest
|
||||
# endpoint can compare against it without re-reading the file on every
|
||||
# request.
|
||||
_HOOKS_INGEST_TOKEN: str = ""
|
||||
|
||||
|
||||
def _discovery_path() -> Path:
|
||||
"""Return the absolute path of ``$HERMES_HOME/dashboard.json``."""
|
||||
return get_hermes_home() / _DISCOVERY_FILENAME
|
||||
|
||||
|
||||
def write_dashboard_discovery_file(host: str, port: int) -> str:
|
||||
"""Generate a fresh hooks-ingest token and write the discovery file.
|
||||
|
||||
The discovery file is written atomically (write to ``.tmp``, rename
|
||||
to final path) so a forwarder that reads concurrently never sees a
|
||||
partially-written file. The file mode is ``0600`` — owner-only —
|
||||
so a same-host non-root user can't read the token without already
|
||||
having compromised the dashboard user's account.
|
||||
|
||||
Idempotent in the trivial sense: calling twice in the same process
|
||||
rotates the token and overwrites the file. Not thread-safe; should
|
||||
only be called from the dashboard's startup path.
|
||||
|
||||
Args:
|
||||
host: The host the dashboard bound to (``127.0.0.1``, ``0.0.0.0``,
|
||||
a specific interface, …). Written verbatim into the discovery
|
||||
file so forwarders dial the right address.
|
||||
port: The port the dashboard bound to.
|
||||
|
||||
Returns:
|
||||
The freshly-generated bearer token (also stored module-locally
|
||||
so :func:`_hook_ingest_auth_ok` can validate POSTs).
|
||||
"""
|
||||
global _HOOKS_INGEST_TOKEN
|
||||
token = secrets.token_urlsafe(32)
|
||||
_HOOKS_INGEST_TOKEN = token
|
||||
|
||||
payload = {
|
||||
"url": f"http://{host}:{port}",
|
||||
"hooks_ingest_token": token,
|
||||
"pid": os.getpid(),
|
||||
"started_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
|
||||
path = _discovery_path()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
tmp_path = path.with_suffix(".json.tmp")
|
||||
try:
|
||||
tmp_path.write_text(json.dumps(payload, indent=2), encoding="utf-8")
|
||||
# 0600 — owner read+write, nothing for group or other. Same
|
||||
# posture as ~/.hermes/auth.json.
|
||||
os.chmod(tmp_path, stat.S_IRUSR | stat.S_IWUSR)
|
||||
tmp_path.replace(path)
|
||||
except OSError as exc:
|
||||
_log.warning(
|
||||
"[hooks-ingest] failed to write dashboard discovery file at %s: %s",
|
||||
path,
|
||||
exc,
|
||||
)
|
||||
try:
|
||||
tmp_path.unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
_log.debug("[hooks-ingest] wrote %s", path)
|
||||
return token
|
||||
|
||||
|
||||
def remove_dashboard_discovery_file() -> None:
|
||||
"""Delete the discovery file. Idempotent; safe to call from atexit."""
|
||||
global _HOOKS_INGEST_TOKEN
|
||||
try:
|
||||
_discovery_path().unlink(missing_ok=True)
|
||||
except OSError:
|
||||
pass
|
||||
_HOOKS_INGEST_TOKEN = ""
|
||||
_log.debug("[hooks-ingest] removed discovery file")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Auth helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _hook_ingest_auth_ok(request: Request) -> bool:
|
||||
"""True if the request carries a valid hooks-ingest bearer token.
|
||||
|
||||
The token is independent of the dashboard session token / OAuth
|
||||
cookie; only forwarders that have read ``dashboard.json`` know it.
|
||||
|
||||
Constant-time comparison via :func:`hmac.compare_digest`.
|
||||
"""
|
||||
if not _HOOKS_INGEST_TOKEN:
|
||||
# No token yet — discovery file not written; refuse everything.
|
||||
return False
|
||||
auth = request.headers.get("authorization", "")
|
||||
expected = f"Bearer {_HOOKS_INGEST_TOKEN}"
|
||||
return hmac.compare_digest(auth.encode(), expected.encode())
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# FastAPI router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def build_hook_router() -> APIRouter:
|
||||
"""Return a FastAPI router with ``/health`` and ``/ingest`` endpoints.
|
||||
|
||||
Caller mounts at ``/api/hooks`` to get the documented endpoints
|
||||
(``GET /api/hooks/health`` and ``POST /api/hooks/ingest``).
|
||||
"""
|
||||
router = APIRouter()
|
||||
|
||||
@router.get("/health")
|
||||
async def hook_health() -> dict:
|
||||
"""Unauthenticated reachability probe for the forwarder."""
|
||||
return {"ok": True}
|
||||
|
||||
@router.post("/ingest")
|
||||
async def hook_ingest(request: Request) -> dict:
|
||||
"""Republish a forwarded event on the dashboard's local registry.
|
||||
|
||||
Wire shape (POST body)::
|
||||
|
||||
{
|
||||
"event_type": "agent:start",
|
||||
"context": {"platform": "telegram", "user_id": "u-1", ...},
|
||||
"src": "gateway"
|
||||
}
|
||||
|
||||
The context is republished with ``_forwarded=True`` and
|
||||
``_forwarded_from=<src>`` stamped on it so source-side
|
||||
forwarders skip the event if it ever round-trips back (closing
|
||||
the loop).
|
||||
|
||||
Returns ``{"ok": True}`` on success, raises 401 on missing/bad
|
||||
token, 400 on malformed body.
|
||||
|
||||
``emit_sync`` is the right entry point: handlers run
|
||||
synchronously on the request thread (fast, push-to-queue style
|
||||
for typical plugin handlers); async handlers are scheduled on
|
||||
the running event loop via the existing ``asyncio.ensure_future``
|
||||
path inside ``emit_sync``.
|
||||
"""
|
||||
if not _hook_ingest_auth_ok(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception as exc:
|
||||
raise HTTPException(
|
||||
status_code=400, detail=f"invalid JSON body: {exc}"
|
||||
) from exc
|
||||
|
||||
if not isinstance(body, dict):
|
||||
raise HTTPException(status_code=400, detail="body must be a JSON object")
|
||||
|
||||
event_type = body.get("event_type")
|
||||
if not isinstance(event_type, str) or not event_type:
|
||||
raise HTTPException(
|
||||
status_code=400, detail="missing or empty 'event_type'"
|
||||
)
|
||||
|
||||
context = body.get("context") or {}
|
||||
if not isinstance(context, dict):
|
||||
raise HTTPException(
|
||||
status_code=400, detail="'context' must be a JSON object"
|
||||
)
|
||||
|
||||
src = body.get("src", "?")
|
||||
if not isinstance(src, str):
|
||||
src = "?"
|
||||
|
||||
# Stamp the context with forwarding metadata so:
|
||||
# 1. Source-side forwarders skip this event if it ever round-trips
|
||||
# back to them (loop prevention).
|
||||
# 2. Subscribers can filter forwarded-vs-original on
|
||||
# ``context["_forwarded"]`` (useful for hooks that fire in
|
||||
# every process and don't want 2x firing).
|
||||
context = {**context, "_forwarded": True, "_forwarded_from": src}
|
||||
|
||||
# Lazy import to avoid a hard dependency on gateway.hooks at
|
||||
# module-load time (web_server can be imported in contexts
|
||||
# where gateway.hooks isn't ready).
|
||||
from gateway.hooks import get_default_registry
|
||||
|
||||
try:
|
||||
get_default_registry().emit_sync(event_type, context)
|
||||
except Exception as exc:
|
||||
# emit_sync swallows handler exceptions internally — if we
|
||||
# see one here it's a registry-level bug. Log it but still
|
||||
# return 200 so the forwarder doesn't retry-storm.
|
||||
_log.warning(
|
||||
"[hooks-ingest] emit_sync raised for %s: %s", event_type, exc
|
||||
)
|
||||
|
||||
return {"ok": True}
|
||||
|
||||
return router
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _reset_for_tests() -> None:
|
||||
"""Clear the cached token + remove any leftover discovery file."""
|
||||
global _HOOKS_INGEST_TOKEN
|
||||
_HOOKS_INGEST_TOKEN = ""
|
||||
remove_dashboard_discovery_file()
|
||||
|
||||
|
||||
def get_current_token_for_tests() -> str:
|
||||
"""Read the currently-active hooks-ingest token.
|
||||
|
||||
Test-only accessor; do not call from production code.
|
||||
"""
|
||||
return _HOOKS_INGEST_TOKEN
|
||||
+202
-4
@@ -499,6 +499,7 @@ 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,
|
||||
@@ -2830,7 +2831,12 @@ def select_provider_and_model(args=None):
|
||||
member_labels = [
|
||||
provider_labels.get(m, m) for m in selected_members
|
||||
]
|
||||
member_idx = _prompt_provider_choice(member_labels, default=member_default)
|
||||
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:",
|
||||
)
|
||||
if member_idx is None:
|
||||
print("No change.")
|
||||
return
|
||||
@@ -2974,6 +2980,7 @@ _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"),
|
||||
@@ -3263,6 +3270,7 @@ def _aux_flow_provider_model(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
pricing=pricing,
|
||||
confirm_provider=provider_slug,
|
||||
)
|
||||
if selected is None:
|
||||
print("No change.")
|
||||
@@ -3331,7 +3339,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):
|
||||
def _prompt_provider_choice(choices, *, default=0, title="Select provider:"):
|
||||
"""Show provider selection menu with curses arrow-key navigation.
|
||||
|
||||
Falls back to a numbered list when curses is unavailable (e.g. piped
|
||||
@@ -3341,7 +3349,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
||||
try:
|
||||
from hermes_cli.setup import _curses_prompt_choice
|
||||
|
||||
idx = _curses_prompt_choice("Select provider:", choices, default)
|
||||
idx = _curses_prompt_choice(title, choices, default)
|
||||
if idx >= 0:
|
||||
print()
|
||||
return idx
|
||||
@@ -3349,7 +3357,7 @@ def _prompt_provider_choice(choices, *, default=0):
|
||||
pass
|
||||
|
||||
# Fallback: numbered list
|
||||
print("Select provider:")
|
||||
print(title)
|
||||
for i, c in enumerate(choices, 1):
|
||||
marker = "→" if i - 1 == default else " "
|
||||
print(f" {marker} {i}. {c}")
|
||||
@@ -6385,6 +6393,167 @@ 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],
|
||||
*,
|
||||
@@ -8316,6 +8485,13 @@ 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
|
||||
|
||||
@@ -8369,6 +8545,12 @@ 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()
|
||||
@@ -10683,6 +10865,22 @@ 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():
|
||||
|
||||
@@ -0,0 +1,134 @@
|
||||
"""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),
|
||||
)
|
||||
+131
-43
@@ -25,6 +25,44 @@ 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
|
||||
@@ -64,7 +102,12 @@ 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
|
||||
openrouter_models,
|
||||
current_model=current_model,
|
||||
pricing=pricing,
|
||||
confirm_provider="openrouter",
|
||||
confirm_base_url=OPENROUTER_BASE_URL,
|
||||
confirm_api_key=_resolved or existing_key,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
@@ -273,6 +316,9 @@ 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)
|
||||
@@ -321,16 +367,9 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
if status.get("logged_in"):
|
||||
print(" OpenAI Codex credentials: ✓")
|
||||
print()
|
||||
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"
|
||||
choice = _prompt_auth_credentials_choice("OpenAI Codex credentials:")
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
print("Starting a fresh OpenAI Codex login...")
|
||||
print()
|
||||
try:
|
||||
@@ -350,7 +389,7 @@ def _model_flow_openai_codex(config, current_model=""):
|
||||
if not status.get("logged_in"):
|
||||
print("Login failed.")
|
||||
return
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
else:
|
||||
print("Not logged into OpenAI Codex. Starting login...")
|
||||
@@ -385,7 +424,13 @@ 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)
|
||||
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 "",
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("openai-codex", DEFAULT_CODEX_BASE_URL)
|
||||
@@ -411,16 +456,11 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
if status.get("logged_in"):
|
||||
print(" xAI Grok OAuth (SuperGrok / Premium+) credentials: ✓")
|
||||
print()
|
||||
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"
|
||||
choice = _prompt_auth_credentials_choice(
|
||||
"xAI Grok OAuth (SuperGrok / Premium+) credentials:"
|
||||
)
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
print("Starting a fresh xAI OAuth login...")
|
||||
print()
|
||||
try:
|
||||
@@ -444,7 +484,7 @@ def _model_flow_xai_oauth(_config, current_model="", *, args=None):
|
||||
except Exception as exc:
|
||||
print(f"Login failed: {exc}")
|
||||
return
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
else:
|
||||
print("Not logged into xAI Grok OAuth (SuperGrok / Premium+). Starting login...")
|
||||
@@ -520,7 +560,12 @@ 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)
|
||||
selected = _prompt_model_selection(
|
||||
models,
|
||||
current_model=default,
|
||||
confirm_provider="qwen-oauth",
|
||||
confirm_base_url=DEFAULT_QWEN_BASE_URL,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider("qwen-oauth", DEFAULT_QWEN_BASE_URL)
|
||||
@@ -569,7 +614,12 @@ 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)
|
||||
selected = _prompt_model_selection(
|
||||
model_ids,
|
||||
current_model,
|
||||
confirm_provider="minimax-oauth",
|
||||
confirm_base_url=creds["base_url"],
|
||||
)
|
||||
if not selected:
|
||||
return
|
||||
_save_model_choice(selected)
|
||||
@@ -638,7 +688,12 @@ 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)
|
||||
selected = _prompt_model_selection(
|
||||
models,
|
||||
current_model=default,
|
||||
confirm_provider="google-gemini-cli",
|
||||
confirm_base_url=DEFAULT_GEMINI_CLOUDCODE_BASE_URL,
|
||||
)
|
||||
if selected:
|
||||
_save_model_choice(selected)
|
||||
_update_config_for_provider(
|
||||
@@ -1563,7 +1618,11 @@ def _model_flow_copilot(config, current_model=""):
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(
|
||||
model_list, current_model=normalized_current_model
|
||||
model_list,
|
||||
current_model=normalized_current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=api_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
@@ -1701,6 +1760,9 @@ 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:
|
||||
@@ -1805,7 +1867,13 @@ 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)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Enter model name: ").strip()
|
||||
@@ -1913,7 +1981,13 @@ def _model_flow_stepfun(config, current_model=""):
|
||||
)
|
||||
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name: ").strip()
|
||||
@@ -1989,7 +2063,13 @@ 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)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="custom",
|
||||
confirm_base_url=mantle_base_url,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input(" Model ID: ").strip()
|
||||
@@ -2178,7 +2258,12 @@ def _model_flow_bedrock(config, current_model=""):
|
||||
|
||||
# 4. Model selection
|
||||
if model_list:
|
||||
selected = _prompt_model_selection(model_list, current_model=current_model)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="bedrock",
|
||||
confirm_base_url=f"https://bedrock-runtime.{region}.amazonaws.com",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input(" Model ID: ").strip()
|
||||
@@ -2462,7 +2547,13 @@ 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)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider=provider_id,
|
||||
confirm_base_url=effective_base,
|
||||
confirm_api_key=existing_key,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name: ").strip()
|
||||
@@ -2560,20 +2651,13 @@ def _model_flow_anthropic(config, current_model=""):
|
||||
elif cc_available:
|
||||
print(" Claude Code credentials: ✓ (auto-detected)")
|
||||
print()
|
||||
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"
|
||||
choice = _prompt_auth_credentials_choice("Anthropic credentials:")
|
||||
|
||||
if choice == "2":
|
||||
if choice == "reauth":
|
||||
needs_auth = True
|
||||
elif choice == "3":
|
||||
elif choice == "cancel":
|
||||
return
|
||||
# choice == "1" or default: use existing, proceed to model selection
|
||||
# choice == "use" or default: use existing, proceed to model selection
|
||||
|
||||
if needs_auth:
|
||||
# Show auth method choice
|
||||
@@ -2619,7 +2703,11 @@ 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)
|
||||
selected = _prompt_model_selection(
|
||||
model_list,
|
||||
current_model=current_model,
|
||||
confirm_provider="anthropic",
|
||||
)
|
||||
else:
|
||||
try:
|
||||
selected = input("Model name (e.g., claude-sonnet-4-20250514): ").strip()
|
||||
|
||||
@@ -351,13 +351,29 @@ 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..."):
|
||||
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)}[/]"
|
||||
)
|
||||
|
||||
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:
|
||||
|
||||
+100
-48
@@ -246,7 +246,31 @@ def _has_valid_session_token(request: Request) -> bool:
|
||||
|
||||
|
||||
def _require_token(request: Request) -> None:
|
||||
"""Validate the ephemeral session token. Raises 401 on mismatch."""
|
||||
"""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")
|
||||
if not _has_valid_session_token(request):
|
||||
raise HTTPException(status_code=401, detail="Unauthorized")
|
||||
|
||||
@@ -633,21 +657,6 @@ 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",
|
||||
@@ -689,6 +698,7 @@ 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(
|
||||
@@ -1362,11 +1372,28 @@ 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 = _spawn_hermes_action(["gateway", "restart"], "gateway-restart")
|
||||
proc, _reused = _spawn_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}")
|
||||
@@ -2446,6 +2473,30 @@ 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")
|
||||
@@ -3714,6 +3765,34 @@ 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
|
||||
@@ -3768,11 +3847,14 @@ 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": True,
|
||||
"needs_restart": not restart_result["restart_started"],
|
||||
**restart_result,
|
||||
}
|
||||
|
||||
|
||||
@@ -10028,13 +10110,6 @@ def _mount_plugin_api_routes():
|
||||
# Mount plugin API routes before the SPA catch-all.
|
||||
_mount_plugin_api_routes()
|
||||
|
||||
# Mount the cross-process hook ingest router (POST /api/hooks/ingest +
|
||||
# GET /api/hooks/health). These endpoints have their own bearer-token
|
||||
# auth (independent of _SESSION_TOKEN / the OAuth gate) — see
|
||||
# hermes_cli/hook_ingest.py and DESIGN-cross-process-hooks.md.
|
||||
from hermes_cli.hook_ingest import build_hook_router as _build_hook_router # noqa: E402
|
||||
app.include_router(_build_hook_router(), prefix="/api/hooks")
|
||||
|
||||
# Mount the dashboard auth routes (/login, /auth/*, /api/auth/*) before the
|
||||
# SPA catch-all so /{full_path:path} doesn't swallow them. These are
|
||||
# always mounted — the gate middleware decides whether to enforce auth,
|
||||
@@ -10125,29 +10200,6 @@ def start_server(
|
||||
app.state.bound_host = host
|
||||
app.state.bound_port = port
|
||||
|
||||
# Cross-process hook delivery: drop the discovery file so source
|
||||
# processes (gateway, TUI, subagents) can find this dashboard and
|
||||
# authenticate POSTs to /api/hooks/ingest. The file is removed via
|
||||
# atexit so a clean shutdown stops orphan forwarders from trying to
|
||||
# POST to a port that's now closed.
|
||||
import atexit
|
||||
from hermes_cli.hook_ingest import (
|
||||
remove_dashboard_discovery_file,
|
||||
write_dashboard_discovery_file,
|
||||
)
|
||||
try:
|
||||
write_dashboard_discovery_file(host, port)
|
||||
atexit.register(remove_dashboard_discovery_file)
|
||||
except OSError as exc:
|
||||
# Non-fatal — the dashboard still works without cross-process
|
||||
# hook delivery. Warn so the operator knows the orb (or any
|
||||
# other event-driven plugin) won't see gateway/TUI events.
|
||||
_log.warning(
|
||||
"Failed to write hook discovery file: %s. Cross-process hook "
|
||||
"delivery to dashboard plugins will be unavailable.",
|
||||
exc,
|
||||
)
|
||||
|
||||
if open_browser:
|
||||
import webbrowser
|
||||
|
||||
|
||||
@@ -20,7 +20,10 @@ from typing import List, Optional
|
||||
|
||||
from tools import write_approval as wa
|
||||
|
||||
_VALID_MODES = (wa.MODE_ON, wa.MODE_OFF, wa.MODE_APPROVE)
|
||||
|
||||
def _fmt_state(subsystem: str) -> str:
|
||||
on = wa.write_approval_enabled(subsystem)
|
||||
return f"{subsystem}.write_approval = {'on' if on else 'off'}"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -63,18 +66,17 @@ 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 ``(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).
|
||||
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).
|
||||
|
||||
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 + current mode.
|
||||
mode = wa.get_write_mode(subsystem)
|
||||
return f"{subsystem}.write_mode = {mode}\n\n" + _fmt_pending_list(subsystem)
|
||||
# Bare /memory or /skills with no sub → show pending + gate state.
|
||||
return f"{_fmt_state(subsystem)}\n\n" + _fmt_pending_list(subsystem)
|
||||
|
||||
sub = args[0].lower()
|
||||
rest = args[1:]
|
||||
@@ -91,8 +93,8 @@ def handle_pending_subcommand(
|
||||
if sub == "diff" and subsystem == wa.SKILLS:
|
||||
return _diff(rest)
|
||||
|
||||
if sub == "mode":
|
||||
return _set_mode(subsystem, rest, set_mode_fn)
|
||||
if sub in {"approval", "mode"}: # 'mode' kept as a back-compat alias
|
||||
return _set_approval(subsystem, rest, set_mode_fn)
|
||||
|
||||
return None # not ours — caller handles
|
||||
|
||||
@@ -179,19 +181,29 @@ def _diff(rest: List[str]) -> str:
|
||||
return header + "\n" + diff
|
||||
|
||||
|
||||
def _set_mode(subsystem: str, rest: List[str], set_mode_fn) -> str:
|
||||
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.
|
||||
"""
|
||||
if not rest:
|
||||
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."
|
||||
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."
|
||||
if set_mode_fn is None:
|
||||
return (f"To change {subsystem} write mode, run:\n"
|
||||
f" hermes config set {subsystem}.write_mode {mode}")
|
||||
val = "true" if enabled else "false"
|
||||
return (f"To change the {subsystem} approval gate, run:\n"
|
||||
f" hermes config set {subsystem}.write_approval {val}")
|
||||
try:
|
||||
set_mode_fn(mode)
|
||||
set_mode_fn(enabled)
|
||||
except Exception as e:
|
||||
return f"Failed to set {subsystem}.write_mode: {e}"
|
||||
return f"{subsystem}.write_mode set to '{mode}'."
|
||||
return f"Failed to set {subsystem}.write_approval: {e}"
|
||||
return f"{subsystem}.write_approval set to '{'on' if enabled else 'off'}'."
|
||||
|
||||
@@ -116,6 +116,8 @@ 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
|
||||
@@ -132,18 +134,36 @@ 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):
|
||||
pass # omit reasoning entirely → adaptive default
|
||||
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
|
||||
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, {"extra_headers": extra_headers} if extra_headers else {}
|
||||
return extra_body, top_level
|
||||
|
||||
|
||||
openrouter = OpenRouterProfile(
|
||||
|
||||
@@ -227,7 +227,30 @@ def _trace_key(task_id: str, session_id: str) -> str:
|
||||
return f"thread:{threading.get_ident()}"
|
||||
|
||||
|
||||
def _truncate_text(value: str, max_chars: int) -> str:
|
||||
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)
|
||||
if len(value) <= max_chars:
|
||||
return value
|
||||
return value[:max_chars] + f"... [truncated {len(value) - max_chars} chars]"
|
||||
|
||||
@@ -602,6 +602,11 @@ 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.
|
||||
@@ -789,6 +794,7 @@ 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":
|
||||
@@ -812,6 +818,7 @@ 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 —
|
||||
@@ -853,7 +860,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
if "*" not in _free_channels and not (_channel_ids & _free_channels):
|
||||
return
|
||||
|
||||
await self._handle_message(message)
|
||||
await self._handle_message(message, role_authorized=_role_authorized)
|
||||
|
||||
@self._client.event
|
||||
async def on_voice_state_update(member, before, after):
|
||||
@@ -2265,6 +2272,20 @@ 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:
|
||||
@@ -2395,6 +2416,11 @@ 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
|
||||
@@ -4702,7 +4728,7 @@ class DiscordAdapter(BasePlatformAdapter):
|
||||
raise Exception(f"HTTP {resp.status}")
|
||||
return await resp.read()
|
||||
|
||||
async def _handle_message(self, message: DiscordMessage) -> None:
|
||||
async def _handle_message(self, message: DiscordMessage, role_authorized: bool = False) -> 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
|
||||
@@ -4886,6 +4912,7 @@ 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
|
||||
@@ -5611,6 +5638,7 @@ 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()
|
||||
|
||||
@@ -5693,6 +5721,41 @@ 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(
|
||||
@@ -5722,7 +5785,11 @@ def _define_discord_view_classes() -> None:
|
||||
view=self,
|
||||
)
|
||||
|
||||
async def _on_model_selected(self, interaction: discord.Interaction):
|
||||
async def _switch_selected_model(
|
||||
self,
|
||||
interaction: discord.Interaction,
|
||||
model_id: str,
|
||||
):
|
||||
if self.resolved:
|
||||
await interaction.response.send_message(
|
||||
"Already resolved~", ephemeral=True
|
||||
@@ -5735,7 +5802,6 @@ 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(
|
||||
@@ -5764,6 +5830,50 @@ 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(
|
||||
|
||||
+17
-6
@@ -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
|
||||
from utils import atomic_json_write, base_url_host_matches, base_url_hostname, is_truthy_value, model_forces_max_completion_tokens
|
||||
|
||||
|
||||
|
||||
@@ -1253,13 +1253,24 @@ 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, 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'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 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():
|
||||
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)
|
||||
):
|
||||
return {"max_completion_tokens": value}
|
||||
return {"max_tokens": value}
|
||||
|
||||
|
||||
@@ -45,6 +45,9 @@ 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",
|
||||
@@ -1039,6 +1042,7 @@ 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",
|
||||
@@ -1504,6 +1508,7 @@ 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)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -3791,3 +3791,82 @@ 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}
|
||||
|
||||
@@ -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
|
||||
for p in parent.iterdir():
|
||||
assert not p.name.startswith(".curator_state_"), f"tmp leftover: {p.name}"
|
||||
tmp_files = [p.name for p in parent.iterdir() if p.name.endswith(".tmp")]
|
||||
assert tmp_files == []
|
||||
|
||||
|
||||
def test_state_preserves_last_report_path(curator_env):
|
||||
|
||||
@@ -964,6 +964,57 @@ 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)
|
||||
|
||||
@@ -192,6 +192,32 @@ 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,6 +5,12 @@ 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", {})))
|
||||
@@ -70,6 +76,54 @@ 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,3 +80,91 @@ 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,405 +0,0 @@
|
||||
"""Unit tests for ``gateway/hook_forwarder.py``.
|
||||
|
||||
Covers:
|
||||
|
||||
* No-op behavior when discovery file absent / env var disables
|
||||
* Idempotent ``start_if_dashboard_available`` (repeat calls re-use same forwarder)
|
||||
* Handler registration on every forwarded namespace
|
||||
* Loop prevention via ``_forwarded`` context flag
|
||||
* Queue overflow drops oldest, enqueues newest
|
||||
* ``stop()`` and ``_reset_for_tests`` are clean and idempotent
|
||||
* Error-log rate limiting
|
||||
|
||||
The HTTP-POST path is exercised in the integration test
|
||||
(``test_hook_forwarder_integration.py``) where a real FastAPI app
|
||||
stands in for the dashboard. Unit tests here focus on the
|
||||
registry-side behaviors that don't need an HTTP server.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway import hook_forwarder
|
||||
from gateway.hooks import HookRegistry
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_forwarder():
|
||||
"""Reset forwarder state + env between tests so they're independent."""
|
||||
hook_forwarder._reset_for_tests()
|
||||
# Make sure HERMES_HOOK_FORWARDER isn't sticky from a previous test.
|
||||
saved = os.environ.pop("HERMES_HOOK_FORWARDER", None)
|
||||
yield
|
||||
hook_forwarder._reset_for_tests()
|
||||
if saved is not None:
|
||||
os.environ["HERMES_HOOK_FORWARDER"] = saved
|
||||
|
||||
|
||||
def _write_discovery(tmp_path: Path, *, url: str = "http://127.0.0.1:9119") -> Path:
|
||||
"""Write a valid dashboard.json into tmp_path and return its path."""
|
||||
discovery = tmp_path / "dashboard.json"
|
||||
discovery.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"url": url,
|
||||
"hooks_ingest_token": "test-token-abc",
|
||||
"pid": 99999,
|
||||
"started_at": "2026-05-29T12:00:00Z",
|
||||
}
|
||||
)
|
||||
)
|
||||
return discovery
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# No-op paths
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestStartIfDashboardAvailable:
|
||||
def test_no_dashboard_json_returns_none(self, tmp_path, monkeypatch):
|
||||
"""Missing discovery file ⇒ forwarder doesn't start."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
reg = HookRegistry()
|
||||
|
||||
result = hook_forwarder.start_if_dashboard_available(reg, src="gateway")
|
||||
|
||||
assert result is None
|
||||
assert not hook_forwarder.is_active()
|
||||
# No handlers registered on the registry.
|
||||
assert reg._handlers == {}
|
||||
|
||||
def test_env_disabled_returns_none_even_with_dashboard(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""``HERMES_HOOK_FORWARDER=0`` short-circuits even when discovery
|
||||
is otherwise valid."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
_write_discovery(tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOOK_FORWARDER", "0")
|
||||
reg = HookRegistry()
|
||||
|
||||
result = hook_forwarder.start_if_dashboard_available(reg, src="gateway")
|
||||
|
||||
assert result is None
|
||||
assert reg._handlers == {}
|
||||
|
||||
@pytest.mark.parametrize("value", ["0", "false", "FALSE", "No", "off"])
|
||||
def test_env_disabled_accepts_common_falsy_values(
|
||||
self, tmp_path, monkeypatch, value
|
||||
):
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
_write_discovery(tmp_path)
|
||||
monkeypatch.setenv("HERMES_HOOK_FORWARDER", value)
|
||||
reg = HookRegistry()
|
||||
|
||||
assert hook_forwarder.start_if_dashboard_available(reg, src="gateway") is None
|
||||
|
||||
def test_malformed_discovery_json_returns_none(self, tmp_path, monkeypatch):
|
||||
"""Garbage in ``dashboard.json`` ⇒ forwarder doesn't start."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
(tmp_path / "dashboard.json").write_text("{not json")
|
||||
reg = HookRegistry()
|
||||
|
||||
assert hook_forwarder.start_if_dashboard_available(reg, src="gateway") is None
|
||||
|
||||
def test_discovery_missing_required_keys_returns_none(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""``dashboard.json`` without ``url`` or ``hooks_ingest_token`` ⇒
|
||||
no forwarder."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
(tmp_path / "dashboard.json").write_text(
|
||||
json.dumps({"pid": 123}) # missing url + token
|
||||
)
|
||||
reg = HookRegistry()
|
||||
|
||||
assert hook_forwarder.start_if_dashboard_available(reg, src="gateway") is None
|
||||
|
||||
def test_repeated_calls_are_idempotent(self, tmp_path, monkeypatch):
|
||||
"""Two ``start_if_dashboard_available`` in the same process re-use
|
||||
the same forwarder instance and don't double-register handlers."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
_write_discovery(tmp_path)
|
||||
reg = HookRegistry()
|
||||
|
||||
first = hook_forwarder.start_if_dashboard_available(reg, src="gateway")
|
||||
try:
|
||||
second = hook_forwarder.start_if_dashboard_available(reg, src="tui")
|
||||
|
||||
assert first is not None
|
||||
assert second is first # same instance
|
||||
# And handlers registered exactly once per namespace.
|
||||
for pattern in hook_forwarder._FORWARDED_NAMESPACES:
|
||||
assert len(reg._handlers[pattern]) == 1
|
||||
finally:
|
||||
hook_forwarder.stop()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Registration coverage
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestRegistration:
|
||||
def test_registers_every_forwarded_namespace(self, tmp_path, monkeypatch):
|
||||
"""Forwarder subscribes to all five canonical namespaces."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
_write_discovery(tmp_path)
|
||||
reg = HookRegistry()
|
||||
|
||||
try:
|
||||
hook_forwarder.start_if_dashboard_available(reg, src="gateway")
|
||||
for pattern in (
|
||||
"tui:*",
|
||||
"agent:*",
|
||||
"session:*",
|
||||
"command:*",
|
||||
"gateway:*",
|
||||
):
|
||||
assert pattern in reg._handlers
|
||||
assert len(reg._handlers[pattern]) == 1
|
||||
finally:
|
||||
hook_forwarder.stop()
|
||||
|
||||
def test_stop_unregisters_all_handlers(self, tmp_path, monkeypatch):
|
||||
"""``stop()`` removes every handler the forwarder installed."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
_write_discovery(tmp_path)
|
||||
reg = HookRegistry()
|
||||
|
||||
hook_forwarder.start_if_dashboard_available(reg, src="gateway")
|
||||
for pattern in hook_forwarder._FORWARDED_NAMESPACES:
|
||||
assert pattern in reg._handlers
|
||||
|
||||
hook_forwarder.stop()
|
||||
for pattern in hook_forwarder._FORWARDED_NAMESPACES:
|
||||
assert reg._handlers.get(pattern, []) == []
|
||||
|
||||
def test_stop_is_idempotent(self, tmp_path, monkeypatch):
|
||||
"""Calling ``stop()`` twice doesn't raise."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
_write_discovery(tmp_path)
|
||||
reg = HookRegistry()
|
||||
|
||||
hook_forwarder.start_if_dashboard_available(reg, src="gateway")
|
||||
hook_forwarder.stop()
|
||||
hook_forwarder.stop() # second call is no-op
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Handler behavior — loop prevention + queue management
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHandlerBehavior:
|
||||
"""Tests the handler in isolation — skipping the worker thread by
|
||||
constructing the ``_HookForwarder`` directly so we can inspect the
|
||||
queue without racing on a daemon thread."""
|
||||
|
||||
def test_handler_enqueues_event(self):
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
fwd._handler("agent:start", {"platform": "telegram", "user_id": "u-1"})
|
||||
|
||||
assert fwd._queue.qsize() == 1
|
||||
frame = fwd._queue.get_nowait()
|
||||
assert frame == {
|
||||
"event_type": "agent:start",
|
||||
"context": {"platform": "telegram", "user_id": "u-1"},
|
||||
"src": "gateway",
|
||||
}
|
||||
|
||||
def test_handler_skips_forwarded_events(self):
|
||||
"""The ``_forwarded=True`` flag closes the source ↔ dashboard loop."""
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
|
||||
# This came from the ingest endpoint; must not be shipped back.
|
||||
fwd._handler(
|
||||
"agent:start",
|
||||
{"platform": "telegram", "_forwarded": True, "_forwarded_from": "gateway"},
|
||||
)
|
||||
|
||||
assert fwd._queue.qsize() == 0
|
||||
|
||||
def test_handler_does_not_skip_explicit_false_forwarded(self):
|
||||
"""Only the literal ``True`` triggers loop prevention; a ``False``
|
||||
or absent flag is fine."""
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
|
||||
fwd._handler("agent:start", {"_forwarded": False})
|
||||
fwd._handler("agent:start", {"_forwarded": None})
|
||||
|
||||
assert fwd._queue.qsize() == 2
|
||||
|
||||
def test_handler_drops_oldest_on_queue_full(self):
|
||||
"""At ``_QUEUE_MAX`` capacity, oldest frame is evicted to make
|
||||
room for newest."""
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
# Replace the queue with a tiny one so we can exercise overflow
|
||||
# without filling 1024 slots.
|
||||
from queue import Queue as _Queue
|
||||
|
||||
fwd._queue = _Queue(maxsize=3)
|
||||
|
||||
for i in range(5):
|
||||
fwd._handler("agent:step", {"iteration": i})
|
||||
|
||||
# Only the last 3 should survive (iterations 2, 3, 4).
|
||||
iterations = []
|
||||
while not fwd._queue.empty():
|
||||
iterations.append(fwd._queue.get_nowait()["context"]["iteration"])
|
||||
assert iterations == [2, 3, 4]
|
||||
|
||||
def test_handler_never_raises_on_pathological_queue(self):
|
||||
"""Even if the queue is wedged (both put_nowait calls fail), the
|
||||
handler returns cleanly — never propagates to the publisher."""
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
from queue import Queue as _Queue, Full as _Full
|
||||
|
||||
# Build a queue stub whose put_nowait always raises Full.
|
||||
class _StubbedFull(_Queue):
|
||||
def __init__(self):
|
||||
super().__init__(maxsize=1)
|
||||
|
||||
def put_nowait(self, item):
|
||||
raise _Full()
|
||||
|
||||
def get_nowait(self):
|
||||
from queue import Empty
|
||||
raise Empty()
|
||||
|
||||
fwd._queue = _StubbedFull() # type: ignore[assignment]
|
||||
|
||||
# Should not raise.
|
||||
fwd._handler("agent:step", {"iteration": 1})
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery refresh / probe behavior — exercised without starting the worker
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoveryRefresh:
|
||||
def test_refresh_picks_up_new_token(self, tmp_path, monkeypatch):
|
||||
"""A dashboard restart writes a new token; the next probe must
|
||||
pick it up."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
discovery_path = tmp_path / "dashboard.json"
|
||||
discovery_path.write_text(
|
||||
json.dumps({"url": "http://127.0.0.1:9119", "hooks_ingest_token": "first"})
|
||||
)
|
||||
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
|
||||
# Stub the http client's get() to always return 200 — the
|
||||
# discovery file is what we want to test here, not the probe.
|
||||
class _Stub200:
|
||||
status_code = 200
|
||||
|
||||
class _StubClient:
|
||||
def get(self, *a, **kw):
|
||||
return _Stub200()
|
||||
|
||||
fwd._refresh_discovery(_StubClient())
|
||||
assert fwd._discovery is not None
|
||||
assert fwd._discovery["hooks_ingest_token"] == "first"
|
||||
|
||||
# Now the dashboard "restarts" with a new token.
|
||||
discovery_path.write_text(
|
||||
json.dumps({"url": "http://127.0.0.1:9119", "hooks_ingest_token": "second"})
|
||||
)
|
||||
fwd._refresh_discovery(_StubClient())
|
||||
assert fwd._discovery["hooks_ingest_token"] == "second"
|
||||
|
||||
def test_refresh_invalidates_on_probe_failure(self, tmp_path, monkeypatch):
|
||||
"""If the health probe fails, discovery is cleared even though
|
||||
the file still exists."""
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
_write_discovery(tmp_path)
|
||||
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
# First probe succeeds.
|
||||
class _Stub200:
|
||||
status_code = 200
|
||||
|
||||
class _OkClient:
|
||||
def get(self, *a, **kw):
|
||||
return _Stub200()
|
||||
|
||||
fwd._refresh_discovery(_OkClient())
|
||||
assert fwd._discovery is not None
|
||||
|
||||
# Now make the probe fail — connection refused, dashboard gone.
|
||||
class _FailingClient:
|
||||
def get(self, *a, **kw):
|
||||
raise ConnectionError("refused")
|
||||
|
||||
fwd._refresh_discovery(_FailingClient())
|
||||
assert fwd._discovery is None
|
||||
|
||||
def test_refresh_invalidates_on_non_200_response(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
_write_discovery(tmp_path)
|
||||
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
|
||||
class _Stub503:
|
||||
status_code = 503
|
||||
|
||||
class _BadClient:
|
||||
def get(self, *a, **kw):
|
||||
return _Stub503()
|
||||
|
||||
fwd._refresh_discovery(_BadClient())
|
||||
assert fwd._discovery is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Error-log rate limiting
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestErrorRateLimit:
|
||||
def test_first_error_logs_immediately(self, caplog):
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
caplog.set_level("WARNING", logger="gateway.hook_forwarder")
|
||||
fwd._log_error("first error")
|
||||
|
||||
assert any("first error" in r.message for r in caplog.records)
|
||||
|
||||
def test_subsequent_errors_suppressed_until_window_elapses(self, caplog):
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
caplog.set_level("WARNING", logger="gateway.hook_forwarder")
|
||||
|
||||
fwd._log_error("first error")
|
||||
before = len(caplog.records)
|
||||
|
||||
# Many follow-up errors within the same minute — all suppressed.
|
||||
for _ in range(10):
|
||||
fwd._log_error("nth error")
|
||||
|
||||
assert len(caplog.records) == before
|
||||
|
||||
def test_suppression_count_surfaces_on_next_log(self, caplog, monkeypatch):
|
||||
fwd = hook_forwarder._HookForwarder(src="gateway")
|
||||
caplog.set_level("WARNING", logger="gateway.hook_forwarder")
|
||||
|
||||
fwd._log_error("first error")
|
||||
for _ in range(5):
|
||||
fwd._log_error("suppressed")
|
||||
|
||||
# Simulate the rate-limit window elapsing.
|
||||
fwd._last_error_log_at = time.monotonic() - hook_forwarder._ERROR_LOG_INTERVAL_S - 1
|
||||
fwd._log_error("after window")
|
||||
|
||||
# Last record should include the "5 similar errors suppressed" suffix.
|
||||
last = caplog.records[-1].message
|
||||
assert "after window" in last
|
||||
assert "5 similar errors suppressed" in last
|
||||
@@ -314,256 +314,3 @@ class TestEmitCollect:
|
||||
await reg.emit_collect("agent:start") # no context arg
|
||||
|
||||
assert captured == [("agent:start", {})]
|
||||
|
||||
|
||||
class TestRegister:
|
||||
"""Tests for the programmatic ``HookRegistry.register`` API."""
|
||||
|
||||
def test_registers_handler(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
def handler(event_type, context):
|
||||
calls.append((event_type, context))
|
||||
|
||||
reg.register("agent:start", handler)
|
||||
|
||||
assert "agent:start" in reg._handlers
|
||||
assert reg._handlers["agent:start"] == [handler]
|
||||
|
||||
def test_records_metadata_in_loaded_hooks(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
def my_handler(_e, _c):
|
||||
return None
|
||||
|
||||
reg.register("tui:tool.start", my_handler)
|
||||
|
||||
assert len(reg.loaded_hooks) == 1
|
||||
meta = reg.loaded_hooks[0]
|
||||
assert meta["name"] == "my_handler"
|
||||
assert meta["events"] == ["tui:tool.start"]
|
||||
assert meta["path"] == "<programmatic>"
|
||||
|
||||
def test_custom_name_override(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
reg.register("agent:end", lambda _e, _c: None, name="orb-collector")
|
||||
|
||||
assert reg.loaded_hooks[0]["name"] == "orb-collector"
|
||||
|
||||
def test_returns_working_unregister(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
def handler(_e, _c):
|
||||
return None
|
||||
|
||||
unregister = reg.register("agent:start", handler)
|
||||
|
||||
assert handler in reg._handlers["agent:start"]
|
||||
assert len(reg.loaded_hooks) == 1
|
||||
|
||||
unregister()
|
||||
|
||||
assert handler not in reg._handlers["agent:start"]
|
||||
assert len(reg.loaded_hooks) == 0
|
||||
|
||||
def test_unregister_is_idempotent(self):
|
||||
reg = HookRegistry()
|
||||
unregister = reg.register("agent:start", lambda _e, _c: None)
|
||||
unregister()
|
||||
# Second call should not raise.
|
||||
unregister()
|
||||
|
||||
def test_multiple_handlers_same_event(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
def h1(_e, _c):
|
||||
calls.append("h1")
|
||||
|
||||
def h2(_e, _c):
|
||||
calls.append("h2")
|
||||
|
||||
reg.register("agent:start", h1)
|
||||
reg.register("agent:start", h2)
|
||||
|
||||
assert reg._handlers["agent:start"] == [h1, h2]
|
||||
assert len(reg.loaded_hooks) == 2
|
||||
|
||||
def test_unregister_does_not_affect_other_handlers(self):
|
||||
reg = HookRegistry()
|
||||
|
||||
def h1(_e, _c):
|
||||
return None
|
||||
|
||||
def h2(_e, _c):
|
||||
return None
|
||||
|
||||
unreg1 = reg.register("agent:start", h1)
|
||||
reg.register("agent:start", h2)
|
||||
|
||||
unreg1()
|
||||
|
||||
assert h1 not in reg._handlers["agent:start"]
|
||||
assert h2 in reg._handlers["agent:start"]
|
||||
|
||||
|
||||
class TestEmitSync:
|
||||
"""Tests for the synchronous emit path used from hot non-async callers."""
|
||||
|
||||
def test_fires_sync_handler(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
reg.register(
|
||||
"tui:tool.start",
|
||||
lambda e, c: calls.append((e, c)),
|
||||
)
|
||||
|
||||
reg.emit_sync("tui:tool.start", {"session_id": "s1", "payload": {"name": "foo"}})
|
||||
|
||||
assert calls == [("tui:tool.start", {"session_id": "s1", "payload": {"name": "foo"}})]
|
||||
|
||||
def test_default_context_when_none(self):
|
||||
reg = HookRegistry()
|
||||
seen: list = []
|
||||
reg.register("evt:x", lambda _e, c: seen.append(c))
|
||||
|
||||
reg.emit_sync("evt:x") # no context arg
|
||||
|
||||
assert seen == [{}]
|
||||
|
||||
def test_sync_handler_exception_isolated(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
def bad(_e, _c):
|
||||
raise RuntimeError("boom")
|
||||
|
||||
def good(_e, _c):
|
||||
calls.append("good")
|
||||
|
||||
reg.register("evt:x", bad)
|
||||
reg.register("evt:x", good)
|
||||
|
||||
# Must not raise; second handler still fires.
|
||||
reg.emit_sync("evt:x", {})
|
||||
|
||||
assert calls == ["good"]
|
||||
|
||||
def test_wildcard_matching(self):
|
||||
reg = HookRegistry()
|
||||
calls: list = []
|
||||
|
||||
reg.register("tui:*", lambda e, _c: calls.append(e))
|
||||
reg.register("tui:tool.start", lambda e, _c: calls.append(f"exact:{e}"))
|
||||
|
||||
reg.emit_sync("tui:tool.start", {})
|
||||
|
||||
# Exact match first, then wildcard.
|
||||
assert calls == ["exact:tui:tool.start", "tui:tool.start"]
|
||||
|
||||
def test_no_handlers_does_not_raise(self):
|
||||
reg = HookRegistry()
|
||||
# Just shouldn't blow up.
|
||||
reg.emit_sync("nobody:listening", {"foo": "bar"})
|
||||
|
||||
def test_async_handler_skipped_with_no_loop(self, capsys):
|
||||
from gateway.hooks import _reset_default_registry_for_tests
|
||||
|
||||
_reset_default_registry_for_tests()
|
||||
reg = HookRegistry()
|
||||
marker: list = []
|
||||
|
||||
async def async_handler(_e, _c):
|
||||
marker.append("ran")
|
||||
|
||||
reg.register("evt:x", async_handler, name="async_handler_unique")
|
||||
|
||||
# First emit logs a warning and skips.
|
||||
reg.emit_sync("evt:x", {})
|
||||
captured = capsys.readouterr()
|
||||
# The warning uses the handler's __name__ for diagnostic clarity.
|
||||
assert "async_handler" in captured.out
|
||||
assert "Skipping async handler" in captured.out
|
||||
assert marker == [] # async handler never ran
|
||||
|
||||
# Second emit is silent (warning suppressed).
|
||||
reg.emit_sync("evt:x", {})
|
||||
captured = capsys.readouterr()
|
||||
assert captured.out == ""
|
||||
assert marker == []
|
||||
|
||||
def test_async_handler_scheduled_when_loop_running(self):
|
||||
import asyncio as _asyncio
|
||||
|
||||
reg = HookRegistry()
|
||||
marker: list = []
|
||||
|
||||
async def async_handler(_e, _c):
|
||||
marker.append("ran")
|
||||
|
||||
reg.register("evt:x", async_handler)
|
||||
|
||||
async def driver():
|
||||
reg.emit_sync("evt:x", {})
|
||||
# Yield to the loop so the scheduled task can run.
|
||||
await _asyncio.sleep(0)
|
||||
await _asyncio.sleep(0)
|
||||
|
||||
_asyncio.run(driver())
|
||||
|
||||
assert marker == ["ran"]
|
||||
|
||||
|
||||
class TestDefaultRegistry:
|
||||
"""Tests for the module-level default-registry singleton."""
|
||||
|
||||
def test_get_default_returns_same_instance(self):
|
||||
from gateway.hooks import (
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
)
|
||||
|
||||
_reset_default_registry_for_tests()
|
||||
|
||||
first = get_default_registry()
|
||||
second = get_default_registry()
|
||||
|
||||
assert first is second
|
||||
|
||||
def test_install_as_default_replaces(self):
|
||||
from gateway.hooks import (
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
install_as_default,
|
||||
)
|
||||
|
||||
_reset_default_registry_for_tests()
|
||||
|
||||
custom = HookRegistry()
|
||||
install_as_default(custom)
|
||||
|
||||
assert get_default_registry() is custom
|
||||
|
||||
def test_install_then_get_picks_up_handlers(self):
|
||||
from gateway.hooks import (
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
install_as_default,
|
||||
)
|
||||
|
||||
_reset_default_registry_for_tests()
|
||||
|
||||
custom = HookRegistry()
|
||||
install_as_default(custom)
|
||||
|
||||
calls: list = []
|
||||
get_default_registry().register("agent:x", lambda _e, _c: calls.append("hit"))
|
||||
|
||||
# Same handler is visible on the installed instance.
|
||||
custom.emit_sync("agent:x", {})
|
||||
|
||||
assert calls == ["hit"]
|
||||
|
||||
|
||||
@@ -0,0 +1,186 @@
|
||||
"""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
|
||||
@@ -9,6 +9,7 @@ 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
|
||||
@@ -1076,6 +1077,54 @@ 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
|
||||
|
||||
@@ -102,6 +102,45 @@ 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
|
||||
|
||||
@@ -91,10 +91,6 @@ 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]
|
||||
@@ -133,17 +129,11 @@ 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
|
||||
@@ -184,7 +174,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}, # lone group member
|
||||
{"slug": "xai", "name": "xAI", "total_models": 1},
|
||||
]
|
||||
|
||||
await adapter.send_model_picker(
|
||||
@@ -197,14 +187,11 @@ 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()
|
||||
@@ -216,7 +203,49 @@ class TestTelegramModelPicker:
|
||||
|
||||
assert "mp:minimax" in built
|
||||
assert "mp:minimax-cn" in built
|
||||
assert "mb" in built # back-to-providers button present
|
||||
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
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_retries_without_thread_when_thread_not_found(self):
|
||||
|
||||
@@ -415,14 +415,17 @@ 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), \
|
||||
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"), \
|
||||
@@ -430,9 +433,32 @@ 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
|
||||
@@ -1929,6 +1955,49 @@ 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
|
||||
|
||||
@@ -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 offer the existing manual-paste path."""
|
||||
"""Loopback timeout should accept a bare Grok Build code paste."""
|
||||
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": captured["state"],
|
||||
"state": None,
|
||||
"error": None,
|
||||
"error_description": None,
|
||||
}
|
||||
@@ -558,6 +558,48 @@ 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(
|
||||
|
||||
@@ -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=""):
|
||||
def _fake_prompt_model_selection(model_ids, current_model="", **_kwargs):
|
||||
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="": None,
|
||||
lambda model_ids, current_model="", **_kwargs: 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="": None,
|
||||
lambda model_ids, current_model="", **_kwargs: None,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
"hermes_cli.auth._login_openai_codex",
|
||||
|
||||
@@ -292,6 +292,25 @@ 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):
|
||||
@@ -335,6 +354,28 @@ 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)."""
|
||||
@@ -1056,3 +1097,50 @@ 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,6 +191,111 @@ 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
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""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()
|
||||
@@ -1,406 +0,0 @@
|
||||
"""Unit tests for ``hermes_cli/hook_ingest.py``.
|
||||
|
||||
Covers:
|
||||
|
||||
* Discovery file write/read/remove lifecycle
|
||||
* File permissions (0600) and atomic replace semantics
|
||||
* Auth gating on the ingest endpoint (401 without token, 200 with)
|
||||
* Body validation (400 on bad shape, missing keys, non-dict context)
|
||||
* Forwarded events are stamped with ``_forwarded=True`` and
|
||||
``_forwarded_from=<src>`` and republished via ``emit_sync``
|
||||
* Health endpoint returns 200 unconditionally and is unauthenticated
|
||||
* `--insecure` mode does not disable the ingest endpoint (the bearer
|
||||
token is the security boundary regardless of bind address)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import stat
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi import FastAPI
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from gateway.hooks import (
|
||||
HookRegistry,
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
install_as_default,
|
||||
)
|
||||
from hermes_cli import hook_ingest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _isolate(tmp_path, monkeypatch):
|
||||
"""Each test gets a fresh hermes home + fresh registry singleton +
|
||||
fresh ingest-token state. Without this the module-level
|
||||
``_HOOKS_INGEST_TOKEN`` from one test would leak into the next."""
|
||||
monkeypatch.setattr(hook_ingest, "get_hermes_home", lambda: tmp_path)
|
||||
hook_ingest._reset_for_tests()
|
||||
_reset_default_registry_for_tests()
|
||||
yield
|
||||
hook_ingest._reset_for_tests()
|
||||
_reset_default_registry_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def fresh_registry():
|
||||
"""Install a clean ``HookRegistry`` as the process default and return it."""
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
return reg
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hook_app(fresh_registry):
|
||||
"""A FastAPI app with the hook router mounted at /api/hooks."""
|
||||
app = FastAPI()
|
||||
app.include_router(hook_ingest.build_hook_router(), prefix="/api/hooks")
|
||||
return app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(hook_app):
|
||||
return TestClient(hook_app)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Discovery file lifecycle
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestDiscoveryFile:
|
||||
def test_write_creates_file_with_expected_shape(self, tmp_path):
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
path = tmp_path / "dashboard.json"
|
||||
assert path.exists()
|
||||
|
||||
data = json.loads(path.read_text())
|
||||
assert data["url"] == "http://127.0.0.1:9119"
|
||||
assert data["hooks_ingest_token"] == token
|
||||
assert isinstance(data["pid"], int)
|
||||
assert "started_at" in data
|
||||
# ISO-8601 timestamp.
|
||||
assert "T" in data["started_at"]
|
||||
|
||||
def test_write_returns_fresh_token_each_call(self, tmp_path):
|
||||
first = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
second = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
assert first != second # 32-byte urlsafe tokens collide ~never
|
||||
|
||||
def test_write_records_non_loopback_bind(self, tmp_path):
|
||||
"""``--insecure`` mode binds non-loopback; the discovery file
|
||||
must reflect that so forwarders dial the right address."""
|
||||
hook_ingest.write_dashboard_discovery_file("0.0.0.0", 9119)
|
||||
data = json.loads((tmp_path / "dashboard.json").read_text())
|
||||
assert data["url"] == "http://0.0.0.0:9119"
|
||||
|
||||
def test_write_creates_parent_dir_if_missing(self, monkeypatch, tmp_path):
|
||||
"""``$HERMES_HOME`` might not exist yet on first startup."""
|
||||
nested = tmp_path / "new" / "hermes"
|
||||
monkeypatch.setattr(hook_ingest, "get_hermes_home", lambda: nested)
|
||||
|
||||
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
assert (nested / "dashboard.json").exists()
|
||||
|
||||
def test_file_mode_is_0600(self, tmp_path):
|
||||
"""Discovery file holds a bearer token in cleartext; must be
|
||||
owner-only readable."""
|
||||
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
path = tmp_path / "dashboard.json"
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
# 0o600 = read+write for owner, nothing for group/other.
|
||||
assert mode == 0o600, f"expected 0600, got {oct(mode)}"
|
||||
|
||||
def test_remove_deletes_file(self, tmp_path):
|
||||
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
assert (tmp_path / "dashboard.json").exists()
|
||||
|
||||
hook_ingest.remove_dashboard_discovery_file()
|
||||
assert not (tmp_path / "dashboard.json").exists()
|
||||
|
||||
def test_remove_is_idempotent(self):
|
||||
# Never written.
|
||||
hook_ingest.remove_dashboard_discovery_file()
|
||||
# And again — must not raise.
|
||||
hook_ingest.remove_dashboard_discovery_file()
|
||||
|
||||
def test_remove_clears_in_memory_token(self):
|
||||
"""Once removed, no one can authenticate to ingest anymore."""
|
||||
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
assert hook_ingest.get_current_token_for_tests() != ""
|
||||
|
||||
hook_ingest.remove_dashboard_discovery_file()
|
||||
assert hook_ingest.get_current_token_for_tests() == ""
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /health endpoint
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
def test_health_returns_200_unauthenticated(self, client):
|
||||
r = client.get("/api/hooks/health")
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
def test_health_returns_200_with_no_token_set(self, client):
|
||||
"""Health probe must succeed even before any token has been
|
||||
written (forwarder probes before discovery is established)."""
|
||||
# No write_dashboard_discovery_file call — token stays "".
|
||||
r = client.get("/api/hooks/health")
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /ingest endpoint — auth
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIngestAuth:
|
||||
def test_ingest_401_without_token(self, client):
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
del token # noqa: F841 — we intentionally don't send it.
|
||||
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_ingest_401_with_wrong_token(self, client):
|
||||
hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
|
||||
headers={"Authorization": "Bearer wrong"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_ingest_401_when_no_token_set(self, client):
|
||||
"""Discovery file never written ⇒ ingest refuses everything."""
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
|
||||
headers={"Authorization": "Bearer something"},
|
||||
)
|
||||
assert r.status_code == 401
|
||||
|
||||
def test_ingest_200_with_valid_token(self, client):
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert r.json() == {"ok": True}
|
||||
|
||||
def test_ingest_works_for_non_loopback_bind(self, client):
|
||||
"""``--insecure`` doesn't disable ingest. The bearer token IS
|
||||
the security boundary regardless of bind address (see
|
||||
DESIGN-cross-process-hooks.md "Bind-address independence")."""
|
||||
token = hook_ingest.write_dashboard_discovery_file("0.0.0.0", 9119)
|
||||
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start", "context": {}, "src": "gateway"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /ingest endpoint — body validation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIngestBodyValidation:
|
||||
@pytest.fixture
|
||||
def auth_headers(self):
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
def test_400_on_non_json_body(self, client, auth_headers):
|
||||
# FastAPI itself produces 422 for parse failures, but we explicitly
|
||||
# request JSON parsing so the response should be 400 with our
|
||||
# message — actually, request.json() on bad JSON raises and we
|
||||
# convert it to 400.
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
content=b"not json",
|
||||
headers={**auth_headers, "Content-Type": "application/json"},
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_400_on_non_dict_body(self, client, auth_headers):
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json=["this", "is", "an", "array"],
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "object" in r.json()["detail"]
|
||||
|
||||
def test_400_on_missing_event_type(self, client, auth_headers):
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"context": {}, "src": "gateway"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
assert "event_type" in r.json()["detail"]
|
||||
|
||||
def test_400_on_empty_event_type(self, client, auth_headers):
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "", "context": {}, "src": "gateway"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_400_on_non_dict_context(self, client, auth_headers):
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start", "context": "string", "src": "gateway"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 400
|
||||
|
||||
def test_200_with_missing_optional_fields(self, client, auth_headers):
|
||||
"""src and context are optional; default to "?" and {}."""
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start"},
|
||||
headers=auth_headers,
|
||||
)
|
||||
assert r.status_code == 200
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# /ingest endpoint — republish behavior
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class TestIngestRepublish:
|
||||
def test_republishes_via_emit_sync(self, client, fresh_registry):
|
||||
captured: list = []
|
||||
fresh_registry.register("agent:start", lambda e, c: captured.append((e, c)))
|
||||
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={
|
||||
"event_type": "agent:start",
|
||||
"context": {"platform": "telegram", "user_id": "u-1"},
|
||||
"src": "gateway",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert len(captured) == 1
|
||||
|
||||
event, ctx = captured[0]
|
||||
assert event == "agent:start"
|
||||
# Original context keys preserved.
|
||||
assert ctx["platform"] == "telegram"
|
||||
assert ctx["user_id"] == "u-1"
|
||||
# Forwarding metadata stamped.
|
||||
assert ctx["_forwarded"] is True
|
||||
assert ctx["_forwarded_from"] == "gateway"
|
||||
|
||||
def test_forwarded_stamp_overrides_caller_provided_value(
|
||||
self, client, fresh_registry
|
||||
):
|
||||
"""If a malicious/buggy caller tries to set _forwarded=False to
|
||||
smuggle the event past loop prevention, the endpoint overrides
|
||||
it. This isn't a security boundary (the auth gate is) but a
|
||||
defensive sanity check."""
|
||||
captured: list = []
|
||||
fresh_registry.register("agent:start", lambda e, c: captured.append(c))
|
||||
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={
|
||||
"event_type": "agent:start",
|
||||
"context": {"_forwarded": False},
|
||||
"src": "gateway",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert captured[0]["_forwarded"] is True
|
||||
|
||||
def test_wildcard_handlers_see_forwarded_events(self, client, fresh_registry):
|
||||
"""A subscriber to ``tui:*`` sees forwarded ``tui:tool.start`` events."""
|
||||
captured: list = []
|
||||
fresh_registry.register("tui:*", lambda e, c: captured.append(e))
|
||||
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={
|
||||
"event_type": "tui:tool.start",
|
||||
"context": {"session_id": "s-1", "payload": {"name": "search_files"}},
|
||||
"src": "tui",
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert captured == ["tui:tool.start"]
|
||||
|
||||
def test_src_defaults_to_question_mark(self, client, fresh_registry):
|
||||
captured: list = []
|
||||
fresh_registry.register("agent:start", lambda e, c: captured.append(c))
|
||||
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start", "context": {}},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert captured[0]["_forwarded_from"] == "?"
|
||||
|
||||
def test_non_string_src_is_normalized(self, client, fresh_registry):
|
||||
"""If something weird sends src=123, we don't propagate the bad type."""
|
||||
captured: list = []
|
||||
fresh_registry.register("agent:start", lambda e, c: captured.append(c))
|
||||
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:start", "context": {}, "src": 123},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
assert captured[0]["_forwarded_from"] == "?"
|
||||
|
||||
def test_handler_exception_does_not_break_ingest(self, client, fresh_registry):
|
||||
"""A buggy subscriber raising in emit_sync must not 500 the
|
||||
ingest endpoint — emit_sync swallows handler exceptions, but
|
||||
the route also has a top-level try/except defensive layer."""
|
||||
fresh_registry.register("agent:end", lambda _e, _c: 1 / 0)
|
||||
|
||||
token = hook_ingest.write_dashboard_discovery_file("127.0.0.1", 9119)
|
||||
|
||||
r = client.post(
|
||||
"/api/hooks/ingest",
|
||||
json={"event_type": "agent:end", "context": {}, "src": "gateway"},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
assert r.status_code == 200
|
||||
@@ -0,0 +1,97 @@
|
||||
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
|
||||
@@ -0,0 +1,64 @@
|
||||
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
|
||||
@@ -653,6 +653,44 @@ 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,6 +2,7 @@
|
||||
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
|
||||
|
||||
@@ -24,6 +25,46 @@ 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
|
||||
|
||||
|
||||
@@ -0,0 +1,218 @@
|
||||
"""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
|
||||
@@ -4,6 +4,7 @@ import os
|
||||
import json
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
import pytest
|
||||
@@ -1069,6 +1070,41 @@ 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
|
||||
@@ -1363,6 +1399,17 @@ 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
|
||||
@@ -1384,13 +1431,138 @@ class TestWebServerEndpoints:
|
||||
"ok": True,
|
||||
"platform": "telegram",
|
||||
"bot_username": "hermes_pair_ready_bot",
|
||||
"needs_restart": True,
|
||||
"needs_restart": False,
|
||||
"restart_started": True,
|
||||
"restart_action": "gateway-restart",
|
||||
"restart_pid": 4242,
|
||||
}
|
||||
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
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
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"
|
||||
@@ -171,6 +171,40 @@ 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).
|
||||
#
|
||||
|
||||
@@ -291,6 +291,121 @@ 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):
|
||||
|
||||
@@ -5063,6 +5063,41 @@ 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."""
|
||||
|
||||
@@ -1,319 +0,0 @@
|
||||
"""End-to-end integration test for cross-process hook delivery.
|
||||
|
||||
Wires together (in the SAME process to avoid subprocess complexity, but
|
||||
end-to-end through real HTTP + a real FastAPI app):
|
||||
|
||||
Source HookRegistry ──forwarder──HTTP──> /api/hooks/ingest ──> Dashboard HookRegistry
|
||||
│
|
||||
└─> handler fires
|
||||
|
||||
This validates the wire-format contract between the forwarder and the
|
||||
ingest endpoint: anything the forwarder produces must be accepted and
|
||||
republished correctly by the ingest endpoint.
|
||||
|
||||
The test runs uvicorn in a daemon thread so we have a real OS socket
|
||||
forwarders can POST to. ``dashboard.json`` is written into a temp
|
||||
$HERMES_HOME so the forwarder finds the test server instead of any
|
||||
real running dashboard on the dev box.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import socket
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Callable
|
||||
|
||||
import pytest
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
|
||||
from gateway import hook_forwarder
|
||||
from gateway.hooks import (
|
||||
HookRegistry,
|
||||
_reset_default_registry_for_tests,
|
||||
install_as_default,
|
||||
)
|
||||
from hermes_cli import hook_ingest
|
||||
|
||||
|
||||
def _free_port() -> int:
|
||||
"""Find an unused TCP port on loopback by binding port 0."""
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
|
||||
s.bind(("127.0.0.1", 0))
|
||||
return s.getsockname()[1]
|
||||
|
||||
|
||||
def _wait_until(predicate: Callable[[], bool], *, timeout: float = 5.0) -> bool:
|
||||
"""Poll a predicate until it returns True or the timeout elapses.
|
||||
|
||||
Cheaper than ``time.sleep`` calls littered through the test body;
|
||||
keeps test runtime down when the system is fast and bounded when
|
||||
it's slow.
|
||||
"""
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if predicate():
|
||||
return True
|
||||
time.sleep(0.02)
|
||||
return False
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hermes_home(tmp_path, monkeypatch):
|
||||
"""Each test gets a fresh $HERMES_HOME so ``dashboard.json`` writes
|
||||
don't bleed across tests (or into the real dev box's home)."""
|
||||
monkeypatch.setattr(hook_ingest, "get_hermes_home", lambda: tmp_path)
|
||||
monkeypatch.setattr(hook_forwarder, "get_hermes_home", lambda: tmp_path)
|
||||
hook_ingest._reset_for_tests()
|
||||
hook_forwarder._reset_for_tests()
|
||||
yield tmp_path
|
||||
hook_ingest._reset_for_tests()
|
||||
hook_forwarder._reset_for_tests()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def dashboard_server(hermes_home):
|
||||
"""Start a real uvicorn server with the hook router mounted, write a
|
||||
matching ``dashboard.json``, and tear it all down on test exit."""
|
||||
# Build the dashboard's FastAPI app. Just the hook router — no need
|
||||
# to load the full web_server which would pull in the SPA build,
|
||||
# auth providers, etc.
|
||||
app = FastAPI()
|
||||
app.include_router(hook_ingest.build_hook_router(), prefix="/api/hooks")
|
||||
|
||||
# Install a fresh default registry on the (test) dashboard side.
|
||||
# This is what the ingest endpoint republishes events onto.
|
||||
_reset_default_registry_for_tests()
|
||||
dashboard_reg = HookRegistry()
|
||||
install_as_default(dashboard_reg)
|
||||
|
||||
port = _free_port()
|
||||
config = uvicorn.Config(app, host="127.0.0.1", port=port, log_level="warning")
|
||||
server = uvicorn.Server(config)
|
||||
|
||||
server_thread = threading.Thread(target=server.run, daemon=True)
|
||||
server_thread.start()
|
||||
|
||||
# Wait for the server to start accepting connections.
|
||||
if not _wait_until(lambda: server.started, timeout=5.0):
|
||||
raise RuntimeError("uvicorn failed to start within 5s")
|
||||
|
||||
# Drop the discovery file pointing at our test server.
|
||||
hook_ingest.write_dashboard_discovery_file("127.0.0.1", port)
|
||||
|
||||
yield {"app": app, "port": port, "registry": dashboard_reg}
|
||||
|
||||
# Teardown: gracefully shut down the server.
|
||||
server.should_exit = True
|
||||
server_thread.join(timeout=5.0)
|
||||
_reset_default_registry_for_tests()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# End-to-end: source → forwarder → HTTP → ingest → dashboard registry
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_end_to_end_event_delivery(dashboard_server, hermes_home):
|
||||
"""A single fired event in the source registry reaches a subscriber
|
||||
on the dashboard registry via real HTTP."""
|
||||
captured: list = []
|
||||
dashboard_server["registry"].register(
|
||||
"agent:start", lambda e, c: captured.append((e, c))
|
||||
)
|
||||
|
||||
# Source-side registry — simulates the gateway process.
|
||||
source_reg = HookRegistry()
|
||||
hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
|
||||
|
||||
try:
|
||||
source_reg.emit_sync(
|
||||
"agent:start",
|
||||
{"platform": "telegram", "user_id": "u-1", "session_id": "s-1"},
|
||||
)
|
||||
|
||||
# Wait for the daemon worker thread to flush the queue + POST.
|
||||
assert _wait_until(lambda: len(captured) >= 1, timeout=10.0), (
|
||||
"Event never reached the dashboard subscriber"
|
||||
)
|
||||
|
||||
event, ctx = captured[0]
|
||||
assert event == "agent:start"
|
||||
# Original context survived the round trip.
|
||||
assert ctx["platform"] == "telegram"
|
||||
assert ctx["user_id"] == "u-1"
|
||||
assert ctx["session_id"] == "s-1"
|
||||
# And the ingest endpoint stamped forwarding metadata.
|
||||
assert ctx["_forwarded"] is True
|
||||
assert ctx["_forwarded_from"] == "gateway"
|
||||
finally:
|
||||
hook_forwarder.stop()
|
||||
|
||||
|
||||
def test_multiple_namespaces_round_trip(dashboard_server, hermes_home):
|
||||
"""The forwarder covers every namespace the design promises."""
|
||||
captured: dict = {ns: [] for ns in ("tui:*", "agent:*", "session:*", "command:*")}
|
||||
for ns in captured:
|
||||
# Closure over ns — bind it as a default arg to avoid late binding.
|
||||
dashboard_server["registry"].register(
|
||||
ns, lambda e, _c, _ns=ns: captured[_ns].append(e)
|
||||
)
|
||||
|
||||
source_reg = HookRegistry()
|
||||
hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
|
||||
|
||||
try:
|
||||
# One event per namespace.
|
||||
source_reg.emit_sync("tui:tool.start", {"session_id": "s", "payload": {}})
|
||||
source_reg.emit_sync("agent:start", {"user_id": "u"})
|
||||
source_reg.emit_sync("session:reset", {"session_key": "k"})
|
||||
source_reg.emit_sync("command:reset", {"command": "reset"})
|
||||
|
||||
assert _wait_until(
|
||||
lambda: all(len(v) >= 1 for v in captured.values()),
|
||||
timeout=10.0,
|
||||
), f"Some events missing: {captured}"
|
||||
|
||||
assert captured["tui:*"] == ["tui:tool.start"]
|
||||
assert captured["agent:*"] == ["agent:start"]
|
||||
assert captured["session:*"] == ["session:reset"]
|
||||
assert captured["command:*"] == ["command:reset"]
|
||||
finally:
|
||||
hook_forwarder.stop()
|
||||
|
||||
|
||||
def test_loop_prevention_forwarded_events_not_reshipped(dashboard_server, hermes_home):
|
||||
"""An event whose context already has ``_forwarded=True`` is NOT
|
||||
shipped by the source-side forwarder.
|
||||
|
||||
This is what closes the loop: dashboard republishes an event into
|
||||
its own registry with ``_forwarded=True``, and if a forwarder were
|
||||
also running in that process (it isn't, by design — but defense in
|
||||
depth) it would skip the event instead of round-tripping it back.
|
||||
"""
|
||||
# Counter on the dashboard side — bumps every time the ingest
|
||||
# endpoint fires the agent:start handler.
|
||||
ingest_hits: list = []
|
||||
dashboard_server["registry"].register(
|
||||
"agent:start", lambda _e, _c: ingest_hits.append(1)
|
||||
)
|
||||
|
||||
source_reg = HookRegistry()
|
||||
hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
|
||||
|
||||
try:
|
||||
# Fire an event that's already marked as forwarded — simulates
|
||||
# an event that came back to the source process somehow. The
|
||||
# forwarder must skip it (no POST to /ingest).
|
||||
source_reg.emit_sync(
|
||||
"agent:start",
|
||||
{
|
||||
"platform": "telegram",
|
||||
"_forwarded": True,
|
||||
"_forwarded_from": "dashboard-echo",
|
||||
},
|
||||
)
|
||||
|
||||
# Wait long enough that if a POST were going to happen, it
|
||||
# would have. Then verify NO ingest hits occurred.
|
||||
time.sleep(0.3)
|
||||
assert ingest_hits == [], (
|
||||
f"Forwarder shipped a _forwarded=True event: {ingest_hits}"
|
||||
)
|
||||
finally:
|
||||
hook_forwarder.stop()
|
||||
|
||||
|
||||
def test_forwarder_recovers_from_dashboard_restart(dashboard_server, hermes_home):
|
||||
"""If the dashboard rotates its token (restart), the forwarder's
|
||||
next probe re-reads ``dashboard.json`` and picks up the new token.
|
||||
|
||||
Unit-tested in detail in ``test_hook_forwarder.py::TestDiscoveryRefresh``.
|
||||
Here we pin the integration: after a token rotation + probe, events
|
||||
still land at the dashboard.
|
||||
"""
|
||||
captured: list = []
|
||||
dashboard_server["registry"].register(
|
||||
"agent:start", lambda _e, c: captured.append(c)
|
||||
)
|
||||
|
||||
source_reg = HookRegistry()
|
||||
hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
|
||||
|
||||
try:
|
||||
# First event lands cleanly with the original token.
|
||||
source_reg.emit_sync("agent:start", {"seq": 1})
|
||||
assert _wait_until(lambda: len(captured) >= 1, timeout=5.0)
|
||||
assert captured[0]["seq"] == 1
|
||||
|
||||
# Simulate dashboard restart: token rotates in dashboard.json
|
||||
# AND in the ingest endpoint's module state (we're sharing the
|
||||
# same hook_ingest module so write_dashboard_discovery_file
|
||||
# updates both).
|
||||
port = dashboard_server["port"]
|
||||
hook_ingest.write_dashboard_discovery_file("127.0.0.1", port)
|
||||
|
||||
# Force a discovery refresh directly — simulates what the
|
||||
# forwarder's worker does on its 30s probe cycle. Using the
|
||||
# active forwarder's HTTP client this way avoids a 30s
|
||||
# real-time wait.
|
||||
import httpx
|
||||
with httpx.Client(timeout=2.0) as client:
|
||||
fwd = hook_forwarder._active
|
||||
assert fwd is not None
|
||||
fwd._refresh_discovery(client)
|
||||
|
||||
source_reg.emit_sync("agent:start", {"seq": 2})
|
||||
assert _wait_until(lambda: len(captured) >= 2, timeout=10.0)
|
||||
assert captured[1]["seq"] == 2
|
||||
finally:
|
||||
hook_forwarder.stop()
|
||||
|
||||
|
||||
def test_no_dashboard_available_silent_noop(hermes_home):
|
||||
"""When no dashboard is reachable (no discovery file), the forwarder
|
||||
is a complete no-op — no thread spawned, no handlers registered."""
|
||||
# Don't start the dashboard fixture — there's nothing to forward to.
|
||||
source_reg = HookRegistry()
|
||||
result = hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
|
||||
|
||||
assert result is None
|
||||
assert source_reg._handlers == {}
|
||||
# And firing an event doesn't cause anything to happen.
|
||||
source_reg.emit_sync("agent:start", {}) # must not raise
|
||||
|
||||
|
||||
def test_no_dashboard_then_dashboard_starts_later(hermes_home):
|
||||
"""``start_if_dashboard_available`` is a one-shot check at call time.
|
||||
|
||||
If no dashboard is running when the source process starts, the
|
||||
forwarder doesn't start. Documented behavior: source-process
|
||||
consumers must call ``start_if_dashboard_available`` once at startup;
|
||||
if a dashboard appears later, only newly-started processes pick it
|
||||
up. (A long-running process with no forwarder will never get one
|
||||
retroactively.)
|
||||
|
||||
This test pins that contract so we notice if we accidentally add
|
||||
retry behavior — that would be a behavior change worth discussing,
|
||||
not a sneak in.
|
||||
"""
|
||||
source_reg = HookRegistry()
|
||||
# First call: no dashboard yet.
|
||||
result = hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
|
||||
assert result is None
|
||||
|
||||
# Dashboard appears.
|
||||
port = _free_port()
|
||||
hook_ingest.write_dashboard_discovery_file("127.0.0.1", port)
|
||||
|
||||
# Calling again does start the forwarder now. (The contract says
|
||||
# the wire-up sites only call once at startup, but the function
|
||||
# itself supports retries — useful for tests, and so callers can
|
||||
# call it from a post-config hook if they want.)
|
||||
result = hook_forwarder.start_if_dashboard_available(source_reg, src="gateway")
|
||||
assert result is not None
|
||||
|
||||
hook_forwarder.stop()
|
||||
@@ -0,0 +1,137 @@
|
||||
"""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
|
||||
@@ -1,185 +0,0 @@
|
||||
"""Tests for the TUI gateway → ``gateway.hooks`` bridge.
|
||||
|
||||
Every call into ``tui_gateway.server._emit`` should mirror the event onto the
|
||||
process-wide ``HookRegistry`` under the ``tui:`` namespace so in-process
|
||||
plugins can subscribe via :func:`gateway.hooks.get_default_registry`.
|
||||
|
||||
The mirror runs as a side-effect after ``write_json`` and is wrapped in a
|
||||
broad try/except so a buggy subscriber can never break the main JSON-RPC
|
||||
dispatch path.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from gateway.hooks import (
|
||||
HookRegistry,
|
||||
_reset_default_registry_for_tests,
|
||||
get_default_registry,
|
||||
install_as_default,
|
||||
)
|
||||
from tui_gateway import server
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _reset_registry_and_module_cache():
|
||||
"""Reset the default registry and the TUI module-level cache before each test.
|
||||
|
||||
Without this the cache from a previous test (or a previous run within the
|
||||
same process) would shadow our fresh install_as_default call and the
|
||||
mirrored event would land on the wrong registry.
|
||||
"""
|
||||
_reset_default_registry_for_tests()
|
||||
# Force the deferred-import cache in the TUI module to re-resolve.
|
||||
server._hook_registry = None
|
||||
# Also reset the forwarder-start sentinel so each test's first emit
|
||||
# re-evaluates "is a dashboard reachable?" against the fixture's
|
||||
# state instead of remembering a previous test's outcome.
|
||||
server._forwarder_started = False
|
||||
yield
|
||||
_reset_default_registry_for_tests()
|
||||
server._hook_registry = None
|
||||
server._forwarder_started = False
|
||||
|
||||
|
||||
class _StubTransport:
|
||||
"""Captures write_json calls so the test doesn't actually touch stdout."""
|
||||
|
||||
def __init__(self):
|
||||
self.written: list[dict] = []
|
||||
|
||||
def write(self, obj):
|
||||
self.written.append(obj)
|
||||
return True
|
||||
|
||||
|
||||
def test_emit_mirrors_to_default_registry():
|
||||
transport = _StubTransport()
|
||||
captured: list = []
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
reg.register("tui:tool.start", lambda e, c: captured.append((e, c)))
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "sid-123", {"name": "search_files"})
|
||||
|
||||
# The JSON-RPC event was written as before.
|
||||
assert len(transport.written) == 1
|
||||
assert transport.written[0]["params"]["type"] == "tool.start"
|
||||
|
||||
# The hook bus saw a tui:-prefixed mirror.
|
||||
assert captured == [
|
||||
(
|
||||
"tui:tool.start",
|
||||
{"session_id": "sid-123", "payload": {"name": "search_files"}},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
def test_emit_with_no_payload_yields_empty_payload_dict():
|
||||
transport = _StubTransport()
|
||||
captured: list = []
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
reg.register("tui:session.info", lambda _e, c: captured.append(c))
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("session.info", "sid-1", None)
|
||||
|
||||
assert captured == [{"session_id": "sid-1", "payload": {}}]
|
||||
|
||||
|
||||
def test_emit_subscriber_exception_does_not_break_dispatch():
|
||||
transport = _StubTransport()
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
|
||||
def broken(_e, _c):
|
||||
raise RuntimeError("subscriber blew up")
|
||||
|
||||
reg.register("tui:tool.start", broken)
|
||||
|
||||
# If _publish_tui_hook propagated, this would raise.
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "sid-1", {"name": "x"})
|
||||
|
||||
# JSON-RPC event still landed on stdout — host pipeline intact.
|
||||
assert len(transport.written) == 1
|
||||
assert transport.written[0]["params"]["type"] == "tool.start"
|
||||
|
||||
|
||||
def test_wildcard_subscriber_sees_all_tui_events():
|
||||
transport = _StubTransport()
|
||||
seen_types: list = []
|
||||
reg = HookRegistry()
|
||||
install_as_default(reg)
|
||||
reg.register("tui:*", lambda e, _c: seen_types.append(e))
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "s", {})
|
||||
server._emit("message.delta", "s", {"text": "hi"})
|
||||
server._emit("session.info", "s", {})
|
||||
|
||||
assert seen_types == [
|
||||
"tui:tool.start",
|
||||
"tui:message.delta",
|
||||
"tui:session.info",
|
||||
]
|
||||
|
||||
|
||||
def test_emit_does_not_blow_up_when_no_subscribers():
|
||||
transport = _StubTransport()
|
||||
# No registry installed beyond the lazy default — and no handlers.
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "s", {"name": "x"})
|
||||
|
||||
# Dispatch worked, no subscribers fired (default registry is empty).
|
||||
assert len(transport.written) == 1
|
||||
|
||||
|
||||
def test_hook_registry_resolved_lazily_via_get_default_registry():
|
||||
"""The TUI module caches whatever ``get_default_registry`` returns at first
|
||||
use. Re-set the default before the first ``_emit`` and confirm the cache
|
||||
picks up the new instance, not a stale or never-installed one."""
|
||||
transport = _StubTransport()
|
||||
captured: list = []
|
||||
custom = HookRegistry()
|
||||
install_as_default(custom)
|
||||
custom.register("tui:tool.start", lambda _e, c: captured.append(c))
|
||||
|
||||
# Sanity: server._hook_registry starts unset thanks to the fixture.
|
||||
assert server._hook_registry is None
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "s", {"x": 1})
|
||||
|
||||
# The cache now points at the installed registry.
|
||||
assert server._hook_registry is custom
|
||||
assert captured == [{"session_id": "s", "payload": {"x": 1}}]
|
||||
|
||||
# Subsequent emits keep using the cached reference even if the default
|
||||
# is swapped — the contract is "resolve once, cache thereafter."
|
||||
new_reg = HookRegistry()
|
||||
install_as_default(new_reg)
|
||||
second: list = []
|
||||
new_reg.register("tui:tool.start", lambda _e, c: second.append(c))
|
||||
custom.register("tui:tool.start", lambda _e, c: captured.append({"second": c}))
|
||||
|
||||
with patch.object(server, "_stdio_transport", transport):
|
||||
server._emit("tool.start", "s", {"x": 2})
|
||||
|
||||
# The cached registry (``custom``) saw the new event, the freshly-installed
|
||||
# ``new_reg`` did not.
|
||||
assert second == []
|
||||
# ``custom`` has two handlers registered now (the original lambda still
|
||||
# fires on every event, plus the second one that wraps payload in
|
||||
# ``{"second": ...}``). Both fire on the second ``_emit`` call.
|
||||
assert captured == [
|
||||
{"session_id": "s", "payload": {"x": 1}},
|
||||
{"session_id": "s", "payload": {"x": 2}},
|
||||
{"second": {"session_id": "s", "payload": {"x": 2}}},
|
||||
]
|
||||
assert get_default_registry() is new_reg
|
||||
@@ -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):
|
||||
def fake_apply(sid, target, raw, **kwargs):
|
||||
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):
|
||||
def _fake_apply(sid, session, raw, **_kwargs):
|
||||
seen["args"] = (sid, session["session_key"], raw)
|
||||
return {"value": "new/model", "warning": "catalog unreachable"}
|
||||
|
||||
@@ -2442,6 +2442,74 @@ 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"
|
||||
@@ -3944,7 +4012,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):
|
||||
def _fake_apply(sid, session, raw, **_kwargs):
|
||||
seen["called"] = True
|
||||
return {"value": raw, "warning": ""}
|
||||
|
||||
@@ -3978,7 +4046,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):
|
||||
def _fake_apply(sid, session, raw, **_kwargs):
|
||||
seen["called"] = True
|
||||
return {"value": "newmodel", "warning": ""}
|
||||
|
||||
|
||||
@@ -338,7 +338,7 @@ class TestCaptureResponse:
|
||||
from tools.computer_use.backend import CaptureResult
|
||||
from tools.computer_use import tool as cu_tool
|
||||
|
||||
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
||||
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nGNgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
|
||||
|
||||
class FakeBackend:
|
||||
def start(self): pass
|
||||
@@ -372,11 +372,41 @@ 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 = "iVBORw0KGgo="
|
||||
fake_png = "iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nGNgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
|
||||
|
||||
class FakeBackend:
|
||||
def start(self): pass
|
||||
|
||||
@@ -33,10 +33,10 @@ import pytest
|
||||
# Fixtures / helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# 1×1 PNG (transparent) — minimal bytes that decode cleanly.
|
||||
# 8×8 PNG (transparent) — minimal provider-acceptable bytes that decode cleanly.
|
||||
_PNG_B64 = (
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42m"
|
||||
"NkYAAAAAYAAjCB0C8AAAAASUVORK5CYII="
|
||||
"iVBORw0KGgoAAAANSUhEUgAAAAgAAAAICAYAAADED76LAAAADUlEQVR4nG"
|
||||
"NgGAUgAAABCAABgukLHQAAAABJRU5ErkJggg=="
|
||||
)
|
||||
|
||||
# 1×1 JPEG — used to verify mime detection works for either stream type.
|
||||
|
||||
@@ -172,6 +172,31 @@ 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)
|
||||
|
||||
@@ -321,12 +321,19 @@ class TestStdioPgroupReaping:
|
||||
|
||||
psutil = pytest.importorskip("psutil")
|
||||
|
||||
# Grandchild: sleep forever, write its pid then wait.
|
||||
# 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_pid_file = tmp_path / "grandchild.pid"
|
||||
grandchild_script = tmp_path / "grandchild.py"
|
||||
grandchild_script.write_text(
|
||||
"import os, sys, time\n"
|
||||
f"open({str(grandchild_pid_file)!r}, 'w').write(str(os.getpid()))\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"
|
||||
"while True:\n"
|
||||
" time.sleep(0.5)\n"
|
||||
)
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""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
|
||||
@@ -14,13 +16,15 @@ from tools.skills_hub import (
|
||||
UrlSource,
|
||||
WellKnownSkillSource,
|
||||
OptionalSkillSource,
|
||||
SkillMeta,
|
||||
SkillSource,
|
||||
SkillBundle,
|
||||
SkillMeta,
|
||||
HubLockFile,
|
||||
TapsManager,
|
||||
bundle_content_hash,
|
||||
check_for_skill_updates,
|
||||
create_source_router,
|
||||
parallel_search_sources,
|
||||
unified_search,
|
||||
append_audit_log,
|
||||
_skill_meta_to_dict,
|
||||
@@ -2201,3 +2205,80 @@ 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
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user