Compare commits

..
Author SHA1 Message Date
Austin Pickett ea0efea2bd fix(cli): /plugins shows installed-but-not-enabled plugins
The /plugins slash command read from the live PluginManager, which only
knows about *loaded* plugins. A freshly-installed plugin that hadn't been
enabled yet showed 'No plugins installed. Drop plugin directories into
~/.hermes/plugins/' — even though it was on disk and a valid plugin.

Switch to the same disk-discovery path as 'hermes plugins list'
(_discover_all_plugins + enabled/disabled sets + _plugin_status), so an
installed plugin now appears with its activation state ([not enabled],
enabled, or disabled) plus the exact enable command.

Default the quick /plugins view to user-installed plugins and summarize
bundled providers/platforms on one line (the full catalog stays behind
'hermes plugins list') so the output isn't drowned by 60+ bundled
provider plugins.
2026-06-09 00:13:38 -04:00
128 changed files with 1052 additions and 6436 deletions
+8 -40
View File
@@ -55,31 +55,15 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Run tests (slice ${{ matrix.slice }}/6)
# Per-file isolation via scripts/run_tests_parallel.py: discovers
@@ -177,31 +161,15 @@ jobs:
- name: Install uv
uses: astral-sh/setup-uv@d4b2f3b6ecc6e67c4457f6d3e41ec42d3d0fcb86 # v5
with:
# Persist uv's download/wheel cache (~/.cache/uv) across runs.
# Keyed on the dependency manifests, so the cache is reused until
# pyproject.toml or uv.lock changes. `uv sync` still runs every
# time, but resolves from the warm cache instead of re-downloading
# and re-building wheels.
enable-cache: true
cache-dependency-glob: |
pyproject.toml
uv.lock
- name: Set up Python 3.11
run: uv python install 3.11
- name: Install dependencies
# `uv sync --locked` installs the exact pinned set from uv.lock (and
# fails if the lock is out of sync with pyproject.toml), giving a
# reproducible env. It also creates .venv itself, so no separate
# `uv venv` step is needed.
run: uv sync --locked --python 3.11 --extra all --extra dev
- name: Minimize uv cache
# Optimized for CI: prunes pre-built wheels that are cheap to
# re-download, keeping the persisted cache small and fast to restore.
run: uv cache prune --ci
run: |
uv venv .venv --python 3.11
source .venv/bin/activate
uv pip install -e ".[all,dev]"
- name: Packaged-wheel i18n smoke test
run: |
-1
View File
@@ -1,6 +1,5 @@
graft skills
graft optional-skills
graft optional-mcps
graft locales
# Bundled plugin manifests (plugin.yaml / plugin.yml). Without these the
# PluginManager scan (hermes_cli/plugins.py) finds zero plugins on installs
+1 -3
View File
@@ -3,9 +3,7 @@
</p>
# Hermes Agent ☤
<p align="center">
<a href="https://hermes-agent.nousresearch.com/">Hermes Agent</a> | <a href="https://hermes-agent.nousresearch.com/">Hermes Desktop</a>
</p>
<p align="center">
<a href="https://hermes-agent.nousresearch.com/docs/"><img src="https://img.shields.io/badge/Docs-hermes--agent.nousresearch.com-FFD700?style=for-the-badge" alt="Documentation"></a>
<a href="https://discord.gg/NousResearch"><img src="https://img.shields.io/badge/Discord-5865F2?style=for-the-badge&logo=discord&logoColor=white" alt="Discord"></a>
+20 -80
View File
@@ -73,50 +73,20 @@ ADAPTIVE_EFFORT_MAP = {
"minimal": "low",
}
# ── Anthropic thinking-mode classification ────────────────────────────
# Claude 4.6 replaced budget-based extended thinking with *adaptive* thinking,
# and 4.7 additionally forbids the manual ``thinking`` block entirely and drops
# temperature/top_p/top_k. Newer Claude releases (4.8, and named models like
# claude-fable-5) follow the same modern contract — but they share no common
# version substring, so an allowlist of version numbers ("4.6", "4.7", …) goes
# stale the moment a model ships without a recognized number and silently
# routes it down the legacy manual-thinking path.
#
# Instead we DEFAULT unknown Claude models to the modern contract and keep an
# explicit *legacy* list of the older Claude families that still require manual
# thinking. This mirrors _get_anthropic_max_output's "default to newest" design
# (future models are unlikely to regress to the older contract), so each new
# Claude release works without a code change.
#
# Non-Claude Anthropic-Messages models (minimax, qwen3, GLM, …) are NOT Claude,
# so they fall through to the legacy path automatically — exactly what those
# manual-thinking endpoints need.
# Older Claude families that DON'T support adaptive thinking (manual thinking
# with budget_tokens only). Substring-matched against the model name.
_LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
)
# Older Claude families that DON'T accept the "xhigh" effort level (4.6 only
# supports low/medium/high/max). xhigh arrived with Opus 4.7. Adaptive models
# not in this list (4.7, 4.8, fable, future) accept xhigh.
_NO_XHIGH_CLAUDE_SUBSTRINGS = (
"claude-opus-4-6", "claude-opus-4.6",
"claude-sonnet-4-6", "claude-sonnet-4.6",
)
def _is_claude_model(model: str | None) -> bool:
return "claude" in (model or "").lower()
# Models that accept the "xhigh" output_config.effort level. Opus 4.7 added
# xhigh as a distinct level between high and max; older adaptive-thinking
# models (4.6) reject it with a 400. Keep this substring list in sync with
# the Anthropic migration guide as new model families ship.
_XHIGH_EFFORT_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
# Models where extended thinking is deprecated/removed (4.6+ behavior: adaptive
# is the only supported mode; 4.7 additionally forbids manual thinking entirely
# and drops temperature/top_p/top_k).
_ADAPTIVE_THINKING_SUBSTRINGS = ("4-6", "4.6", "4-7", "4.7", "4-8", "4.8")
# Models where temperature/top_p/top_k return 400 if set to non-default values.
# This is the Opus 4.7 contract; future 4.x+ models are expected to follow it.
_NO_SAMPLING_PARAMS_SUBSTRINGS = ("4-7", "4.7", "4-8", "4.8")
_FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# ── Max output token limits per Anthropic model ───────────────────────
@@ -124,8 +94,6 @@ _FAST_MODE_SUPPORTED_SUBSTRINGS = ("opus-4-6", "opus-4.6")
# max_tokens as a mandatory field. Previously we hardcoded 16384, which
# starves thinking-enabled models (thinking tokens count toward the limit).
_ANTHROPIC_OUTPUT_LIMITS = {
# Mythos-class named models (claude-fable-5, …) — 1M context, reasoning
"claude-fable": 128_000,
# Claude 4.8
"claude-opus-4-8": 128_000,
# Claude 4.7
@@ -240,17 +208,8 @@ def _resolve_anthropic_messages_max_tokens(
def _supports_adaptive_thinking(model: str) -> bool:
"""Return True for Claude models that use adaptive thinking (4.6+).
Defaults *unknown* Claude models to adaptive (the modern contract) and
only returns False for the explicit legacy list of older Claude families
that require manual budget-based thinking. Non-Claude Anthropic-Messages
models (minimax, qwen3, …) return False so they keep the manual path.
"""
if not _is_claude_model(model):
return False
m = model.lower()
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
"""Return True for Claude 4.6+ models that support adaptive thinking."""
return any(v in model for v in _ADAPTIVE_THINKING_SUBSTRINGS)
def _supports_xhigh_effort(model: str) -> bool:
@@ -260,33 +219,18 @@ def _supports_xhigh_effort(model: str) -> bool:
Pre-4.7 adaptive models (Opus/Sonnet 4.6) only accept low/medium/high/max
and reject xhigh with an HTTP 400. Callers should downgrade xhigh→max
when this returns False.
Defaults unknown adaptive Claude models to accepting xhigh (4.7+ contract);
only the 4.6 family and legacy manual-thinking models are excluded.
"""
if not _supports_adaptive_thinking(model):
return False
m = model.lower()
return not any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS)
return any(v in model for v in _XHIGH_EFFORT_SUBSTRINGS)
def _forbids_sampling_params(model: str) -> bool:
"""Return True for models that 400 on any non-default temperature/top_p/top_k.
Opus 4.7 introduced this restriction; later Claude releases follow it.
Defaults unknown Claude models to forbidding sampling params (the modern
contract). The 4.6 family still accepts them, and the legacy manual-thinking
families (4.5 and older) accept them too, so both are excluded. Non-Claude
models are unaffected. Callers should omit these fields entirely rather than
passing zero/default values (the API rejects anything non-null).
Opus 4.7 explicitly rejects sampling parameters; later Claude releases are
expected to follow suit. Callers should omit these fields entirely rather
than passing zero/default values (the API rejects anything non-null).
"""
if not _is_claude_model(model):
return False
m = model.lower()
# 4.6 family is adaptive but still accepts sampling params.
if any(v in m for v in _NO_XHIGH_CLAUDE_SUBSTRINGS):
return False
return not any(v in m for v in _LEGACY_MANUAL_THINKING_CLAUDE_SUBSTRINGS)
return any(v in model for v in _NO_SAMPLING_PARAMS_SUBSTRINGS)
def _supports_fast_mode(model: str) -> bool:
@@ -877,7 +821,6 @@ def _read_claude_code_credentials_from_keychain() -> Optional[Dict[str, Any]]:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
except (OSError, subprocess.TimeoutExpired):
logger.debug("Keychain: security command not available or timed out")
@@ -1220,10 +1163,7 @@ def run_oauth_setup_token() -> Optional[str]:
"Install it with: npm install -g @anthropic-ai/claude-code"
)
# Run interactively — stdin/stdout/stderr inherited so the user can
# complete the OAuth login prompt. Must keep inherited stdin; the TUI-EOF
# concern does not apply to an interactive login the user explicitly
# invokes. noqa: subprocess-stdin
# Run interactively — stdin/stdout/stderr inherited so user can interact
try:
subprocess.run([claude_path, "setup-token"])
except (KeyboardInterrupt, EOFError):
+1 -152
View File
@@ -25,154 +25,6 @@ from typing import Any, Dict, List
logger = logging.getLogger(__name__)
def _coerce_usage_int(value: Any) -> int:
if isinstance(value, bool):
return 0
if isinstance(value, int):
return max(value, 0)
if isinstance(value, float):
return max(int(value), 0)
if isinstance(value, str):
try:
return max(int(value), 0)
except ValueError:
return 0
return 0
def _record_codex_app_server_usage(agent, turn) -> dict[str, Any]:
"""Translate Codex app-server token usage into Hermes accounting.
Codex app-server reports usage via thread/tokenUsage/updated as:
inputTokens, cachedInputTokens, outputTokens, reasoningOutputTokens,
totalTokens.
Hermes' canonical prompt bucket includes uncached input + cached input.
The Codex app-server protocol does not currently expose cache-write tokens,
so that bucket remains zero on this runtime.
Even when Codex omits usage for a turn, Hermes should still count that turn
as one API call for session/status accounting.
"""
agent.session_api_calls += 1
usage = getattr(turn, "token_usage_last", None)
if not isinstance(usage, dict) or not usage:
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server api-call persistence failed (session=%s): %s",
agent.session_id, exc,
)
return {}
from agent.usage_pricing import CanonicalUsage, estimate_usage_cost
input_tokens = _coerce_usage_int(usage.get("inputTokens"))
cache_read_tokens = _coerce_usage_int(usage.get("cachedInputTokens"))
output_tokens = _coerce_usage_int(usage.get("outputTokens"))
reasoning_tokens = _coerce_usage_int(usage.get("reasoningOutputTokens"))
reported_total = _coerce_usage_int(usage.get("totalTokens"))
canonical_usage = CanonicalUsage(
input_tokens=input_tokens,
output_tokens=output_tokens,
cache_read_tokens=cache_read_tokens,
cache_write_tokens=0,
reasoning_tokens=reasoning_tokens,
raw_usage=usage,
)
prompt_tokens = canonical_usage.prompt_tokens
completion_tokens = canonical_usage.output_tokens
total_tokens = reported_total or canonical_usage.total_tokens
usage_dict = {
"prompt_tokens": prompt_tokens,
"completion_tokens": completion_tokens,
"total_tokens": total_tokens,
"input_tokens": canonical_usage.input_tokens,
"output_tokens": canonical_usage.output_tokens,
"cache_read_tokens": canonical_usage.cache_read_tokens,
"cache_write_tokens": canonical_usage.cache_write_tokens,
"reasoning_tokens": canonical_usage.reasoning_tokens,
}
compressor = getattr(agent, "context_compressor", None)
if compressor is not None:
try:
compressor.update_from_response(usage_dict)
context_window = getattr(turn, "model_context_window", None)
if isinstance(context_window, int) and context_window > 0:
compressor.context_length = context_window
except Exception:
logger.debug("codex app-server usage update failed", exc_info=True)
agent.session_prompt_tokens += prompt_tokens
agent.session_completion_tokens += completion_tokens
agent.session_total_tokens += total_tokens
agent.session_input_tokens += canonical_usage.input_tokens
agent.session_output_tokens += canonical_usage.output_tokens
agent.session_cache_read_tokens += canonical_usage.cache_read_tokens
agent.session_cache_write_tokens += canonical_usage.cache_write_tokens
agent.session_reasoning_tokens += canonical_usage.reasoning_tokens
cost_result = estimate_usage_cost(
agent.model,
canonical_usage,
provider=agent.provider,
base_url=agent.base_url,
api_key=getattr(agent, "api_key", ""),
)
if cost_result.amount_usd is not None:
agent.session_estimated_cost_usd += float(cost_result.amount_usd)
agent.session_cost_status = cost_result.status
agent.session_cost_source = cost_result.source
if agent._session_db and agent.session_id:
try:
if not agent._session_db_created:
agent._ensure_db_session()
agent._session_db.update_token_counts(
agent.session_id,
input_tokens=canonical_usage.input_tokens,
output_tokens=canonical_usage.output_tokens,
cache_read_tokens=canonical_usage.cache_read_tokens,
cache_write_tokens=canonical_usage.cache_write_tokens,
reasoning_tokens=canonical_usage.reasoning_tokens,
estimated_cost_usd=float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
cost_status=cost_result.status,
cost_source=cost_result.source,
billing_provider=agent.provider,
billing_base_url=agent.base_url,
billing_mode="subscription_included"
if cost_result.status == "included" else None,
model=agent.model,
api_call_count=1,
)
except Exception as exc:
logger.debug(
"Codex app-server token persistence failed (session=%s, tokens=%d): %s",
agent.session_id, total_tokens, exc,
)
return {
**usage_dict,
"last_prompt_tokens": prompt_tokens,
"estimated_cost_usd": float(cost_result.amount_usd)
if cost_result.amount_usd is not None else None,
"cost_status": cost_result.status,
"cost_source": cost_result.source,
}
def run_codex_app_server_turn(
agent,
*,
@@ -268,8 +120,6 @@ def run_codex_app_server_turn(
agent._iters_since_skill = (
getattr(agent, "_iters_since_skill", 0) + turn.tool_iterations
)
usage_result = _record_codex_app_server_usage(agent, turn)
api_calls = 1
# Now check the skill nudge AFTER iters were incremented — same
# pattern the chat_completions path uses (line ~15432).
@@ -314,13 +164,12 @@ def run_codex_app_server_turn(
return {
"final_response": turn.final_text,
"messages": messages,
"api_calls": api_calls,
"api_calls": 1, # one app-server "turn" maps to one logical API call
"completed": not turn.interrupted and turn.error is None,
"partial": turn.interrupted or turn.error is not None,
"error": turn.error,
"codex_thread_id": turn.thread_id,
"codex_turn_id": turn.turn_id,
**usage_result,
}
-2
View File
@@ -290,7 +290,6 @@ def _expand_git_reference(
capture_output=True,
text=True,
timeout=30,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"{ref.raw}: git command timed out (30s)", None
@@ -483,7 +482,6 @@ def _rg_files(path: Path, cwd: Path, limit: int) -> list[Path] | None:
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except (FileNotFoundError, OSError, subprocess.TimeoutExpired):
return None
-3
View File
@@ -262,7 +262,6 @@ def _install_npm(
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -311,7 +310,6 @@ def _install_go(pkg: str, bin_name: str) -> Optional[str]:
text=True,
timeout=600,
env=env,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
@@ -349,7 +347,6 @@ def _install_pip(pkg: str, bin_name: str) -> Optional[str]:
capture_output=True,
text=True,
timeout=300,
stdin=subprocess.DEVNULL,
)
if proc.returncode != 0:
logger.warning(
-50
View File
@@ -141,8 +141,6 @@ DEFAULT_CONTEXT_LENGTHS = {
# fuzzy-match collisions (e.g. "anthropic/claude-sonnet-4" is a
# substring of "anthropic/claude-sonnet-4.6").
# OpenRouter-prefixed models resolve via OpenRouter live API or models.dev.
"claude-fable-5": 1000000,
"claude-fable": 1000000,
"claude-opus-4-8": 1000000,
"claude-opus-4.8": 1000000,
"claude-opus-4-7": 1000000,
@@ -970,16 +968,6 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
# OpenRouter/Nous phrasing of the same condition.
"in the output" in error_lower
and "maximum context length" in error_lower
) or (
# LM Studio / llama.cpp / some OpenAI-compatible servers:
# "This model's maximum context length is 65536 tokens. However, you
# requested 65536 output tokens and your prompt contains 77409
# characters ..."
# The "requested N output tokens" phrasing means the OUTPUT cap is the
# problem (the input itself fits) — reduce max_tokens, don't compress.
"maximum context length" in error_lower
and "requested" in error_lower
and "output tokens" in error_lower
)
if not is_output_cap_error:
return None
@@ -1011,22 +999,6 @@ def parse_available_output_tokens_from_error(error_msg: str) -> Optional[int]:
if _available >= 1:
return _available
# LM Studio / llama.cpp style: context window is reported in tokens but the
# prompt size is reported in CHARACTERS, e.g.
# "maximum context length is 65536 tokens ... your prompt contains 77409
# characters ...".
# Estimate the input tokens conservatively (~3 chars/token, which
# over-reserves the input so the retried output cap stays safely inside the
# window) and leave the remainder of the window for output.
_m_ctx_tok = re.search(r'maximum context length is (\d+)\s*token', error_lower)
_m_chars = re.search(r'prompt contains (\d+)\s*character', error_lower)
if _m_ctx_tok and _m_chars:
_ctx = int(_m_ctx_tok.group(1))
_est_input = (int(_m_chars.group(1)) + 2) // 3
_available = _ctx - _est_input
if _available >= 1:
return _available
return None
@@ -1812,28 +1784,6 @@ def get_model_context_length(
if ctx is not None:
save_context_length(model, base_url, ctx)
return ctx
# 5f. OpenRouter live /models metadata — authoritative for OpenRouter-routed
# models. OpenRouter's catalog carries per-model context_length (e.g.
# anthropic/claude-fable-5 -> 1M) and refreshes as new slugs ship, so it
# must win over both models.dev (step 5g) and the hardcoded family catch-all
# (step 8). Before this branch, an OpenRouter selection set
# effective_provider="openrouter", which (a) made the models.dev lookup miss
# brand-new slugs and (b) skipped the step-6 OR fallback (gated on `not
# effective_provider`), so a fresh slug like claude-fable-5 fell through to
# the generic "claude": 200K entry and under-reported a 1M window. Mirrors
# the dedicated Nous/Copilot/GMI branches above.
if effective_provider == "openrouter":
metadata = fetch_model_metadata()
entry = metadata.get(model)
if entry:
or_ctx = entry.get("context_length")
# Guard against the known OpenRouter Kimi-family 32k underreport
# (same class the hardcoded overrides exist to mitigate).
if isinstance(or_ctx, int) and or_ctx > 0 and not (
or_ctx == 32768 and _model_name_suggests_kimi(model)
):
return or_ctx
if effective_provider:
from agent.models_dev import lookup_models_dev_context
ctx = lookup_models_dev_context(effective_provider, model)
-2
View File
@@ -274,7 +274,6 @@ def _platform_asset_name() -> str:
capture_output=True,
text=True,
timeout=2,
stdin=subprocess.DEVNULL,
)
if "musl" in (res.stdout + res.stderr).lower():
libc = "musl"
@@ -526,7 +525,6 @@ def _run_bws_list(
capture_output=True,
text=True,
timeout=_BWS_RUN_TIMEOUT,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired as exc:
raise RuntimeError(
-1
View File
@@ -74,7 +74,6 @@ def run_inline_shell(command: str, cwd: Path | None, timeout: int) -> str:
text=True,
timeout=max(1, int(timeout)),
check=False,
stdin=subprocess.DEVNULL,
)
except subprocess.TimeoutExpired:
return f"[inline-shell timeout after {timeout}s: {command}]"
-1
View File
@@ -378,7 +378,6 @@ def check_codex_binary(
capture_output=True,
text=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError:
return False, (
@@ -72,9 +72,6 @@ class TurnResult:
error: Optional[str] = None # Set if turn ended in a non-recoverable error
turn_id: Optional[str] = None
thread_id: Optional[str] = None
token_usage_last: Optional[dict[str, Any]] = None
token_usage_total: Optional[dict[str, Any]] = None
model_context_window: Optional[int] = None
# Hint to the caller that the underlying codex subprocess is likely
# wedged (turn-level timeout fired, post-tool watchdog tripped, or
# token-refresh failure killed the child). The caller should retire
@@ -504,7 +501,6 @@ class CodexAppServerSession:
pending = self._client.take_notification(timeout=0)
if pending is None:
break
_apply_token_usage_notification(result, pending)
self._track_pending_file_change(pending)
proj = projector.project(pending)
if proj.messages:
@@ -540,8 +536,6 @@ class CodexAppServerSession:
except Exception: # pragma: no cover - display callback
logger.debug("on_event callback raised", exc_info=True)
_apply_token_usage_notification(result, note)
# Track in-progress fileChange items so the approval bridge
# can surface a real change summary when codex requests
# approval (the approval params themselves don't carry the
@@ -808,30 +802,6 @@ class CodexAppServerSession:
return cached
def _apply_token_usage_notification(result: TurnResult, note: dict) -> None:
"""Capture Codex app-server token usage updates for caller accounting.
Codex does not put token usage on turn/completed. It emits a separate
thread/tokenUsage/updated notification containing cumulative totals and
the latest turn breakdown.
"""
if not isinstance(note, dict) or note.get("method") != "thread/tokenUsage/updated":
return
params = note.get("params") or {}
token_usage = params.get("tokenUsage") or {}
if not isinstance(token_usage, dict):
return
last = token_usage.get("last")
total = token_usage.get("total")
if isinstance(last, dict):
result.token_usage_last = dict(last)
if isinstance(total, dict):
result.token_usage_total = dict(total)
window = token_usage.get("modelContextWindow")
if isinstance(window, int) and window > 0:
result.model_context_window = window
def _approval_choice_to_codex_decision(choice: str) -> str:
"""Map Hermes approval choices onto codex's CommandExecutionApprovalDecision
/ FileChangeApprovalDecision wire values.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 361 KiB

After

Width:  |  Height:  |  Size: 78 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 561 KiB

After

Width:  |  Height:  |  Size: 674 KiB

+4 -75
View File
@@ -4295,31 +4295,20 @@ async function teardownPrimaryBackendAndWait() {
const dying = hermesProcess && !hermesProcess.killed ? hermesProcess : null
resetHermesConnection()
await waitForBackendExit(dying)
}
async function waitForBackendExit(child, timeoutMs = 5000) {
if (!child) {
return
}
if (child.exitCode !== null || child.signalCode !== null) {
if (!dying) {
return
}
await new Promise(resolve => {
const timer = setTimeout(() => {
try {
if (IS_WINDOWS && Number.isInteger(child.pid)) {
forceKillProcessTree(child.pid)
} else {
child.kill('SIGKILL')
}
dying.kill('SIGKILL')
} catch {
// Already gone.
}
resolve()
}, timeoutMs)
child.once('exit', () => {
}, 5000)
dying.once('exit', () => {
clearTimeout(timer)
resolve()
})
@@ -4511,70 +4500,12 @@ function stopPoolBackend(profile) {
}
}
async function teardownPoolBackendAndWait(profile) {
const entry = backendPool.get(profile)
if (!entry) return
backendPool.delete(profile)
if (entry.process && !entry.process.killed) {
try {
entry.process.kill('SIGTERM')
} catch {
// Already gone.
}
}
await waitForBackendExit(entry.process)
}
function stopAllPoolBackends() {
for (const profile of [...backendPool.keys()]) {
stopPoolBackend(profile)
}
}
function profileNameFromDeleteRequest(request) {
if (!request || String(request.method || 'GET').toUpperCase() !== 'DELETE') {
return null
}
const match = String(request.path || '').match(/^\/api\/profiles\/([^/?#]+)(?:[?#].*)?$/)
if (!match) {
return null
}
let raw = ''
try {
raw = decodeURIComponent(match[1])
} catch {
return null
}
const name = raw.trim()
if (!name) {
return null
}
if (name.toLowerCase() === 'default') {
return 'default'
}
return name.toLowerCase()
}
async function prepareProfileDeleteRequest(request) {
const profile = profileNameFromDeleteRequest(request)
if (!profile || profile === 'default' || !PROFILE_NAME_RE.test(profile)) {
return
}
if (profile === primaryProfileKey()) {
writeActiveDesktopProfile('default')
await teardownPrimaryBackendAndWait()
return
}
await teardownPoolBackendAndWait(profile)
}
async function startHermes() {
// Latched-failure short-circuit: once bootstrap has failed in this
// process, every subsequent startHermes() call re-throws the same error
@@ -5193,8 +5124,6 @@ ipcMain.handle('hermes:api', async (_event, request) => {
return rerouted
}
await prepareProfileDeleteRequest(request)
const connection = await ensureBackend(request?.profile)
const timeoutMs = resolveTimeoutMs(request?.timeoutMs, DEFAULT_FETCH_TIMEOUT_MS)
const url = `${connection.baseUrl}${request.path}`
Binary file not shown.

Before

Width:  |  Height:  |  Size: 528 KiB

After

Width:  |  Height:  |  Size: 1.1 MiB

+122 -170
View File
@@ -76,9 +76,6 @@ import {
} from '@/store/profile'
import {
$cronSessions,
$messagingPlatformTotals,
$messagingSessions,
$messagingTruncated,
$selectedStoredSessionId,
$sessionProfileTotals,
$sessions,
@@ -127,6 +124,7 @@ const WORKSPACE_PAGE = 5
// unified list scannable, then reveal/fetch more in N-sized steps on demand.
const PROFILE_INITIAL_PAGE = 5
const GROUP_DND_ID_PREFIX = 'group:'
const LOCAL_SESSION_SOURCES = new Set(['cli', 'desktop', 'local', 'tui'])
const groupDndId = (id: string) => `${GROUP_DND_ID_PREFIX}${id}`
@@ -143,25 +141,24 @@ function orderByIds<T>(items: T[], getId: (item: T) => string, orderIds: string[
const byId = new Map(items.map(item => [getId(item), item]))
const seen = new Set<string>()
const ordered: T[] = []
const out: T[] = []
for (const id of orderIds) {
const item = byId.get(id)
if (item) {
ordered.push(item)
out.push(item)
seen.add(id)
}
}
// Items missing from the persisted order are new since it was last
// reconciled. Callers pass recency-sorted lists (newest first), so surface
// these at the TOP instead of burying them beneath the saved order —
// otherwise a brand-new session sinks to the bottom of the sidebar and reads
// as "my latest session never showed up".
const fresh = items.filter(item => !seen.has(getId(item)))
for (const item of items) {
if (!seen.has(getId(item))) {
out.push(item)
}
}
return fresh.length ? [...fresh, ...ordered] : ordered
return out
}
function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
@@ -174,15 +171,17 @@ function reconcileOrderIds(currentIds: string[], orderIds: string[]): string[] {
}
const current = new Set(currentIds)
const retained = orderIds.filter(id => current.has(id))
const retainedSet = new Set(retained)
const next = orderIds.filter(id => current.has(id))
const known = new Set(next)
// New ids (absent from the saved order) are the newest sessions/groups; keep
// them ahead of the persisted order so fresh activity surfaces at the top of
// the sidebar rather than being appended to the bottom.
const fresh = currentIds.filter(id => !retainedSet.has(id))
for (const id of currentIds) {
if (!known.has(id)) {
next.push(id)
known.add(id)
}
}
return [...fresh, ...retained]
return next
}
function sameIds(left: string[], right: string[]) {
@@ -252,6 +251,43 @@ function workspaceGroupsFor(
return [...groups.values()]
}
function sourceSessionGroupsFor(sessions: SessionInfo[]): {
localSessions: SessionInfo[]
sourceGroups: SidebarSessionGroup[]
} {
const groups = new Map<string, SidebarSessionGroup>()
const localSessions: SessionInfo[] = []
for (const session of sessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId || LOCAL_SESSION_SOURCES.has(sourceId)) {
localSessions.push(session)
continue
}
const label = sessionSourceLabel(sourceId) ?? sourceId
const group = groups.get(sourceId) ?? {
id: `source:${sourceId}`,
label,
mode: 'source',
path: null,
sessions: [],
sourceId
}
group.sessions.push(session)
groups.set(sourceId, group)
}
return {
localSessions,
sourceGroups: [...groups.values()].sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}
}
function useSortableBindings(id: string) {
const { attributes, isDragging, listeners, setNodeRef, transform, transition } = useSortable({ id })
@@ -273,7 +309,6 @@ interface ChatSidebarProps extends React.ComponentProps<typeof Sidebar> {
onNavigate: (item: SidebarNavItem) => void
onLoadMoreSessions: () => void
onLoadMoreProfileSessions?: (profile: string) => Promise<void> | void
onLoadMoreMessaging?: (platform: string) => Promise<void> | void
onResumeSession: (sessionId: string) => void
onDeleteSession: (sessionId: string) => void
onArchiveSession: (sessionId: string) => void
@@ -287,7 +322,6 @@ export function ChatSidebar({
onNavigate,
onLoadMoreSessions,
onLoadMoreProfileSessions,
onLoadMoreMessaging,
onResumeSession,
onDeleteSession,
onArchiveSession,
@@ -311,9 +345,6 @@ export function ChatSidebar({
const sessions = useStore($sessions)
const cronSessions = useStore($cronSessions)
const cronJobs = useStore($cronJobs)
const messagingSessions = useStore($messagingSessions)
const messagingPlatformTotals = useStore($messagingPlatformTotals)
const messagingTruncated = useStore($messagingTruncated)
const sessionsLoading = useStore($sessionsLoading)
const sessionsTotal = useStore($sessionsTotal)
const sessionProfileTotals = useStore($sessionProfileTotals)
@@ -333,8 +364,6 @@ export function ChatSidebar({
const [serverMatches, setServerMatches] = useState<SessionSearchResult[]>([])
const [newSessionKbdFlash, setNewSessionKbdFlash] = useState(false)
const [profileLoadMorePending, setProfileLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingLoadMorePending, setMessagingLoadMorePending] = useState<Record<string, boolean>>({})
const [messagingOpen, setMessagingOpen] = useState<Record<string, boolean>>({})
const searchInputRef = useRef<HTMLInputElement>(null)
const trimmedQuery = searchQuery.trim()
@@ -500,12 +529,24 @@ export function ChatSidebar({
[unpinnedAgentSessions, agentOrderIds]
)
// Recents are local-only: messaging-platform sessions are fetched as their
// own slice ($messagingSessions) and rendered in self-managed per-platform
// sections below, so there is no source-grouping magic to untangle here.
const { localSessions: localAgentSessions, sourceGroups } = useMemo(
() => sourceSessionGroupsFor(agentSessions),
[agentSessions]
)
const orderedSourceGroups = useMemo(
() => orderByIds(sourceGroups, g => g.id, workspaceOrderIds),
[sourceGroups, workspaceOrderIds]
)
const agentGroups = useMemo(
() => orderByIds(workspaceGroupsFor(agentSessions, s.noWorkspace), g => g.id, workspaceOrderIds),
[agentSessions, s.noWorkspace, workspaceOrderIds]
() =>
orderByIds(
workspaceGroupsFor(localAgentSessions, s.noWorkspace, { preserveSessionOrder: sourceGroups.length > 0 }),
g => g.id,
workspaceOrderIds
),
[localAgentSessions, s.noWorkspace, sourceGroups.length, workspaceOrderIds]
)
const loadMoreForProfileGroup = useCallback(
@@ -523,64 +564,6 @@ export function ChatSidebar({
[onLoadMoreProfileSessions]
)
const loadMoreForMessaging = useCallback(
(platform: string) => {
if (!onLoadMoreMessaging) {
return
}
setMessagingLoadMorePending(prev => ({ ...prev, [platform]: true }))
void Promise.resolve(onLoadMoreMessaging(platform))
.catch(() => undefined)
.finally(() => setMessagingLoadMorePending(({ [platform]: _done, ...rest }) => rest))
},
[onLoadMoreMessaging]
)
// Each messaging platform is its own self-managed section: split the
// separately-fetched messaging slice by source, newest platform first, rows
// within a platform by recency. Per-platform totals (when a "load more" has
// resolved them) drive the count + whether more remain on disk.
const messagingGroups = useMemo<MessagingSection[]>(() => {
if (!messagingSessions.length) {
return []
}
const bySource = new Map<string, SessionInfo[]>()
for (const session of messagingSessions) {
const sourceId = normalizeSessionSource(session.source)
if (!sourceId) {
continue
}
const list = bySource.get(sourceId) ?? []
list.push(session)
bySource.set(sourceId, list)
}
return [...bySource.entries()]
.map(([sourceId, list]) => {
const ordered = [...list].sort((a, b) => sessionTime(b) - sessionTime(a))
const known = messagingPlatformTotals[sourceId]
const total = Math.max(ordered.length, known ?? 0)
return {
// Known exact total → more exist iff total exceeds loaded; otherwise
// the seed fetch was capped, so assume more until a per-platform load
// resolves the count.
hasMore: known != null ? known > ordered.length : messagingTruncated,
label: sessionSourceLabel(sourceId) ?? sourceId,
sessions: ordered,
sourceId,
total
}
})
.sort((a, b) => sessionTime(b.sessions[0]) - sessionTime(a.sessions[0]))
}, [messagingSessions, messagingPlatformTotals, messagingTruncated])
// ALL-profiles view: one collapsible group per profile, color on the header
// (not on every row). Default profile floats to the top, the rest alpha.
const profileGroups = useMemo<SidebarSessionGroup[] | undefined>(() => {
@@ -627,28 +610,37 @@ export function ChatSidebar({
sessionProfileTotals
])
const displayAgentSessions = agentSessions
const displayAgentSessions = sourceGroups.length ? localAgentSessions : agentSessions
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const displayAgentGroups = useMemo(() => {
if (orderedSourceGroups.length) {
const localGroups = agentsGrouped
? agentGroups
: localAgentSessions.length
? [
{
id: 'local-sessions',
label: 'Local',
mode: 'workspace' as const,
path: null,
sessions: localAgentSessions
}
]
: []
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
return orderByIds([...orderedSourceGroups, ...localGroups], g => g.id, workspaceOrderIds)
}
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const displayAgentGroups = showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
return showAllProfiles ? profileGroups : agentsGrouped ? agentGroups : undefined
}, [
agentGroups,
agentsGrouped,
localAgentSessions,
orderedSourceGroups,
profileGroups,
showAllProfiles,
workspaceOrderIds
])
useEffect(() => {
if (!displayAgentGroups?.length || showAllProfiles) {
@@ -669,6 +661,25 @@ export function ChatSidebar({
const showSessionSections = showSessionSkeletons || sortedSessions.length > 0
// Pagination is scope-aware. In "All profiles" mode it tracks the global
// unified set. When scoped to one profile it must compare that profile's own
// loaded rows against that profile's total — otherwise a huge default profile
// keeps "Load more" stuck on while you browse a small one (the aggregator's
// total sums every profile). Per-profile totals come from the aggregator
// (children excluded); fall back to the global total / loaded count.
const loadedSessionCount = showAllProfiles ? sessions.length : visibleSessions.length
const scopedProfileTotal = showAllProfiles ? undefined : sessionProfileTotals[profileScope]
const knownSessionTotal = Math.max(
showAllProfiles ? sessionsTotal : (scopedProfileTotal ?? loadedSessionCount),
loadedSessionCount
)
const hasMoreSessions = knownSessionTotal > loadedSessionCount
const remainingSessionCount = Math.max(0, knownSessionTotal - loadedSessionCount)
const recentsMeta = countLabel(agentSessions.length, knownSessionTotal)
const handlePinnedDragEnd = ({ active, over }: DragEndEvent) => {
if (!over || active.id === over.id) {
return
@@ -891,7 +902,7 @@ export function ChatSidebar({
// the toggle does nothing, and it's irrelevant in the ALL-profiles
// view (always grouped by profile), so hide the button (not the slot).
<div className="grid size-6 shrink-0 place-items-center">
{!showAllProfiles && agentSessions.length > 0 ? (
{!showAllProfiles && localAgentSessions.length > 0 ? (
<Tip label={agentsGrouped ? s.groupTitleGrouped : s.groupTitleUngrouped}>
<Button
aria-label={agentsGrouped ? s.groupAriaGrouped : s.groupAriaUngrouped}
@@ -931,46 +942,6 @@ export function ChatSidebar({
/>
)}
{contentVisible && showSessionSections && !trimmedQuery &&
messagingGroups.map(group => (
<SidebarSessionsSection
activeSessionId={activeSidebarSessionId}
contentClassName="flex max-h-56 shrink-0 flex-col gap-px overflow-y-auto overscroll-contain pb-1.75"
emptyState={null}
footer={
group.hasMore ? (
<SidebarLoadMoreRow
loading={Boolean(messagingLoadMorePending[group.sourceId])}
onClick={() => loadMoreForMessaging(group.sourceId)}
step={Math.max(0, group.total - group.sessions.length)}
/>
) : null
}
key={group.sourceId}
label={group.label}
labelIcon={
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5625rem] [&_svg]:size-3"
platformId={group.sourceId}
platformName={group.label}
/>
}
labelMeta={countLabel(group.sessions.length, group.total)}
onArchiveSession={onArchiveSession}
onDeleteSession={onDeleteSession}
onResumeSession={onResumeSession}
onToggle={() =>
setMessagingOpen(prev => ({ ...prev, [group.sourceId]: prev[group.sourceId] === false }))
}
onTogglePin={pinSession}
open={messagingOpen[group.sourceId] !== false}
pinned={false}
rootClassName="shrink-0 p-0"
sessions={group.sessions}
workingSessionIdSet={workingSessionIdSet}
/>
))}
{contentVisible && !trimmedQuery && cronJobs.length > 0 && (
<SidebarCronJobsSection
jobs={cronJobs}
@@ -1001,10 +972,9 @@ interface SidebarSectionHeaderProps {
onToggle: () => void
action?: React.ReactNode
meta?: React.ReactNode
icon?: React.ReactNode
}
function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: SidebarSectionHeaderProps) {
function SidebarSectionHeader({ label, open, onToggle, action, meta }: SidebarSectionHeaderProps) {
return (
<div className="group/section flex shrink-0 items-center justify-between pb-1 pt-1.5">
<button
@@ -1012,7 +982,6 @@ function SidebarSectionHeader({ label, open, onToggle, action, meta, icon }: Sid
onClick={onToggle}
type="button"
>
{icon}
<SidebarPanelLabel>{label}</SidebarPanelLabel>
{meta && <SidebarCount>{meta}</SidebarCount>}
<DisclosureCaret
@@ -1075,14 +1044,6 @@ interface SidebarSessionGroup {
totalCount?: number
}
interface MessagingSection {
sourceId: string
label: string
sessions: SessionInfo[]
total: number
hasMore: boolean
}
interface SidebarSessionsSectionProps {
label: string
open: boolean
@@ -1104,7 +1065,6 @@ interface SidebarSessionsSectionProps {
footer?: React.ReactNode
groups?: SidebarSessionGroup[]
labelMeta?: React.ReactNode
labelIcon?: React.ReactNode
sortable?: boolean
onReorder?: (event: DragEndEvent) => void
dndSensors?: ReturnType<typeof useSensors>
@@ -1131,7 +1091,6 @@ function SidebarSessionsSection({
footer,
groups,
labelMeta,
labelIcon,
sortable = false,
onReorder,
dndSensors
@@ -1250,14 +1209,7 @@ function SidebarSessionsSection({
return (
<SidebarGroup className={rootClassName}>
<SidebarSectionHeader
action={headerAction}
icon={labelIcon}
label={label}
meta={labelMeta}
onToggle={onToggle}
open={open}
/>
<SidebarSectionHeader action={headerAction} label={label} meta={labelMeta} onToggle={onToggle} open={open} />
{open && (
<SidebarGroupContent className={resolvedContentClassName}>
{body}
@@ -2,15 +2,12 @@ import { useStore } from '@nanostores/react'
import type * as React from 'react'
import { writeSessionDrag } from '@/app/chat/composer/inline-refs'
import { PlatformAvatar } from '@/app/messaging/platform-icon'
import { Button } from '@/components/ui/button'
import { Codicon } from '@/components/ui/codicon'
import { Tip } from '@/components/ui/tooltip'
import type { SessionInfo } from '@/hermes'
import { type Translations, useI18n } from '@/i18n'
import { sessionTitle } from '@/lib/chat-runtime'
import { triggerHaptic } from '@/lib/haptics'
import { handoffOriginSource, sessionSourceLabel } from '@/lib/session-source'
import { cn } from '@/lib/utils'
import { $attentionSessionIds } from '@/store/session'
@@ -70,11 +67,6 @@ export function SidebarSessionRow({
const title = sessionTitle(session)
const age = formatAge(session.last_active || session.started_at, r)
const handleLabel = `Reorder ${title}`
// A handed-off session's live source is local, but it originated on a
// messaging platform — surface that origin as a small badge so e.g. a
// Telegram thread continued here still reads as Telegram.
const handoffSource = handoffOriginSource(session.handoff_state, session.handoff_platform)
const handoffLabel = handoffSource ? sessionSourceLabel(handoffSource) ?? handoffSource : null
// Subscribe per-row (the leaf) instead of drilling a set through the list —
// the atom is tiny and rarely non-empty. True when a clarify prompt in this
// session is waiting on the user.
@@ -187,15 +179,6 @@ export function SidebarSessionRow({
<SidebarRowDot isWorking={isWorking} needsInput={needsInput} />
</span>
)}
{handoffSource && handoffLabel ? (
<Tip label={r.handoffOrigin(handoffLabel)}>
<PlatformAvatar
className="size-4 rounded-[4px] text-[0.5rem] [&_svg]:size-2.5"
platformId={handoffSource}
platformName={handoffLabel}
/>
</Tip>
) : null}
<span className="min-w-0 flex-1 truncate text-[0.8125rem] font-normal text-(--ui-text-secondary) group-hover:text-foreground group-data-[working=true]:text-foreground/90">
{title}
</span>
+3 -70
View File
@@ -14,12 +14,6 @@ import { useSkinCommand } from '@/themes/use-skin-command'
import { formatRefValue } from '../components/assistant-ui/directive-text'
import { getCronJobs, getSessionMessages, listAllProfileSessions, type SessionInfo, triggerCronJob } from '../hermes'
import { preserveLocalAssistantErrors, toChatMessages } from '../lib/chat-messages'
import {
isMessagingSource,
LOCAL_SESSION_SOURCE_IDS,
MESSAGING_SESSION_SOURCE_IDS,
normalizeSessionSource
} from '../lib/session-source'
import { setCronFocusJobId, setCronJobs } from '../store/cron'
import {
$panesFlipped,
@@ -52,12 +46,10 @@ import {
$gatewayState,
$selectedStoredSessionId,
$sessions,
$messagingSessions,
$workingSessionIds,
CRON_SECTION_LIMIT,
getRecentlySettledSessionIds,
mergeSessionPage,
MESSAGING_SECTION_LIMIT,
sessionPinId,
setAwaitingResponse,
setBusy,
@@ -67,9 +59,6 @@ import {
setCurrentModel,
setCurrentProvider,
setMessages,
setMessagingPlatformTotals,
setMessagingSessions,
setMessagingTruncated,
setSessionProfileTotals,
setSessions,
setSessionsLoading,
@@ -132,15 +121,6 @@ const SkillsView = lazy(async () => ({ default: (await import('./skills')).Skill
// this cadence while the app is open + visible so new runs surface promptly
// instead of waiting for the next user-triggered refreshSessions().
const CRON_POLL_INTERVAL_MS = 30_000
// The recents list is local-only: cron rows have their own section, and each
// messaging platform (telegram, discord, …) is fetched separately into its own
// self-managed sidebar section (refreshMessagingSessions). Excluding both here
// keeps "Load more" paging through interactive local chats instead of
// interleaving gateway threads that bury them.
const SIDEBAR_EXCLUDED_SOURCES = ['cron', ...MESSAGING_SESSION_SOURCE_IDS]
// The messaging slice is the inverse: drop cron + every local source so only
// external-platform conversations remain, then split per platform in the UI.
const MESSAGING_EXCLUDED_SOURCES = ['cron', ...LOCAL_SESSION_SOURCE_IDS]
// Cheap signature compare so the poll only swaps the atom (and re-renders the
// sidebar) when the visible cron rows actually changed.
@@ -300,51 +280,6 @@ export function DesktopController() {
}
}, [])
// Messaging-platform sessions as their own slice, fetched separately from
// local recents so each platform renders a self-managed section and never
// competes with local chats for the recents page budget. One combined fetch
// seeds every platform; the sidebar splits the rows per source.
const refreshMessagingSessions = useCallback(async () => {
try {
const result = await listAllProfileSessions(MESSAGING_SECTION_LIMIT, 1, 'exclude', 'recent', 'all', {
excludeSources: MESSAGING_EXCLUDED_SOURCES
})
// Drop any non-messaging source the broad exclude didn't catch (custom
// sources) — those stay in local recents, not a platform section.
const rows = result.sessions.filter(s => isMessagingSource(s.source))
setMessagingSessions(prev => (sameCronSignature(prev, rows) ? prev : rows))
// Hit the cap → at least one platform may have more on disk than loaded,
// so platform sections offer their own per-platform "load more".
setMessagingTruncated(result.sessions.length >= MESSAGING_SECTION_LIMIT)
} catch {
// Non-fatal: the messaging sections just stay empty/stale.
}
}, [])
// Page a single platform's section independently (mirrors the per-profile
// pager): fetch that source's next window and merge it back in place, leaving
// every other platform's rows untouched. Resolves the platform's exact total.
const loadMoreMessagingForPlatform = useCallback(async (platform: string) => {
const inPlatform = (s: SessionInfo) => normalizeSessionSource(s.source) === platform
const loaded = $messagingSessions.get().filter(inPlatform).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', 'all', {
source: platform
})
const incoming = result.sessions.filter(s => normalizeSessionSource(s.source) === platform)
setMessagingSessions(prev => [
...prev.filter(s => !inPlatform(s)),
...mergeSessionPage(prev.filter(inPlatform), incoming, sessionsToKeep())
])
const total = result.total ?? incoming.length
setMessagingPlatformTotals(prev => ({ ...prev, [platform]: Math.max(total, incoming.length) }))
}, [])
// Cron *jobs* drive the sidebar "Cron jobs" section. Jobs are created
// synchronously (agent tool call or the cron UI), so refreshing here right
// after an agent turn surfaces a new job immediately; the interval poll keeps
@@ -381,7 +316,7 @@ export function DesktopController() {
const sessionProfile = profileScope === ALL_PROFILES ? 'all' : profileScope
const result = await listAllProfileSessions(limit, 1, 'exclude', 'recent', sessionProfile, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
excludeSources: ['cron']
})
if (refreshSessionsRequestRef.current === requestId) {
@@ -397,8 +332,7 @@ export function DesktopController() {
void refreshCronSessions()
void refreshCronJobs()
void refreshMessagingSessions()
}, [profileScope, refreshCronSessions, refreshCronJobs, refreshMessagingSessions])
}, [profileScope, refreshCronSessions, refreshCronJobs])
const loadMoreSessions = useCallback(() => {
bumpSessionsLimit()
@@ -413,7 +347,7 @@ export function DesktopController() {
const loaded = $sessions.get().filter(inKey).length
const result = await listAllProfileSessions(loaded + SIDEBAR_SESSIONS_PAGE_SIZE, 1, 'exclude', 'recent', key, {
excludeSources: SIDEBAR_EXCLUDED_SOURCES
excludeSources: ['cron']
})
const keep = sessionsToKeep(key)
@@ -770,7 +704,6 @@ export function DesktopController() {
currentView={currentView}
onArchiveSession={sessionId => void archiveSession(sessionId)}
onDeleteSession={sessionId => void removeSession(sessionId)}
onLoadMoreMessaging={loadMoreMessagingForPlatform}
onLoadMoreProfileSessions={loadMoreSessionsForProfile}
onLoadMoreSessions={loadMoreSessions}
onManageCronJob={jobId => {
@@ -18,7 +18,6 @@ import {
toggleSidebarOpen
} from '@/store/layout'
import {
$newChatProfile,
cycleProfile,
requestProfileCreate,
switchProfileToSlot,
@@ -107,10 +106,6 @@ export function useKeybinds(deps: KeybindRuntimeDeps): void {
'nav.agents': () => navigate(AGENTS_ROUTE),
'session.new': () => {
// Match the sidebar New Session button. A plain keyboard new chat should
// target the current live profile, not a stale per-profile quick-create
// selection from a prior action.
$newChatProfile.set(null)
deps.startFreshSession()
window.dispatchEvent(new CustomEvent('hermes:new-session-shortcut'))
},
@@ -4,8 +4,6 @@ import { useEffect } from 'react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { $sessions, setSessions } from '@/store/session'
import { $connection } from '@/store/session'
import type { ComposerAttachment } from '@/store/composer'
import type { SessionInfo } from '@/types/hermes'
import { usePromptActions } from './use-prompt-actions'
@@ -44,10 +42,7 @@ function sessionInfo(overrides: Partial<SessionInfo> = {}): SessionInfo {
interface HarnessHandle {
steerPrompt: (text: string) => Promise<boolean>
submitText: (
text: string,
options?: { attachments?: ComposerAttachment[]; fromQueue?: boolean }
) => Promise<boolean>
submitText: (text: string, options?: { attachments?: never[]; fromQueue?: boolean }) => Promise<boolean>
}
function Harness({
@@ -55,20 +50,16 @@ function Harness({
onReady,
onSeedState,
refreshSessions,
requestGateway,
storedSessionId
requestGateway
}: {
busyRef?: MutableRefObject<boolean>
onReady: (handle: HarnessHandle) => void
onSeedState?: (state: Record<string, unknown>) => void
refreshSessions: () => Promise<void>
requestGateway: <T>(method: string, params?: Record<string, unknown>) => Promise<T>
storedSessionId?: null | string
}) {
const activeSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const selectedStoredSessionIdRef: MutableRefObject<string | null> = {
current: storedSessionId === undefined ? RUNTIME_SESSION_ID : storedSessionId
}
const selectedStoredSessionIdRef: MutableRefObject<string | null> = { current: RUNTIME_SESSION_ID }
const localBusyRef = busyRef ?? { current: false }
const actions = usePromptActions({
@@ -323,198 +314,3 @@ describe('usePromptActions steerPrompt', () => {
expect(requestGateway).not.toHaveBeenCalled()
})
})
describe('usePromptActions file attachment sync', () => {
afterEach(() => {
cleanup()
$connection.set(null)
vi.restoreAllMocks()
})
function fileAttachment(): ComposerAttachment {
return {
id: 'file:report.txt',
kind: 'file',
label: 'report.txt',
path: '/Users/alice/Downloads/report.txt',
refText: '@file:`/Users/alice/Downloads/report.txt`'
}
}
it('uploads file bytes via file.attach on a remote gateway and submits the rewritten ref', async () => {
// Remote gateway can't read the client-disk path, so the desktop must upload
// the bytes and submit the workspace-relative ref the gateway hands back —
// not the original /Users/... path (which would dead-end as "outside the
// allowed workspace").
$connection.set({ mode: 'remote' } as never)
Object.defineProperty(window, 'hermesDesktop', {
configurable: true,
value: { readFileDataUrl: vi.fn(async () => 'data:text/plain;base64,aGVsbG8=') }
})
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return {
attached: true,
path: '/remote/work/.hermes/desktop-attachments/report.txt',
ref_text: '@file:.hermes/desktop-attachments/report.txt',
uploaded: true
} as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('convert this to epub', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls.map(c => c.method)).toEqual(['file.attach', 'prompt.submit'])
expect(calls[0]?.params).toMatchObject({
session_id: RUNTIME_SESSION_ID,
path: '/Users/alice/Downloads/report.txt',
name: 'report.txt',
data_url: 'data:text/plain;base64,aGVsbG8='
})
expect(calls[1]?.params).toEqual({
session_id: RUNTIME_SESSION_ID,
text: '@file:.hermes/desktop-attachments/report.txt\n\nconvert this to epub'
})
})
it('passes the path directly via file.attach in local mode (no byte upload)', async () => {
$connection.set({ mode: 'local' } as never)
const calls: { method: string; params?: Record<string, unknown> }[] = []
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'file.attach') {
return { attached: true, ref_text: '@file:data/report.txt', uploaded: false } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(<Harness onReady={h => (handle = h)} refreshSessions={async () => undefined} requestGateway={requestGateway} />)
const ok = await handle!.submitText('summarize', { attachments: [fileAttachment()] })
expect(ok).toBe(true)
expect(calls[0]?.method).toBe('file.attach')
// Local mode sends no data_url — the gateway shares this disk.
expect(calls[0]?.params).not.toHaveProperty('data_url')
expect(calls[1]).toEqual({
method: 'prompt.submit',
params: { session_id: RUNTIME_SESSION_ID, text: '@file:data/report.txt\n\nsummarize' }
})
})
})
describe('usePromptActions sleep/wake session recovery', () => {
const STORED_SESSION_ID = 'stored-db-xyz789'
const RECOVERED_SESSION_ID = 'rt-recovered-456'
afterEach(() => {
cleanup()
vi.restoreAllMocks()
})
it('resumes the stored session and retries once when prompt.submit reports "session not found"', async () => {
// After sleep/wake the gateway's in-memory session table is cleared, so the
// first prompt.submit with the stale runtime id fails. The hook resumes the
// durable stored id (which survives gateway restarts), gets a fresh live id,
// and retries the send transparently.
const calls: { method: string; params?: Record<string, unknown> }[] = []
let submitAttempts = 0
const requestGateway = vi.fn(async (method: string, params?: Record<string, unknown>) => {
calls.push({ method, params })
if (method === 'prompt.submit') {
submitAttempts += 1
if (submitAttempts === 1) {
throw new Error('session not found')
}
return {} as never
}
if (method === 'session.resume') {
return { session_id: RECOVERED_SESSION_ID } as never
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
const ok = await handle!.submitText('message after wake')
expect(ok).toBe(true)
// First submit (stale id) → session.resume (stored id) → retry submit (fresh id).
expect(calls.map(c => c.method)).toEqual(['prompt.submit', 'session.resume', 'prompt.submit'])
expect(calls[1]?.params).toEqual({ session_id: STORED_SESSION_ID })
expect(calls[2]?.params).toEqual({ session_id: RECOVERED_SESSION_ID, text: 'message after wake' })
})
it('surfaces the original error (no resume) when the failure is not "session not found"', async () => {
const calls: string[] = []
const states: Record<string, unknown>[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session busy')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
onSeedState={s => states.push(s)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={STORED_SESSION_ID}
/>
)
// submitText swallows the error into an inline bubble and returns false.
expect(await handle!.submitText('message')).toBe(false)
// No resume attempt for a non-recoverable error.
expect(calls).not.toContain('session.resume')
})
it('surfaces "session not found" (no resume) when there is no stored session id', async () => {
const calls: string[] = []
const requestGateway = vi.fn(async (method: string) => {
calls.push(method)
if (method === 'prompt.submit') {
throw new Error('session not found')
}
return {} as never
})
let handle: HarnessHandle | null = null
render(
<Harness
onReady={h => (handle = h)}
refreshSessions={async () => undefined}
requestGateway={requestGateway}
storedSessionId={null}
/>
)
// With a null stored ref, the `&& selectedStoredSessionIdRef.current` guard
// short-circuits — no resume is attempted and the error surfaces normally.
expect(await handle!.submitText('message')).toBe(false)
expect(calls).not.toContain('session.resume')
})
})
@@ -47,7 +47,6 @@ import {
import type {
ClientSessionState,
FileAttachResponse,
ImageAttachResponse,
SessionSteerResponse,
SessionTitleResponse,
@@ -104,20 +103,6 @@ async function readImageForRemoteAttach(
return contentBase64 ? { contentBase64, filename: imageFilenameFromPath(filePath) } : null
}
// Read a non-image file as a data URL for upload via file.attach. Returns null
// when the desktop bridge can't read the file (e.g. it was moved/deleted).
async function readFileDataUrlForAttach(filePath: string): Promise<string | null> {
const reader = window.hermesDesktop?.readFileDataUrl
if (!reader) {
return null
}
const dataUrl = await reader(filePath)
return dataUrl || null
}
interface PromptActionsOptions {
activeSessionId: string | null
activeSessionIdRef: MutableRefObject<string | null>
@@ -227,114 +212,62 @@ export function usePromptActions({
[selectedStoredSessionIdRef, updateSessionState]
)
const syncAttachmentsForSubmit = useCallback(
const syncImageAttachmentsForSubmit = useCallback(
async (
sessionId: string,
attachments: ComposerAttachment[],
options: { updateComposerAttachments?: boolean } = {}
): Promise<ComposerAttachment[]> => {
) => {
const updateComposerAttachments = options.updateComposerAttachments ?? true
const images = attachments.filter(attachment => attachment.kind === 'image' && attachment.path)
const remote = $connection.get()?.mode === 'remote'
const synced: ComposerAttachment[] = []
for (const attachment of attachments) {
// Already-synced or pathless refs (terminal, url, etc.) pass through.
if (!attachment.path || attachment.attachedSessionId === sessionId) {
synced.push(attachment)
for (const attachment of images) {
if (attachment.attachedSessionId === sessionId) {
continue
}
if (attachment.kind === 'image') {
let result: ImageAttachResponse
let result: ImageAttachResponse
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = await readImageForRemoteAttach(attachment.path)
if (remote) {
// The gateway is on another machine — it can't read attachment.path
// (a path on THIS disk). Upload the bytes via image.attach_bytes.
const payload = attachment.path ? await readImageForRemoteAttach(attachment.path) : null
if (!payload) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
if (!payload) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(`Could not read ${label}`)
}
if (!result.attached) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
result = await requestGateway<ImageAttachResponse>('image.attach_bytes', {
session_id: sessionId,
content_base64: payload.contentBase64,
filename: payload.filename
})
} else {
result = await requestGateway<ImageAttachResponse>('image.attach', {
session_id: sessionId,
path: attachment.path
})
}
const attachedPath = result.path || attachment.path
const nextAttachment: ComposerAttachment = {
if (!result.attached) {
const label = attachment.label || (attachment.path ? pathLabel(attachment.path) : 'image')
throw new Error(result.message || `Could not attach ${label}`)
}
const attachedPath = result.path || attachment.path
if (updateComposerAttachments) {
addComposerAttachment({
...attachment,
id: attachment.id,
label: attachedPath ? pathLabel(attachedPath) : attachment.label,
path: attachedPath,
attachedSessionId: sessionId
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
if (attachment.kind === 'file') {
// Non-image file refs are @file: paths the gateway reads with its file
// tools. On a remote gateway the desktop path doesn't exist there, so
// upload the bytes; the gateway stages them into the session workspace
// and hands back a workspace-relative ref that actually resolves.
// Local mode can pass the path directly (gateway shares this disk).
const dataUrl = remote ? await readFileDataUrlForAttach(attachment.path) : null
if (remote && !dataUrl) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(`Could not read ${label}`)
}
const result = await requestGateway<FileAttachResponse>('file.attach', {
session_id: sessionId,
path: attachment.path,
name: attachment.label || pathLabel(attachment.path),
...(dataUrl ? { data_url: dataUrl } : {})
})
if (!result.attached || !result.ref_text) {
const label = attachment.label || pathLabel(attachment.path)
throw new Error(result.message || `Could not attach ${label}`)
}
const nextAttachment: ComposerAttachment = {
...attachment,
id: attachment.id,
refText: result.ref_text,
attachedSessionId: sessionId
}
if (updateComposerAttachments) {
addComposerAttachment(nextAttachment)
}
synced.push(nextAttachment)
continue
}
synced.push(attachment)
}
return synced
},
[requestGateway]
)
@@ -345,42 +278,35 @@ export function usePromptActions({
const usingComposerAttachments = !options?.attachments
const attachments = options?.attachments ?? $composerAttachments.get()
const contextRefs = attachments
.map(a => a.refText)
.filter(Boolean)
.join('\n')
const terminalContextBlocks = terminalContextBlocksFromDraft(rawText).join('\n\n')
const hasImage = attachments.some(a => a.kind === 'image')
const attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
// Refs are recomputed after sync (file.attach rewrites @file: refs to
// workspace-relative paths the remote gateway can resolve). Seed the
// optimistic message with the pre-sync refs, then rewrite once synced.
let attachmentRefs = attachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
const buildContextText = (atts: ComposerAttachment[]): string => {
const contextRefs = atts
.map(a => a.refText)
.filter(Boolean)
.join('\n')
return (
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(atts.some(a => a.kind === 'image') ? 'What do you see in this image?' : '')
)
}
const text =
[contextRefs, terminalContextBlocks, visibleText].filter(Boolean).join('\n\n') ||
(hasImage ? 'What do you see in this image?' : '')
// Queue drains fire on the busy→false settle edge, where busyRef (synced
// from $busy by a separate effect) may still read true — honoring it would
// bounce the drained send. The drain lock serializes them; the user path
// keeps the guard so a stray Enter mid-turn can't double-submit.
const hasSendable = Boolean(visibleText || terminalContextBlocks || attachments.length || hasImage)
if (!hasSendable || (!options?.fromQueue && busyRef.current)) {
if (!text || (!options?.fromQueue && busyRef.current)) {
return false
}
const optimisticId = `user-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`
const buildUserMessage = (): ChatMessage => ({
const userMessage: ChatMessage = {
id: optimisticId,
role: 'user',
parts: [textPart(visibleText || (attachmentRefs.length ? '' : attachments.map(a => a.label).join(', ')))],
attachmentRefs
})
}
const releaseBusy = () => {
setMutableRef(busyRef, false)
@@ -397,7 +323,7 @@ export function usePromptActions({
...state,
messages: state.messages.some(m => m.id === optimisticId)
? state.messages
: [...state.messages, buildUserMessage()],
: [...state.messages, userMessage],
busy: true,
awaitingResponse: true,
pendingBranchGroup: null,
@@ -410,18 +336,6 @@ export function usePromptActions({
selectedStoredSessionIdRef.current
)
// After sync rewrites refs, refresh the optimistic message in place so the
// transcript shows the resolved @file: ref rather than the local path.
const rewriteOptimistic = (sid: string) =>
updateSessionState(
sid,
state => ({
...state,
messages: state.messages.map(message => (message.id === optimisticId ? buildUserMessage() : message))
}),
selectedStoredSessionIdRef.current
)
const dropOptimistic = (sid: null | string) => {
if (!sid) {
setMessages(current => current.filter(m => m.id !== optimisticId))
@@ -452,7 +366,7 @@ export function usePromptActions({
if (sessionId) {
seedOptimistic(sessionId)
} else {
setMessages(current => [...current, buildUserMessage()])
setMessages(current => [...current, userMessage])
}
if (!sessionId) {
@@ -478,46 +392,10 @@ export function usePromptActions({
}
try {
const syncedAttachments = await syncAttachmentsForSubmit(sessionId, attachments, {
await syncImageAttachmentsForSubmit(sessionId, attachments, {
updateComposerAttachments: usingComposerAttachments
})
// Rewrite the optimistic message + prompt text with the synced refs so
// the gateway receives @file: paths that resolve in its workspace.
attachmentRefs = syncedAttachments.map(attachmentDisplayText).filter((r): r is string => Boolean(r))
rewriteOptimistic(sessionId)
const text = buildContextText(syncedAttachments)
// On sleep/wake the gateway's in-memory session may have been cleared
// while the desktop app still holds the old session ID. Detect this,
// resume the stored session to re-register it, and retry once.
let submitErr: unknown = null
try {
await requestGateway('prompt.submit', { session_id: sessionId, text })
} catch (firstErr) {
const firstMsg = firstErr instanceof Error ? firstErr.message : String(firstErr)
if (/session not found/i.test(firstMsg) && selectedStoredSessionIdRef.current) {
// Re-register the session in the gateway and get a fresh live ID.
const resumed = await requestGateway<{ session_id: string }>('session.resume', {
session_id: selectedStoredSessionIdRef.current
})
const recoveredId = resumed?.session_id
if (recoveredId) {
activeSessionIdRef.current = recoveredId
await requestGateway('prompt.submit', { session_id: recoveredId, text })
} else {
submitErr = firstErr
}
} else {
submitErr = firstErr
}
}
if (submitErr !== null) {
throw submitErr
}
await requestGateway('prompt.submit', { session_id: sessionId, text })
if (usingComposerAttachments) {
clearComposerAttachments()
@@ -564,7 +442,7 @@ export function usePromptActions({
createBackendSessionForSend,
requestGateway,
selectedStoredSessionIdRef,
syncAttachmentsForSubmit,
syncImageAttachmentsForSubmit,
updateSessionState
]
)
@@ -84,60 +84,6 @@ describe('useRouteResume', () => {
expect(resumeSession).not.toHaveBeenCalled()
})
it('self-heals a stranded routed session (null selected/active, same pathname, not a fresh draft)', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
// A create/stream race nulls selected/active but the route stays on the
// session and freshDraftReady is false (NOT a new-chat transition).
activeSessionIdRef.current = null
selectedStoredSessionIdRef.current = null
rerender(
<RouteResumeHarness
activeSessionId={null}
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId={null}
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
it('resumes when pathname changes to a routed session', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
@@ -187,72 +133,4 @@ describe('useRouteResume', () => {
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-2', true)
})
it('resumes the selected route again when the gateway reconnects', () => {
const resumeSession = vi.fn(async () => undefined)
const startFreshSessionDraft = vi.fn()
const activeSessionIdRef: MutableRefObject<null | string> = { current: 'runtime-1' }
const creatingSessionRef = { current: false }
const runtimeIdByStoredSessionIdRef = { current: new Map([['session-1', 'runtime-1']]) }
const selectedStoredSessionIdRef: MutableRefObject<null | string> = { current: 'session-1' }
const { rerender } = render(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).not.toHaveBeenCalled()
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="closed"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
rerender(
<RouteResumeHarness
activeSessionId="runtime-1"
activeSessionIdRef={activeSessionIdRef}
creatingSessionRef={creatingSessionRef}
currentView="chat"
freshDraftReady={false}
gatewayState="open"
locationPathname="/session-1"
resumeSession={resumeSession}
routedSessionId="session-1"
runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef}
selectedStoredSessionId="session-1"
selectedStoredSessionIdRef={selectedStoredSessionIdRef}
startFreshSessionDraft={startFreshSessionDraft}
/>
)
expect(resumeSession).toHaveBeenCalledTimes(1)
expect(resumeSession).toHaveBeenCalledWith('session-1', true)
})
})
@@ -56,19 +56,13 @@ export function useRouteResume({
startFreshSessionDraft
}: RouteResumeOptions) {
const lastPathnameRef = useRef<string | null>(null)
const seenGatewayStateRef = useRef(false)
const wasGatewayOpenRef = useRef(false)
useEffect(() => {
const gatewayOpen = gatewayState === 'open'
const pathnameChanged = lastPathnameRef.current !== locationPathname
// Fire only on a genuine closed->open transition (a reconnect). seenGatewayStateRef
// stays false until the first effect run, so a session that mounts with the gateway
// already open is not mistaken for "became open" and does not double-resume with the
// pathname-driven initial resume below.
const gatewayBecameOpen = seenGatewayStateRef.current && !wasGatewayOpenRef.current && gatewayOpen
const gatewayBecameOpen = !wasGatewayOpenRef.current && gatewayOpen
lastPathnameRef.current = locationPathname
seenGatewayStateRef.current = true
wasGatewayOpenRef.current = gatewayOpen
if (currentView !== 'chat' || !gatewayOpen) {
@@ -83,33 +77,12 @@ export function useRouteResume({
Boolean(cachedRuntime) &&
cachedRuntime === activeSessionIdRef.current
// Self-heal a desynced view: the route points at a session that isn't the
// loaded one. A create/stream race can leave selected/active null while
// the route stays on /:sid (symptom: brand-new chat shows "Thinking" then
// an empty transcript even though the turn completed and persisted). The
// pathname didn't change, so the normal gate would skip and the view stays
// stuck empty forever. selectedStoredSessionIdRef is set synchronously at
// resume entry, so this can't loop; the resume's cached fast-path restores
// the already-streamed messages without a refetch.
//
// Crucially this must NOT fire during a /:sid -> /new transition, where
// startFreshSessionDraft nulls selected/active one render before the
// pathname flips to / (same null+/:sid signature). freshDraftReady is the
// discriminator: it's true while heading into a blank new chat, false when
// genuinely stranded on a routed session.
const stuckOnRoutedSession = routedSessionId !== selectedStoredSessionIdRef.current && !freshDraftReady
// Resume when the route meaningfully changed, the gateway just opened, or
// we're stranded on a routed session that never loaded. The first two
// guard against a transient /:sid re-resume during "new chat" state clears
// Resume only when the route meaningfully changed (or gateway just opened).
// This avoids a transient /:sid re-resume during "new chat" state clears
// before the pathname updates from /:sid -> /.
const shouldResume = pathnameChanged || gatewayBecameOpen || stuckOnRoutedSession
const shouldResume = pathnameChanged || gatewayBecameOpen
// On a reconnect (gatewayBecameOpen) re-resume even when the route looks
// `alreadyActive`: the cached runtime id can be stale once the gateway
// rebinds/reaps the session on its side, and trusting it strands Desktop on
// a dead id ("session not found"). Otherwise keep skipping when already active.
if ((gatewayBecameOpen || !alreadyActive) && shouldResume && !creatingSessionRef.current) {
if (!alreadyActive && shouldResume && !creatingSessionRef.current) {
void resumeSession(routedSessionId, true)
}
@@ -150,29 +150,6 @@ export function useSessionStateCache({
pendingViewStateRef.current = { sessionId, state }
// Terminal / attention transitions (turn finished, error, or the agent is
// now waiting on the user) MUST reach the view immediately. Electron
// throttles `requestAnimationFrame` to ~0 while the window is
// backgrounded, occluded, or unfocused, so an RAF-deferred flush can be
// stranded in `pendingViewStateRef` indefinitely — that's the "new chat
// stuck on Thinking until I refocus / F5" bug. Flush these synchronously
// (cancelling any in-flight RAF, since we're about to publish the latest
// state anyway). The plain busy heartbeat stays RAF-batched: that
// coalescing exists only to keep periodic `session.info` updates from
// churning `$messages` and jerking the scroll position while reading.
const isCriticalTransition = !state.busy || state.needsInput
if (isCriticalTransition) {
if (viewSyncRafRef.current !== null && typeof window !== 'undefined') {
window.cancelAnimationFrame(viewSyncRafRef.current)
viewSyncRafRef.current = null
}
flushPendingViewState()
return
}
if (viewSyncRafRef.current !== null) {
return
}
-14
View File
@@ -27,20 +27,6 @@ export interface ImageDetachResponse {
count?: number
}
export interface FileAttachResponse {
attached?: boolean
message?: string
// Gateway-side absolute path the file was staged to.
path?: string
// Workspace-relative path used to build ref_text.
ref_path?: string
// Rewritten @file: ref that resolves on the gateway (workspace-relative).
ref_text?: string
// True when bytes/host file were copied into the session workspace.
uploaded?: boolean
name?: string
}
export interface SlashExecResponse {
output?: string
warning?: string
@@ -494,9 +494,11 @@ export function MarkdownTextContent({ isRunning, text, ...surfaceProps }: Markdo
const MarkdownTextImpl = () => {
return (
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
<SmoothStreamingText>
<DeferStreamingText>
<MarkdownTextSurface />
</DeferStreamingText>
</SmoothStreamingText>
)
}
-1
View File
@@ -1081,7 +1081,6 @@ export const en: Translations = {
sessionRunning: 'Session running',
needsInput: 'Needs your input',
waitingForAnswer: 'Waiting for your answer',
handoffOrigin: platform => `Handed off from ${platform}`,
renamed: 'Renamed',
renameFailed: 'Rename failed',
renameTitle: 'Rename session',
-1
View File
@@ -1224,7 +1224,6 @@ export const ja = defineLocale({
sessionRunning: 'セッション実行中',
needsInput: '入力が必要です',
waitingForAnswer: '回答を待っています',
handoffOrigin: platform => `${platform} から引き継ぎ`,
renamed: '名前を変更しました',
renameFailed: '名前の変更に失敗しました',
renameTitle: 'セッションの名前を変更',
-1
View File
@@ -838,7 +838,6 @@ export interface Translations {
sessionRunning: string
needsInput: string
waitingForAnswer: string
handoffOrigin: (platform: string) => string
renamed: string
renameFailed: string
renameTitle: string
-1
View File
@@ -1190,7 +1190,6 @@ export const zhHant = defineLocale({
sessionRunning: '工作階段執行中',
needsInput: '需要您的輸入',
waitingForAnswer: '等待您的回答',
handoffOrigin: platform => `${platform} 轉接`,
renamed: '已重新命名',
renameFailed: '重新命名失敗',
renameTitle: '重新命名工作階段',
-1
View File
@@ -1268,7 +1268,6 @@ export const zh: Translations = {
sessionRunning: '会话运行中',
needsInput: '需要你输入',
waitingForAnswer: '正在等待你的回答',
handoffOrigin: platform => `${platform} 转接`,
renamed: '已重命名',
renameFailed: '重命名失败',
renameTitle: '重命名会话',
-64
View File
@@ -34,76 +34,12 @@ const SOURCE_ALIASES: Record<string, string[]> = {
whatsapp: ['wa']
}
// Sources that run on the local machine rather than an external messaging
// platform. A handoff *from* one of these isn't a platform origin worth a badge.
// Exported so the recents fetch can keep these in the main list while the
// messaging fetch excludes them.
export const LOCAL_SESSION_SOURCE_IDS = ['cli', 'codex', 'desktop', 'gateway', 'local', 'tui']
const LOCAL_SOURCE_IDS = new Set(LOCAL_SESSION_SOURCE_IDS)
// External messaging platforms that each get their own self-managed sidebar
// section (fetched separately from local recents). Mirrors the gateway platform
// adapters; keep in sync with PLATFORM_ICONS in app/messaging/platform-icon.tsx.
export const MESSAGING_SESSION_SOURCE_IDS = [
'telegram',
'discord',
'slack',
'mattermost',
'matrix',
'signal',
'whatsapp',
'bluebubbles',
'homeassistant',
'email',
'sms',
'webhook',
'api_server',
'weixin',
'wecom',
'qqbot',
'yuanbao',
'dingtalk',
'feishu'
]
const MESSAGING_SOURCE_IDS = new Set(MESSAGING_SESSION_SOURCE_IDS)
/** True when a source id is an external messaging platform (gets its own
* sidebar section) rather than a local/CLI/desktop session. */
export function isMessagingSource(source: null | string | undefined): boolean {
const id = normalizeSessionSource(source)
return id != null && MESSAGING_SOURCE_IDS.has(id)
}
export function normalizeSessionSource(source: null | string | undefined): string | null {
const id = source?.trim().toLowerCase()
return id || null
}
/**
* Resolve the origin messaging platform for a handed-off session. Returns the
* normalized platform id (e.g. 'telegram') when the session completed a handoff
* from a real messaging platform, otherwise null. After a handoff the live
* source is local, so this is what drives the row's origin-platform badge.
*/
export function handoffOriginSource(
handoffState: null | string | undefined,
handoffPlatform: null | string | undefined
): string | null {
if (handoffState !== 'completed') {
return null
}
const id = normalizeSessionSource(handoffPlatform)
if (!id || LOCAL_SOURCE_IDS.has(id)) {
return null
}
return id
}
export function sessionSourceLabel(source: null | string | undefined): string | null {
const id = normalizeSessionSource(source)
-18
View File
@@ -85,20 +85,6 @@ export const $cronSessions = atom<SessionInfo[]>([])
// badge renders "N+". Lives here so the controller (fetch) and sidebar (badge)
// share one source of truth without a circular import.
export const CRON_SECTION_LIMIT = 50
// Messaging-platform sessions (telegram/discord/...) are fetched as their own
// slice — separate from local recents — so each platform renders a
// self-managed sidebar section and never interleaves with (or buries) local
// chats in the recents page. One combined fetch seeds every platform; a
// platform that exceeds this cap gets its own per-platform "load more".
export const $messagingSessions = atom<SessionInfo[]>([])
export const MESSAGING_SECTION_LIMIT = 100
// Exact per-platform conversation totals, keyed by source id. Empty until a
// per-platform "load more" fetch resolves it (the combined seed fetch only
// knows the aggregate), so sections fall back to their loaded count.
export const $messagingPlatformTotals = atom<Record<string, number>>({})
// True when the combined seed fetch hit MESSAGING_SECTION_LIMIT, so at least
// one platform may have more rows on disk than were loaded.
export const $messagingTruncated = atom<boolean>(false)
// Listable conversation count per profile (children excluded), keyed by profile
// name. Lets the sidebar scope its "Load more" footer to the active profile so a
// huge default profile doesn't keep "Load more" visible while browsing a small
@@ -143,10 +129,6 @@ export const setGatewayState = (next: Updater<string>) => updateAtom($gatewaySta
export const setSessions = (next: Updater<SessionInfo[]>) => updateAtom($sessions, next)
export const setSessionsTotal = (next: Updater<number>) => updateAtom($sessionsTotal, next)
export const setCronSessions = (next: Updater<SessionInfo[]>) => updateAtom($cronSessions, next)
export const setMessagingSessions = (next: Updater<SessionInfo[]>) => updateAtom($messagingSessions, next)
export const setMessagingPlatformTotals = (next: Updater<Record<string, number>>) =>
updateAtom($messagingPlatformTotals, next)
export const setMessagingTruncated = (next: Updater<boolean>) => updateAtom($messagingTruncated, next)
export const setSessionProfileTotals = (next: Updater<Record<string, number>>) =>
updateAtom($sessionProfileTotals, next)
export const setSessionsLoading = (next: Updater<boolean>) => updateAtom($sessionsLoading, next)
+1 -2
View File
@@ -88,8 +88,7 @@ function isUpdateToastSnoozed(): boolean {
// Must match tui_gateway's DESKTOP_BACKEND_CONTRACT that this build was written
// against. The backend reports its own value in session runtime info; a lower
// value (or none — a pre-GUI checkout) means GUI<->backend skew.
// v2: requires the file.attach RPC (remote-gateway non-image file upload).
const REQUIRED_BACKEND_CONTRACT = 2
const REQUIRED_BACKEND_CONTRACT = 1
const SKEW_TOAST_ID = 'backend-contract-skew'
/**
-8
View File
@@ -297,14 +297,6 @@ export interface SessionInfo {
started_at: number
title: null | string
tool_call_count: number
/** Origin platform when this session was handed off from a messaging
* platform (e.g. a Telegram thread continued in the desktop app). The live
* {@link source} becomes local (tui/desktop) after a handoff, so the origin
* is preserved here to surface the platform badge on the row. */
handoff_platform?: null | string
/** Handoff lifecycle: 'pending' | 'in_progress' | 'completed' | 'failed'. */
handoff_state?: null | string
handoff_error?: null | string
/** Owning profile name, set by the cross-profile aggregator
* (`/api/profiles/sessions`). Absent on legacy single-profile responses,
* which the UI treats as the default profile. */
+2 -2
View File
@@ -1,8 +1,8 @@
{
"compilerOptions": {
"target": "ES2023",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["DOM", "DOM.Iterable", "ES2023"],
"lib": ["DOM", "DOM.Iterable", "ES2022"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
+3 -4
View File
@@ -1797,10 +1797,9 @@ class BasePlatformAdapter(ABC):
# ``format_message`` translates/preserves markdown fences into a real code
# block). Capability flag for markdown-aware presentation choices.
# Default False (plain-text platforms); markdown-rendering adapters set True.
# Tool-progress uses this to render a terminal command as a bare fenced code
# block (no language tag — Slack mrkdwn would print the tag as a literal
# first code line). Plain-text platforms fall back to the short truncated
# preview (see gateway/run.py progress_callback).
# Note: tool-progress deliberately does NOT use this to render a terminal
# command as a ```bash block — that exposed full commands in chat. Progress
# shows a short truncated preview only (see gateway/run.py progress_callback).
supports_code_blocks: bool = False
def __init__(self, config: PlatformConfig, platform: Platform):
+175 -271
View File
@@ -157,12 +157,6 @@ _YB_RES_REF_RE = re.compile(
r"\[(image|voice|video|file(?::[^|\]]*)?)\|ybres:([A-Za-z0-9_\-]+)\]"
)
# Patched local-media anchors once an inbound resource has been downloaded to the local cache.
# [image: /opt/data/image_cache/img_xxx.bmp]
# [file: report.pdf → /opt/data/.../report.pdf]
# (and any future kind, e.g. [video: /opt/.../clip.mp4])
_YB_LOCAL_MEDIA_RE = re.compile(r"\[(\w+):[^\]]*?(/[^\]]+?)\s*\]")
# Media kinds that can be resolved and injected into the model context
_RESOLVABLE_MEDIA_KINDS = frozenset({"image", "file"})
@@ -946,11 +940,7 @@ class InboundContext:
reply_to_text: Optional[str] = None
quote_media_refs: list = dc_field(default_factory=list) # List of (rid, kind, filename)
# Populated by MediaResolveMiddleware. Combined list of resolved local
# paths from up to three sources (deduped, in this order):
# 1) media carried by the current message (always),
# 2) media from the quoted message (when reply_to_message_id is set),
# 3) recent group-observed media (only when chat_type == "group" and no quote is present).
# Populated by MediaResolveMiddleware
media_urls: list = dc_field(default_factory=list)
media_types: list = dc_field(default_factory=list)
@@ -1695,10 +1685,10 @@ class ExtractContentMiddleware(InboundMiddleware):
"""Extract plain text content from MsgBody.
- TIMTextElem -> text field
- TIMImageElem -> "[image]" / "[image|ybres:RID]"
- TIMFileElem -> "[file: {filename}]" / "[file:{name}|ybres:RID]"
- TIMSoundElem -> "[voice]" / "[voice|ybres:RID]"
- TIMVideoFileElem -> "[video]" / "[video|ybres:RID]"
- TIMImageElem -> "[image]"
- TIMFileElem -> "[file: {filename}]"
- TIMSoundElem -> "[voice]"
- TIMVideoFileElem -> "[video]"
- TIMFaceElem -> "[emoji: {name}]" or "[emoji]"
- TIMCustomElem -> try to extract data field, otherwise "[custom message]"
- Multiple elems joined with spaces
@@ -2197,72 +2187,51 @@ class QuoteContextMiddleware(InboundMiddleware):
name = "quote-context"
def _extract_quote_context(self, cloud_custom_data: str) -> Tuple[Optional[str], Optional[str]]:
"""Extract quote text context, mapping to MessageEvent.reply_to_*.
@staticmethod
def _extract_quote_context(cloud_custom_data: str) -> Tuple[Optional[str], Optional[str], list]:
"""Extract quote context, mapping to MessageEvent.reply_to_*.
Returns:
(reply_to_message_id, reply_to_text, quote_media_refs)
where quote_media_refs is a list of (rid, kind, filename) tuples
"""
if not cloud_custom_data:
return None, None
return None, None, []
try:
parsed = json.loads(cloud_custom_data)
except (json.JSONDecodeError, TypeError):
return None, None
return None, None, []
quote = parsed.get("quote") if isinstance(parsed, dict) else None
if not isinstance(quote, dict):
return None, None
return None, None, []
# type=2 corresponds to image reference; desc may be empty, provide a placeholder.
quote_type = int(quote.get("type") or 0)
desc = str(quote.get("desc") or "").strip()
if quote_type == 2 and not desc:
desc = "[image]"
if not desc:
return None, None, []
quote_id = str(quote.get("id") or "").strip() or None
desc = str(quote.get("desc") or "").strip()
sender = str(quote.get("sender_nickname") or quote.get("sender_id") or "").strip()
quote_text = (f"{sender}: {desc}" if sender else desc) if desc else None
quote_text = f"{sender}: {desc}" if sender else desc
return quote_id, quote_text
# Extract media references from desc using _YB_RES_REF_RE regex
media_refs: list = []
for m in _YB_RES_REF_RE.finditer(desc):
head = m.group(1) # "image" | "file:<name>" | "voice" | "video"
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
media_refs.append((rid, kind, filename.strip()))
async def _extract_media_refs_from_transcript(
self, ctx: InboundContext
) -> List[Tuple[str, str, str]]:
"""Look up the quoted message in the transcript history and return any
``[kind|ybres:RID]`` anchors found in its content as
``(rid, kind, filename)`` tuples.
Returns ``[]`` when ``ctx.reply_to_message_id`` is unset, when the
transcript store / source is unavailable, or when the quoted message
carries no resolvable media anchors.
"""
if ctx.reply_to_message_id is None:
return []
adapter = ctx.adapter
media_refs: List[Tuple[str, str, str]] = []
try:
store = getattr(adapter, "_session_store", None)
if not store or ctx.source is None:
return []
session_entry = store.get_or_create_session(ctx.source)
history = store.load_transcript(session_entry.session_id)
for msg in reversed(history or []):
mid = msg.get("message_id", "")
if not mid or mid != ctx.reply_to_message_id:
continue
_content = msg.get("content", "")
if isinstance(_content, str) and "|ybres:" in _content:
for m in _YB_RES_REF_RE.finditer(_content):
head = m.group(1)
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind in _RESOLVABLE_MEDIA_KINDS:
media_refs.append((rid, kind, filename.strip()))
break
except Exception as exc:
logger.warning(
"[%s] quote transcript lookup failed: %s",
getattr(adapter, "name", "yuanbao"), exc,
)
return media_refs
return quote_id, quote_text, media_refs
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.reply_to_message_id, ctx.reply_to_text = self._extract_quote_context(ctx.cloud_custom_data)
ctx.quote_media_refs = await self._extract_media_refs_from_transcript(ctx)
ctx.reply_to_message_id, ctx.reply_to_text, ctx.quote_media_refs = self._extract_quote_context(ctx.cloud_custom_data)
await next_fn()
@@ -2467,6 +2436,11 @@ class MediaResolveMiddleware(InboundMiddleware):
cls._put_cached_resource(resource_id, local_path, mime)
return local_path, mime
@classmethod
async def _resolve_by_resource_id(cls, adapter, resource_id: str) -> str:
"""Exchange a Yuanbao ``resourceId`` for a short-lived direct download URL. Raises on failure."""
return await cls._fetch_resource_url(adapter, resource_id)
@classmethod
async def _resolve_media_urls(
cls, adapter, media_refs: List[Dict[str, str]]
@@ -2482,7 +2456,6 @@ class MediaResolveMiddleware(InboundMiddleware):
for ref in media_refs:
kind = str(ref.get("kind") or "").strip().lower()
url = str(ref.get("url") or "").strip()
filename = str(ref.get("name") or "").strip()
if kind not in _RESOLVABLE_MEDIA_KINDS or not url:
continue
@@ -2502,7 +2475,7 @@ class MediaResolveMiddleware(InboundMiddleware):
adapter,
fetch_url=fetch_url,
kind=kind,
file_name=filename or None,
file_name=str(ref.get("name") or "").strip() or None,
log_tag=f"placeholder_url={url[:80]}",
resource_id=rid,
)
@@ -2514,44 +2487,6 @@ class MediaResolveMiddleware(InboundMiddleware):
return media_urls, media_types
@classmethod
async def _resolve_ybres_refs(
cls,
adapter,
refs: List[Tuple[str, str, str]],
*,
log_prefix: str,
) -> Tuple[List[str], List[str]]:
"""Resolve a list of ``(rid, kind, filename)`` ybres tuples to local paths.
"""
media_paths: List[str] = []
mimes: List[str] = []
for rid, kind, filename in refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
try:
fresh_url = await cls._fetch_resource_url(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] %s resolve failed: rid=%s kind=%s err=%s",
adapter.name, log_prefix, rid, kind, exc,
)
continue
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"{log_prefix} rid={rid}",
resource_id=rid,
)
if cached is None:
continue
path, mime = cached
media_paths.append(path)
mimes.append(mime)
return media_paths, mimes
@classmethod
async def _collect_observed_media(
cls, adapter, source,
@@ -2598,175 +2533,39 @@ class MediaResolveMiddleware(InboundMiddleware):
if not order:
return [], []
return await cls._resolve_ybres_refs(
adapter, order, log_prefix="observed-media",
)
@classmethod
async def _resolve_quote_media(
cls, adapter, quote_media_refs: List[Tuple[str, str, str]],
) -> Tuple[List[str], List[str]]:
"""Resolve media anchors carried by the quoted message.
``quote_media_refs`` is a list of ``(rid, kind, filename)`` tuples
produced by :class:`QuoteContextMiddleware` from the transcript.
"""
return await cls._resolve_ybres_refs(
adapter, quote_media_refs, log_prefix="quote",
)
@staticmethod
def _collect_quote_local_media(ctx: InboundContext) -> Tuple[List[str], List[str]]:
"""Private-chat fallback for recovering already-local quoted media.
Only already-local media is handled here: by the time a turn is cached,
``PatchAnchorsMiddleware`` has rewritten resolved ``|ybres:`` anchors to
``[image: /path]`` / ``[file: name /path]``. Unresolved anchors are an
original-turn resolution failure and belong to that turn's handling, not
this quote fallback so no re-download happens here.
Returns ``(local_paths, mimes)`` for media already downloaded to the
local cache on its original turn, ready to inject as-is.
"""
paths: List[str] = []
media_paths: List[str] = []
mimes: List[str] = []
rid_key = ctx.reply_to_message_id
if not rid_key:
return paths, mimes
cache = getattr(ctx.adapter, "_msg_content_cache", None)
if not cache:
return paths, mimes
text = cache.get(rid_key)
if not isinstance(text, str) or not text:
return paths, mimes
# Already-local media paths written by PatchAnchorsMiddleware. The
# generic anchor regex covers every kind _patch emits (image/file today,
# video/audio if they later become resolvable) without per-kind upkeep.
seen: set = set()
for m in _YB_LOCAL_MEDIA_RE.finditer(text):
kind = (m.group(1) or "").strip().lower()
path = (m.group(2) or "").strip()
if not path or path in seen:
continue
if not os.path.exists(path):
continue
seen.add(path)
mime = guess_mime_type(os.path.basename(path)) or (
"image/jpeg" if kind == "image" else "application/octet-stream"
)
paths.append(path)
mimes.append(mime)
return paths, mimes
async def handle(self, ctx: InboundContext, next_fn) -> None:
# NOTE: Reaching this middleware in a group chat implies the message has
# @-mentioned the bot (or is an owner command). GroupAtGuardMiddleware
# short-circuits non-@bot group messages earlier in the pipeline, so we
# don't need to re-check @bot status here before downloading media.
adapter = ctx.adapter
urls: List[str] = []
types: List[str] = []
seen: set = set()
def _add_unique_pairs(pair_lists: Tuple[List[str], List[str]]) -> None:
u_list, m_list = pair_lists
for u, m in zip(u_list, m_list):
if not u or u in seen:
continue
seen.add(u)
urls.append(u)
types.append(m)
# 1) Media carried by the current message itself.
own_pairs = await self._resolve_media_urls(adapter, ctx.media_refs)
own_count = sum(1 for u in own_pairs[0] if u)
_add_unique_pairs(own_pairs)
# 2) Second source — quoted media takes priority; otherwise fall back
# to observed-media backfill in groups only (DMs already had their
# media resolved on the turn it was sent).
if ctx.reply_to_message_id is not None:
if ctx.quote_media_refs:
_add_unique_pairs(await self._resolve_quote_media(adapter, ctx.quote_media_refs))
else:
# DM quote fallback: no transcript message_id match (DM user rows
# carry no platform message_id), so recover already-local media
# from the adapter msg cache. Patched on its original turn — no
# re-download needed, inject as-is.
_add_unique_pairs(self._collect_quote_local_media(ctx))
elif ctx.chat_type == "group":
# Group chats: only @-bot turns reach this middleware
# (see GroupAtGuardMiddleware note at top of handle()),
# so unconditional observed-media hydration is safe here.
for rid, kind, filename in order:
try:
_add_unique_pairs(await self._collect_observed_media(adapter, ctx.source))
fresh_url = await cls._resolve_by_resource_id(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] observed-image hydration raised, continuing anyway: %s",
adapter.name, exc,
"[%s] observed-media resolve failed: rid=%s kind=%s err=%s",
adapter.name, rid, kind, exc,
)
ctx.media_urls = urls
ctx.media_types = types
# Re-check placeholder after media resolution.
# Use ``own_count`` (not ``len(urls)``) to preserve the original
# semantics: a placeholder text accompanied only by quote/observed
# media (i.e. no fresh attachment of its own) is still skippable.
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, own_count):
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
return # Stop pipeline
await next_fn()
class PatchAnchorsMiddleware(InboundMiddleware):
"""Replace ``[kind|ybres:RID]`` anchors in ``ctx.raw_text`` with local paths.
Runs after :class:`MediaResolveMiddleware` so that ``ctx.media_urls`` /
``ctx.media_types`` are already populated with downloaded resources
(own media + quote media or group-observed media). The transcript
written downstream then records usable local paths for the model
instead of opaque ``ybres:`` references.
Only resolved media (paths starting with ``/``) are substituted; any
anchor without a corresponding local resource is left untouched.
"""
name = "patch-anchors"
@staticmethod
def _patch(text: str, urls: List[str], types: List[str]) -> str:
if not text or not urls:
return text
patched = text
for u, m in zip(urls, types):
if not u.startswith("/"):
continue
anchor_match = _YB_RES_REF_RE.search(patched)
if not anchor_match:
break
head = anchor_match.group(1)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind == "image" and m.startswith("image/"):
replacement = f"[image: {u}]"
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
else:
continue
patched = (
patched[: anchor_match.start()]
+ replacement
+ patched[anchor_match.end():]
cached = await cls._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"rid={rid}",
resource_id=rid,
)
return patched
if cached is None:
continue
path, mime = cached
media_paths.append(path)
mimes.append(mime)
return media_paths, mimes
async def handle(self, ctx: InboundContext, next_fn) -> None:
ctx.raw_text = self._patch(ctx.raw_text, ctx.media_urls, ctx.media_types)
adapter = ctx.adapter
ctx.media_urls, ctx.media_types = await self._resolve_media_urls(adapter, ctx.media_refs)
# Re-check placeholder after media resolution
if PlaceholderFilterMiddleware.is_skippable_placeholder(ctx.raw_text, len(ctx.media_urls)):
logger.debug("[%s] Skip placeholder after media download: %r", adapter.name, ctx.raw_text)
return # Stop pipeline
await next_fn()
@@ -2785,18 +2584,124 @@ class DispatchMiddleware(InboundMiddleware):
)
async def _dispatch_inbound_event() -> None:
media_urls = list(ctx.media_urls)
media_types = list(ctx.media_types)
# If user quoted a message (reply_to_message_id is set), resolve only
# quote_media_refs to avoid injecting unrelated history media.
# Otherwise, backfill observed media from recent transcript history.
if ctx.reply_to_message_id is not None:
# Fallback: if desc didn't contain ybres refs, look up transcript
if not ctx.quote_media_refs:
try:
store = getattr(adapter, "_session_store", None)
if store:
session_entry = store.get_or_create_session(ctx.source)
history = store.load_transcript(session_entry.session_id)
for msg in reversed(history or []):
mid = msg.get("message_id", "")
if mid and mid == ctx.reply_to_message_id:
_content = msg.get("content", "")
if isinstance(_content, str) and "|ybres:" in _content:
for m in _YB_RES_REF_RE.finditer(_content):
head = m.group(1)
rid = m.group(2)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind in _RESOLVABLE_MEDIA_KINDS:
ctx.quote_media_refs.append((rid, kind, filename.strip()))
break
except Exception as exc:
logger.warning(
"[%s] quote transcript lookup failed: %s",
adapter.name, exc,
)
# User quoted a message — resolve only media from the quote
for rid, kind, filename in ctx.quote_media_refs:
if kind not in _RESOLVABLE_MEDIA_KINDS:
continue
try:
fresh_url = await MediaResolveMiddleware._resolve_by_resource_id(adapter, rid)
except Exception as exc:
logger.warning(
"[%s] quote media resolve failed: rid=%s kind=%s err=%s",
adapter.name, rid, kind, exc,
)
continue
cached = await MediaResolveMiddleware._download_and_cache(
adapter,
fetch_url=fresh_url,
kind=kind,
file_name=filename or None,
log_tag=f"quote rid={rid}",
resource_id=rid,
)
if cached is None:
continue
path, mime = cached
# Avoid duplicates
if path not in media_urls:
media_urls.append(path)
media_types.append(mime)
else:
# No quote — backfill observed media from recent transcript history
extra_img_urls: List[str] = []
extra_img_mimes: List[str] = []
try:
extra_img_urls, extra_img_mimes = await MediaResolveMiddleware._collect_observed_media(
adapter, ctx.source,
)
except Exception as exc:
logger.warning(
"[%s] observed-image hydration raised, continuing anyway: %s",
adapter.name, exc,
)
if extra_img_urls:
current = set(media_urls)
for u, m in zip(extra_img_urls, extra_img_mimes):
if u in current:
continue
media_urls.append(u)
media_types.append(m)
current.add(u)
# Replace [kind|ybres:xxx] anchors with local cache paths so
# the transcript records usable paths for the model.
_patched_event_text = ctx.raw_text
for u, m in zip(media_urls, media_types):
if not u.startswith("/"):
continue
anchor_match = _YB_RES_REF_RE.search(_patched_event_text)
if not anchor_match:
continue
head = anchor_match.group(1)
kind, _, filename = head.partition(":")
kind = kind.strip()
if kind == "image" and m.startswith("image/"):
replacement = f"[image: {u}]"
elif kind == "file":
label = filename.strip() or os.path.basename(u)
replacement = f"[file: {label}{u}]"
else:
continue
_patched_event_text = (
_patched_event_text[:anchor_match.start()]
+ replacement
+ _patched_event_text[anchor_match.end():]
)
event = MessageEvent(
text=ctx.raw_text,
text=_patched_event_text,
message_type=(
MessageType.DOCUMENT
if any(mt.startswith(("application/", "text/")) for mt in ctx.media_types)
if any(mt.startswith(("application/", "text/")) for mt in media_types)
else ctx.msg_type
),
source=ctx.source,
message_id=ctx.msg_id or None,
raw_message=ctx.push,
media_urls=list(ctx.media_urls),
media_types=list(ctx.media_types),
media_urls=media_urls,
media_types=media_types,
reply_to_message_id=ctx.reply_to_message_id,
reply_to_text=ctx.reply_to_text,
channel_prompt=ctx.channel_prompt,
@@ -2890,7 +2795,6 @@ class InboundPipelineBuilder:
ClassifyMessageTypeMiddleware,
QuoteContextMiddleware,
MediaResolveMiddleware,
PatchAnchorsMiddleware,
DispatchMiddleware,
]
+4 -81
View File
@@ -688,18 +688,7 @@ def _last_transcript_timestamp(history: Optional[List[Dict[str, Any]]]) -> Any:
# ordinary outputs. Only tools that intentionally create deliverable media
# artifacts should be eligible for automatic append when the model omits them
# from the final gateway reply.
_AUTO_APPEND_MEDIA_TOOL_NAMES = {
"text_to_speech",
"text_to_speech_tool",
"image_generate",
}
# Tools in this set return their deliverable artifact as a JSON payload with a
# local-file path field rather than a literal ``MEDIA:`` tag (e.g. image_generate
# returns ``{"success": true, "image": "/abs/path.png"}``). The auto-append path
# extracts the path from these fields so delivery is deterministic and does not
# depend on the model restating the path in its final reply.
_JSON_MEDIA_TOOL_PATH_FIELDS = ("host_image", "image", "agent_visible_image")
_AUTO_APPEND_MEDIA_TOOL_NAMES = {"text_to_speech", "text_to_speech_tool"}
# Extension-anchored MEDIA: matcher for tool results. Mirrors the dispatch-site
@@ -766,28 +755,10 @@ def _collect_auto_append_media_tags(
if tool_name_by_call_id.get(call_id) not in _AUTO_APPEND_MEDIA_TOOL_NAMES:
continue
content = str(msg.get("content") or "")
tool_name = tool_name_by_call_id.get(call_id)
# JSON-payload tools (image_generate) return a local-file path in a
# known field rather than a MEDIA: tag. Extract it so delivery is
# deterministic even when the model omits the path from its reply.
if tool_name == "image_generate" and "MEDIA:" not in content:
try:
payload = json.loads(content)
except Exception:
payload = None
if isinstance(payload, dict) and payload.get("success"):
for field in _JSON_MEDIA_TOOL_PATH_FIELDS:
path = payload.get(field)
if (isinstance(path, str)
and _TOOL_MEDIA_RE.fullmatch(f"MEDIA:{path}")
and path not in history_media_paths):
media_tags.append(f"MEDIA:{path}")
break
continue
if "MEDIA:" not in content:
continue
for match in _TOOL_MEDIA_RE.finditer(content):
path = match.group(1).strip().rstrip('",}')
path = match.group(1).strip().rstrip('\",}')
if path and path not in history_media_paths:
media_tags.append(f"MEDIA:{path}")
if "[[audio_as_voice]]" in content:
@@ -13000,53 +12971,9 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# Build progress message with primary argument preview
from agent.display import get_tool_emoji
emoji = get_tool_emoji(tool_name, default="⚙️")
# Markdown-capable platforms render a terminal command as a fenced
# code block instead of the compact `terminal: "cmd…"` preview.
# Gated on the adapter's ``supports_code_blocks`` capability so
# plain-text platforms keep the short line. No language tag is
# emitted — Slack mrkdwn renders the tag as a literal first code
# line ("bash"), and a bare fence renders correctly everywhere
# that supports blocks.
#
# Verbose mode shows the FULL command. Non-verbose ("all"/"new")
# modes still wrap in a fence but truncate to a single line capped
# at ``tool_preview_length`` (default 40) so a long or multi-line
# command doesn't render as a huge block — matching the budget the
# non-terminal preview path already applies (#42634).
_code_block_full = None
_code_block_short = None
try:
_progress_adapter = self.adapters.get(source.platform)
except Exception:
_progress_adapter = None
if (
getattr(_progress_adapter, "supports_code_blocks", False)
and tool_name == "terminal"
and isinstance(args, dict)
and isinstance(args.get("command"), str)
and args["command"].strip()
):
from agent.display import get_tool_preview_max_len
_cmd_full = args["command"].rstrip()
_code_block_full = f"{emoji} {tool_name}\n```\n{_cmd_full}\n```"
# Single-line, capped preview for non-verbose modes.
_pl = get_tool_preview_max_len()
_cap = _pl if _pl > 0 else 40
_lines = _cmd_full.splitlines()
_cmd_short = _lines[0] if _lines else _cmd_full
_multiline = len(_lines) > 1
if len(_cmd_short) > _cap:
_cmd_short = _cmd_short[:_cap - 3] + "..."
elif _multiline:
_cmd_short = _cmd_short + " ..."
_code_block_short = f"{emoji} {tool_name}\n```\n{_cmd_short}\n```"
# Verbose mode: show detailed arguments, respects tool_preview_length
if progress_mode == "verbose":
if _code_block_full is not None:
progress_queue.put(_code_block_full)
return
if args:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
@@ -13067,11 +12994,7 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
# "all" / "new" modes: short preview, respects tool_preview_length
# config (defaults to 40 chars when unset to keep gateway messages
# compact — unlike CLI spinners, these persist as permanent messages).
# Terminal commands on markdown platforms get a single-line capped
# fenced block (built above) instead of the truncated preview.
if _code_block_short is not None:
msg = _code_block_short
elif preview:
if preview:
from agent.display import get_tool_preview_max_len
_pl = get_tool_preview_max_len()
_cap = _pl if _pl > 0 else 40
+30 -92
View File
@@ -13,7 +13,6 @@ This module provides:
"""
import copy
import json
import logging
import os
import platform
@@ -5153,94 +5152,6 @@ def load_config_readonly() -> Dict[str, Any]:
return _load_config_impl(want_deepcopy=False)
TERMINAL_CONFIG_ENV_MAP = {
"backend": "TERMINAL_ENV",
"modal_mode": "TERMINAL_MODAL_MODE",
"cwd": "TERMINAL_CWD",
"timeout": "TERMINAL_TIMEOUT",
"lifetime_seconds": "TERMINAL_LIFETIME_SECONDS",
"docker_image": "TERMINAL_DOCKER_IMAGE",
"docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
"singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"modal_image": "TERMINAL_MODAL_IMAGE",
"daytona_image": "TERMINAL_DAYTONA_IMAGE",
"ssh_host": "TERMINAL_SSH_HOST",
"ssh_user": "TERMINAL_SSH_USER",
"ssh_port": "TERMINAL_SSH_PORT",
"ssh_key": "TERMINAL_SSH_KEY",
"container_cpu": "TERMINAL_CONTAINER_CPU",
"container_memory": "TERMINAL_CONTAINER_MEMORY",
"container_disk": "TERMINAL_CONTAINER_DISK",
"container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
"docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"docker_env": "TERMINAL_DOCKER_ENV",
"docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"docker_extra_args": "TERMINAL_DOCKER_EXTRA_ARGS",
"docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"sandbox_dir": "TERMINAL_SANDBOX_DIR",
"persistent_shell": "TERMINAL_PERSISTENT_SHELL",
}
def _terminal_env_value(value: Any) -> str:
if isinstance(value, (list, dict)):
return json.dumps(value)
return str(value)
def terminal_config_env_var_for_key(key: str) -> Optional[str]:
"""Return the env var mirrored by a ``terminal.*`` config key."""
prefix = "terminal."
if not key.startswith(prefix):
return None
return TERMINAL_CONFIG_ENV_MAP.get(key[len(prefix):])
def apply_terminal_config_to_env(
*,
env: Optional[Dict[str, str]] = None,
config: Optional[Dict[str, Any]] = None,
override: Optional[bool] = None,
) -> Dict[str, str]:
"""Bridge ``terminal.*`` config into the env vars terminal tools read.
``tools.terminal_tool`` is intentionally environment-driven because it also
runs in child processes (TUI, dashboard PTY, gateway workers). This helper
gives those child-process launch paths the same config bridge as classic
CLI without importing ``cli.py`` and paying for its startup side effects.
When the user config contains a ``terminal`` section, config.yaml is
authoritative and overrides existing env values. Otherwise defaults only
backfill missing env vars so exported/.env values keep working.
"""
target = os.environ if env is None else env
raw_config = read_raw_config()
file_has_terminal_config = isinstance(raw_config.get("terminal"), dict)
should_override = file_has_terminal_config if override is None else override
cfg = config if config is not None else load_config_readonly()
terminal_cfg = cfg.get("terminal", {}) if isinstance(cfg, dict) else {}
if not isinstance(terminal_cfg, dict):
return target
for cfg_key, env_var in TERMINAL_CONFIG_ENV_MAP.items():
if cfg_key not in terminal_cfg:
continue
value = terminal_cfg[cfg_key]
if cfg_key == "cwd":
raw_cwd = str(value or "").strip()
if raw_cwd in {".", "auto", "cwd"}:
continue
if isinstance(value, str):
value = os.path.expanduser(value)
if should_override or env_var not in target:
target[env_var] = _terminal_env_value(value)
return target
def _load_config_impl(*, want_deepcopy: bool) -> Dict[str, Any]:
with _CONFIG_LOCK:
ensure_hermes_home()
@@ -6129,9 +6040,36 @@ def set_config_value(key: str, value: str):
# Keep .env in sync for keys that terminal_tool reads directly from env vars.
# config.yaml is authoritative, but terminal_tool only reads TERMINAL_ENV etc.
env_var = terminal_config_env_var_for_key(key)
if env_var and key != "terminal.cwd":
save_env_value(env_var, _terminal_env_value(value))
_config_to_env_sync = {
"terminal.backend": "TERMINAL_ENV",
"terminal.modal_mode": "TERMINAL_MODAL_MODE",
"terminal.docker_image": "TERMINAL_DOCKER_IMAGE",
"terminal.singularity_image": "TERMINAL_SINGULARITY_IMAGE",
"terminal.modal_image": "TERMINAL_MODAL_IMAGE",
"terminal.daytona_image": "TERMINAL_DAYTONA_IMAGE",
"terminal.docker_mount_cwd_to_workspace": "TERMINAL_DOCKER_MOUNT_CWD_TO_WORKSPACE",
"terminal.docker_run_as_host_user": "TERMINAL_DOCKER_RUN_AS_HOST_USER",
"terminal.docker_persist_across_processes": "TERMINAL_DOCKER_PERSIST_ACROSS_PROCESSES",
"terminal.docker_orphan_reaper": "TERMINAL_DOCKER_ORPHAN_REAPER",
"terminal.docker_env": "TERMINAL_DOCKER_ENV",
# JSON-valued keys (terminal_tool parses these via json.loads). The user
# passes JSON on the CLI, so str(value) below already yields valid JSON —
# same as terminal.docker_env. cli.py and gateway/run.py bridge these too.
"terminal.docker_volumes": "TERMINAL_DOCKER_VOLUMES",
"terminal.docker_forward_env": "TERMINAL_DOCKER_FORWARD_ENV",
# terminal.cwd intentionally excluded — CLI resolves at runtime,
# gateway bridges it in gateway/run.py. Persisting to .env causes
# stale values to poison child processes.
"terminal.timeout": "TERMINAL_TIMEOUT",
"terminal.sandbox_dir": "TERMINAL_SANDBOX_DIR",
"terminal.persistent_shell": "TERMINAL_PERSISTENT_SHELL",
"terminal.container_cpu": "TERMINAL_CONTAINER_CPU",
"terminal.container_memory": "TERMINAL_CONTAINER_MEMORY",
"terminal.container_disk": "TERMINAL_CONTAINER_DISK",
"terminal.container_persistent": "TERMINAL_CONTAINER_PERSISTENT",
}
if key in _config_to_env_sync:
save_env_value(_config_to_env_sync[key], str(value))
print(f"✓ Set {key} = {value} in {config_path}")
-50
View File
@@ -1825,11 +1825,6 @@ def _launch_tui(
import tempfile
env = os.environ.copy()
try:
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env(env=env)
except Exception:
logger.debug("Failed to apply terminal config bridge for TUI launch", exc_info=True)
active_session_fd, active_session_file = tempfile.mkstemp(
prefix="hermes-tui-active-session-", suffix=".json"
)
@@ -5226,31 +5221,12 @@ def cmd_gui(args: argparse.Namespace):
# is still locked by a running instance; stop it before retry.
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=env, check=False)
if build_result.returncode != 0 and not source_mode and not env.get("ELECTRON_MIRROR"):
# Still failing and the user hasn't pinned a mirror: GitHub's
# Electron release host is likely blocked/throttled (the repeating
# "retrying" download log). Retry once via npmmirror.com — the
# de-facto Electron community mirror (Alibaba). @electron/get
# SHASUM-checks the download, but the SHASUMS come from the same
# mirror, so that guards against a corrupt/partial download, NOT
# a compromised mirror: reaching for it is an explicit trust
# trade-off we only make AFTER the canonical GitHub download has
# failed, and we never override a user-pinned ELECTRON_MIRROR.
print(" ⚠ Desktop build still failing; the Electron download from "
"GitHub looks blocked. Retrying once via a public mirror "
"(npmmirror.com)... (set ELECTRON_MIRROR to use another mirror)")
mirror_env = dict(env)
mirror_env["ELECTRON_MIRROR"] = "https://npmmirror.com/mirrors/electron/"
_stop_desktop_processes_locking_build(desktop_dir)
build_result = subprocess.run([npm, "run", build_script], cwd=desktop_dir, env=mirror_env, check=False)
if build_result.returncode != 0:
print("✗ Desktop GUI build failed")
print(f" Run manually: cd apps/desktop && npm run {build_script}")
if sys.platform == "win32":
print(" If this says \"Access is denied\" on Hermes.exe, close any")
print(" running Hermes desktop window and retry.")
print(" If the log shows Electron download retries, rebuild via a mirror:")
print(" ELECTRON_MIRROR=<mirror-base-url> hermes desktop --force-build")
sys.exit(build_result.returncode or 1)
packaged_executable = _desktop_packaged_executable(desktop_dir)
if not source_mode:
@@ -5830,16 +5806,6 @@ def _update_via_zip(args):
except Exception:
pass
# Seed the model-catalog disk cache from the freshly-unpacked checkout
# (same rationale as the git-pull path in _cmd_update_impl). Non-fatal.
try:
from hermes_cli.model_catalog import seed_cache_from_checkout
if seed_cache_from_checkout(PROJECT_ROOT):
print(" ✓ Model catalog cache refreshed from checkout")
except Exception as e:
logger.debug("Model catalog seed during zip update failed: %s", e)
print()
print("✓ Update complete!")
try:
@@ -8399,22 +8365,6 @@ def _cmd_update_impl(args, gateway_mode: bool):
print()
print("✓ Code updated!")
# Seed the model-catalog disk cache from the freshly-pulled checkout.
# The repo ships the canonical catalog at
# website/static/api/model-catalog.json, and `git pull` just made it
# current — so copy it straight over ~/.hermes/cache/model_catalog.json
# instead of waiting on a network fetch (which can be bot-gated or hit a
# Portal hiccup). Keeps the model picker's curated/free lists in sync
# with the version the user just installed. Non-fatal on failure: the
# normal network refresh still applies on the next picker open.
try:
from hermes_cli.model_catalog import seed_cache_from_checkout
if seed_cache_from_checkout(PROJECT_ROOT):
print(" ✓ Model catalog cache refreshed from checkout")
except Exception as e:
logger.debug("Model catalog seed during update failed: %s", e)
# After git pull, source files on disk are newer than cached Python
# modules in this process. Reload hermes_constants so that any lazy
# import executed below (skills sync, gateway restart) sees new
-31
View File
@@ -356,37 +356,6 @@ def get_curated_nous_models() -> list[str] | None:
return out or None
def seed_cache_from_checkout(project_root: "Path | str") -> bool:
"""Overwrite the disk cache with the catalog shipped in a local checkout.
``hermes update`` pulls the latest repo, so the freshly-pulled
``website/static/api/model-catalog.json`` IS the newest catalog no
network round-trip needed. Copying it straight over the disk cache keeps
the model picker current even when the remote manifest fetch is bot-gated
or the Portal hiccups.
Reads the shipped manifest, validates it against the schema, and writes it
to ``~/.hermes/cache/model_catalog.json`` via the same atomic writer the
network path uses. Returns ``True`` on success, ``False`` if the file is
missing, malformed, or fails validation (caller should treat a ``False``
as non-fatal the network fetch path still applies on the next picker
open).
"""
src = Path(project_root) / "website" / "static" / "api" / "model-catalog.json"
try:
with open(src, encoding="utf-8") as fh:
data = json.load(fh)
except (OSError, json.JSONDecodeError) as exc:
logger.debug("model catalog seed from checkout skipped (%s): %s", src, exc)
return False
if not _validate_manifest(data):
logger.debug("model catalog seed from checkout skipped: invalid manifest at %s", src)
return False
_write_disk_cache(data)
reset_cache() # drop the in-process copy so the next read picks up the seed
return True
def reset_cache() -> None:
"""Clear the in-process cache. Used by tests and ``hermes model --refresh``."""
global _catalog_cache, _catalog_cache_source_mtime
+6 -102
View File
@@ -33,7 +33,6 @@ COPILOT_REASONING_EFFORTS_O_SERIES = ["low", "medium", "high"]
# (model_id, display description shown in menus)
OPENROUTER_MODELS: list[tuple[str, str]] = [
# Anthropic
("anthropic/claude-fable-5", ""),
("anthropic/claude-opus-4.8", ""),
("anthropic/claude-opus-4.8-fast", "2x price, higher output speed"),
("anthropic/claude-sonnet-4.6", ""),
@@ -74,10 +73,8 @@ OPENROUTER_MODELS: list[tuple[str, str]] = [
# Free tier
("openrouter/elephant-alpha", "free"),
("openrouter/owl-alpha", "free"),
("poolside/laguna-m.1:free", "free"),
("tencent/hy3-preview:free", "free"),
("nvidia/nemotron-3-super-120b-a12b:free", "free"),
("nvidia/nemotron-3-ultra-550b-a55b:free", "free"),
("inclusionai/ring-2.6-1t:free", "free"),
]
@@ -155,7 +152,6 @@ def _xai_curated_models() -> list[str]:
_PROVIDER_MODELS: dict[str, list[str]] = {
"nous": [
# Anthropic
"anthropic/claude-fable-5",
"anthropic/claude-opus-4.8",
"anthropic/claude-sonnet-4.6",
"anthropic/claude-haiku-4.5",
@@ -326,7 +322,6 @@ _PROVIDER_MODELS: dict[str, list[str]] = {
"MiniMax-M2",
],
"anthropic": [
"claude-fable-5",
"claude-opus-4-8",
"claude-opus-4-7",
"claude-opus-4-6",
@@ -769,64 +764,6 @@ _NOUS_RECOMMENDED_CACHE_TTL: int = 600 # seconds (10 minutes)
_nous_recommended_cache: dict[str, tuple[dict[str, Any], float]] = {}
def _nous_recommended_disk_path() -> "Path":
"""Disk path for the persisted recommended-models cache."""
from hermes_constants import get_hermes_home
return get_hermes_home() / "cache" / "nous_recommended_cache.json"
def _read_nous_recommended_disk(base: str) -> dict[str, Any] | None:
"""Return the last-known-good payload for ``base`` from disk, or None.
The disk file is a JSON object keyed by portal base URL so staging and
prod don't collide:
``{"<base>": {"data": {...}, "ts": <epoch_seconds>}}``.
"""
try:
with open(_nous_recommended_disk_path(), encoding="utf-8") as fh:
blob = json.load(fh)
except (OSError, json.JSONDecodeError):
return None
if not isinstance(blob, dict):
return None
entry = blob.get(base)
if not isinstance(entry, dict):
return None
data = entry.get("data")
return data if isinstance(data, dict) and data else None
def _write_nous_recommended_disk(base: str, data: dict[str, Any]) -> None:
"""Persist ``data`` as the last-known-good payload for ``base``.
Merges into any existing per-base map, then writes atomically. Failures
are non-fatal (logged at debug) the in-process cache still works.
"""
if not data:
return
path = _nous_recommended_disk_path()
try:
try:
with open(path, encoding="utf-8") as fh:
blob = json.load(fh)
if not isinstance(blob, dict):
blob = {}
except (OSError, json.JSONDecodeError):
blob = {}
blob[base] = {"data": data, "ts": time.time()}
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
with open(tmp, "w", encoding="utf-8") as fh:
json.dump(blob, fh, indent=2)
fh.write("\n")
os.replace(tmp, path)
except OSError as exc:
import logging
logging.getLogger(__name__).debug(
"nous recommended-models disk cache write failed: %s", exc
)
def fetch_nous_recommended_models(
portal_base_url: str = "",
timeout: float = 5.0,
@@ -837,19 +774,12 @@ def fetch_nous_recommended_models(
Hits ``<portal>/api/nous/recommended-models``. The endpoint is public
no auth is required. Results are cached per portal URL for
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds in process; pass
``force_refresh=True`` to bypass the in-process cache.
``_NOUS_RECOMMENDED_CACHE_TTL`` seconds; pass ``force_refresh=True`` to
bypass the cache.
A successful live fetch is also persisted to a per-base disk cache
(``$HERMES_HOME/cache/nous_recommended_cache.json``) as last-known-good.
When the live fetch fails (network, parse, non-2xx) and the in-process
cache is empty, the disk copy is returned instead of ``{}`` so a
transient Portal hiccup no longer silently drops the free/paid model
recommendations from the picker. Self-heals on the next successful fetch.
Returns the parsed JSON dict, or ``{}`` only when neither the network nor
any cache layer can supply data. Callers must treat missing/null fields
as "no recommendation" and fall back to their own default.
Returns the parsed JSON dict on success, or ``{}`` on any failure
(network, parse, non-2xx). Callers must treat missing/null fields as
"no recommendation" and fall back to their own default.
"""
base = (portal_base_url or "https://portal.nousresearch.com").rstrip("/")
now = time.monotonic()
@@ -872,19 +802,6 @@ def fetch_nous_recommended_models(
except Exception:
data = {}
if data:
# Live fetch succeeded — refresh both cache layers.
_nous_recommended_cache[base] = (data, now)
_write_nous_recommended_disk(base, data)
return data
# Live fetch failed. Fall back to the last-known-good disk copy so a
# transient Portal hiccup doesn't drop the recommendations entirely.
disk = _read_nous_recommended_disk(base)
if disk:
_nous_recommended_cache[base] = (disk, now)
return disk
_nous_recommended_cache[base] = (data, now)
return data
@@ -2220,20 +2137,7 @@ def provider_model_ids(provider: Optional[str], *, force_refresh: bool = False)
if normalized == "anthropic":
live = _fetch_anthropic_models()
if live:
# The live /v1/models dump lags newly-routed curated aliases
# (e.g. claude-fable-5, which is reachable on Anthropic before it
# is enumerated by the models endpoint). Surface curated entries
# first, then append any live-only models, so a fresh curated
# model never disappears just because the API hasn't listed it yet.
curated = list(_PROVIDER_MODELS.get("anthropic", []))
merged = list(curated)
merged_lower = {m.lower() for m in curated}
for m in live:
if m.lower() not in merged_lower:
merged.append(m)
merged_lower.add(m.lower())
return merged
return list(_PROVIDER_MODELS.get("anthropic", []))
return live
if normalized == "ollama-cloud":
live = fetch_ollama_cloud_models(force_refresh=force_refresh)
if live:
+17 -83
View File
@@ -135,89 +135,34 @@ def _sanitize_plugin_name(
return target
def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
"""Turn an identifier into a cloneable Git URL and optional subdirectory.
Returns ``(git_url, subdir)`` where ``subdir`` is the path within the
cloned repository that contains the plugin (``None`` when the plugin lives
at the repo root).
def _resolve_git_url(identifier: str) -> str:
"""Turn an identifier into a cloneable Git URL.
Accepted formats:
- Full URL: https://github.com/owner/repo.git
- Full URL: git@github.com:owner/repo.git
- Full URL: ssh://git@github.com/owner/repo.git
- Shorthand: owner/repo https://github.com/owner/repo.git
- Shorthand w/ subdir: owner/repo/path/to/plugin
(https://github.com/owner/repo.git, "path/to/plugin")
- Full URL w/ subdir (``.git`` boundary):
https://github.com/owner/repo.git/path/to/plugin
(https://github.com/owner/repo.git, "path/to/plugin")
- Any URL w/ explicit subdir fragment (works for every scheme, incl.
``file://`` and ssh): <url>#path/to/plugin
(<url>, "path/to/plugin")
NOTE: ``http://`` and ``file://`` schemes are accepted but will trigger a
security warning at install time.
"""
# Already a URL.
# Already a URL
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
# Explicit ``#subdir`` fragment — unambiguous for any scheme.
if "#" in identifier:
git_url, _, frag = identifier.partition("#")
return git_url, (frag.strip("/") or None)
# Natural ``.git/`` boundary (GitHub-style URLs).
marker = ".git/"
idx = identifier.find(marker)
if idx != -1:
git_url = identifier[: idx + len(".git")]
subdir = identifier[idx + len(marker) :].strip("/")
return git_url, (subdir or None)
return identifier, None
return identifier
# owner/repo[/subdir...] shorthand
parts = [p for p in identifier.strip("/").split("/") if p]
if len(parts) >= 2:
owner, repo = parts[0], parts[1]
subdir = "/".join(parts[2:]).strip("/")
git_url = f"https://github.com/{owner}/{repo}.git"
return git_url, (subdir or None)
# owner/repo shorthand
parts = identifier.strip("/").split("/")
if len(parts) == 2:
owner, repo = parts
return f"https://github.com/{owner}/{repo}.git"
raise ValueError(
f"Invalid plugin identifier: '{identifier}'. "
"Use a Git URL or 'owner/repo' shorthand (optionally with a subdirectory: "
"'owner/repo/path/to/plugin')."
"Use a Git URL or owner/repo shorthand."
)
def _resolve_subdir_within(clone_root: Path, subdir: str) -> Path:
"""Resolve ``subdir`` inside ``clone_root``, rejecting path traversal.
Guards against ``..`` segments, absolute paths, and symlinks that would
escape the cloned repository. Returns the resolved directory path.
Raises ``PluginOperationError`` if the path escapes the clone, doesn't
exist, or is not a directory.
"""
clone_root = clone_root.resolve()
candidate = (clone_root / subdir).resolve()
# The resolved candidate must stay within the clone root.
if candidate != clone_root and clone_root not in candidate.parents:
raise PluginOperationError(
f"Plugin subdirectory '{subdir}' escapes the repository.",
)
if not candidate.exists():
raise PluginOperationError(
f"Plugin subdirectory '{subdir}' does not exist in the repository.",
)
if not candidate.is_dir():
raise PluginOperationError(
f"Plugin subdirectory '{subdir}' is not a directory.",
)
return candidate
def _repo_name_from_url(url: str) -> str:
"""Extract the repo name from a Git URL for the plugin directory name."""
# Strip trailing .git and slashes
@@ -427,14 +372,14 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
import tempfile
try:
git_url, subdir = _resolve_git_url(identifier)
git_url = _resolve_git_url(identifier)
except ValueError as e:
raise PluginOperationError(str(e)) from e
plugins_dir = _plugins_dir()
with tempfile.TemporaryDirectory() as tmp:
tmp_clone = Path(tmp) / "plugin"
tmp_target = Path(tmp) / "plugin"
git_exe = _resolve_git_executable()
if not git_exe:
@@ -442,7 +387,7 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
try:
result = subprocess.run(
[git_exe, "clone", "--depth", "1", git_url, str(tmp_clone)],
[git_exe, "clone", "--depth", "1", git_url, str(tmp_target)],
capture_output=True,
text=True,
timeout=60,
@@ -460,16 +405,8 @@ def _install_plugin_core(identifier: str, *, force: bool) -> tuple[Path, dict, s
err = (result.stderr or result.stdout or "").strip()
raise PluginOperationError(f"Git clone failed:\n{err}")
# Resolve the directory within the clone that holds the plugin.
if subdir:
tmp_target = _resolve_subdir_within(tmp_clone, subdir)
else:
tmp_target = tmp_clone
manifest = _read_manifest(tmp_target)
plugin_name = manifest.get("name") or (
subdir.rstrip("/").rsplit("/", 1)[-1] if subdir else _repo_name_from_url(git_url)
)
plugin_name = manifest.get("name") or _repo_name_from_url(git_url)
try:
target = _sanitize_plugin_name(plugin_name, plugins_dir)
@@ -534,7 +471,7 @@ def cmd_install(
console = Console()
try:
git_url, _subdir = _resolve_git_url(identifier)
git_url = _resolve_git_url(identifier)
except ValueError as e:
console.print(f"[red]Error:[/red] {e}")
sys.exit(1)
@@ -545,10 +482,7 @@ def cmd_install(
"Consider using https:// or git@ for production installs.",
)
if _subdir:
console.print(f"[dim]Cloning {git_url} (subdir: {_subdir})...[/dim]")
else:
console.print(f"[dim]Cloning {git_url}...[/dim]")
console.print(f"[dim]Cloning {git_url}...[/dim]")
try:
target, installed_manifest, installed_name = _install_plugin_core(
@@ -1539,7 +1473,7 @@ def dashboard_install_plugin(
"""Non-interactive install for the web dashboard. Returns a JSON-serializable dict."""
warnings: list[str] = []
try:
git_url, _subdir = _resolve_git_url(identifier)
git_url = _resolve_git_url(identifier)
if git_url.startswith(("http://", "file://")):
warnings.append(
"Insecure URL scheme; prefer https:// or git@ for production installs.",
-5
View File
@@ -1010,7 +1010,6 @@ def delete_profile(name: str, yes: bool = False) -> Path:
print(f"✓ Removed {wrapper_path}")
# 4. Remove profile directory
remove_error: Exception | None = None
try:
def _make_writable(func, path, exc):
"""onexc/onerror handler: add +w on PermissionError so rmtree can proceed.
@@ -1057,7 +1056,6 @@ def delete_profile(name: str, yes: bool = False) -> Path:
print(f"✓ Removed {profile_dir}")
except Exception as e:
print(f"⚠ Could not remove {profile_dir}: {e}")
remove_error = e
# 5. Clear active_profile if it pointed to this profile
try:
@@ -1068,9 +1066,6 @@ def delete_profile(name: str, yes: bool = False) -> Path:
except Exception:
pass
if remove_error is not None:
raise RuntimeError(f"Could not remove profile directory {profile_dir}: {remove_error}") from remove_error
print(f"\nProfile '{canon}' deleted.")
return profile_dir
-5
View File
@@ -8573,11 +8573,6 @@ def _resolve_chat_argv(
argv, cwd = _make_tui_argv(PROJECT_ROOT / "ui-tui", tui_dev=False)
env = os.environ.copy()
try:
from hermes_cli.config import apply_terminal_config_to_env
apply_terminal_config_to_env(env=env)
except Exception:
_log.debug("Failed to apply terminal config bridge for dashboard chat", exc_info=True)
env.setdefault("NODE_ENV", "production")
# Browser-embedded chat should prefer stable wheel-based scrollback over
# native terminal mouse tracking. When mouse tracking is enabled, wheel
+3 -1
View File
@@ -152,12 +152,14 @@ in
fi
# Check if lockfile changed (either from the npm i above or from an
# external edit). Runs npm ci if so.
# external edit). Runs npm ci + fix-lockfiles if so.
LOCK_STAMP="$STAMP_DIR/root-lockfile"
LOCK_STAMP_VALUE=$(sha256sum "$REPO_ROOT/package-lock.json" 2>/dev/null | awk '{print $1}')
if [ ! -f "$LOCK_STAMP" ] || [ "$(cat "$LOCK_STAMP")" != "$LOCK_STAMP_VALUE" ]; then
echo "npm: package-lock.json changed, running npm ci..."
( cd "$REPO_ROOT" && CI=true ${pkgs.lib.getExe' nodejs "npm"} ci --silent --no-fund --no-audit 2>/dev/null )
echo "npm: updating nix hash..."
${fixLockfilesExe} || echo "npm: warning: fix-lockfiles failed, run it manually" >&2
mkdir -p "$STAMP_DIR"
echo "$LOCK_STAMP_VALUE" > "$LOCK_STAMP"
fi
-4
View File
@@ -86,7 +86,6 @@ class AudioBridge:
["pactl", "unload-module", str(mod_id)],
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
)
except Exception:
# Best-effort teardown — never raise from here.
@@ -112,7 +111,6 @@ class AudioBridge:
check=True,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
except FileNotFoundError as exc:
raise RuntimeError(
@@ -137,7 +135,6 @@ class AudioBridge:
check=True,
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
except subprocess.CalledProcessError as exc:
# Roll back the null-sink we just created so we don't leak it.
@@ -145,7 +142,6 @@ class AudioBridge:
["pactl", "unload-module", str(sink_mod_id)],
check=False,
capture_output=True,
stdin=subprocess.DEVNULL,
)
raise RuntimeError(
f"pactl load-module virtual-source failed: {exc.stderr or exc}"
-1
View File
@@ -94,7 +94,6 @@ def _run_brv(args: List[str], timeout: int = _QUERY_TIMEOUT,
result = subprocess.run(
cmd, capture_output=True, text=True,
timeout=timeout, cwd=effective_cwd, env=env,
stdin=subprocess.DEVNULL,
)
stdout = result.stdout.strip()
stderr = result.stderr.strip()
-2
View File
@@ -695,7 +695,6 @@ class HindsightMemoryProvider(MemoryProvider):
subprocess.run(
[uv_path, "pip", "install", "--python", sys.executable, "--quiet", "--upgrade"] + deps_to_install,
check=True, timeout=120, capture_output=True,
stdin=subprocess.DEVNULL,
)
print(" ✓ Dependencies up to date")
except Exception as e:
@@ -1102,7 +1101,6 @@ class HindsightMemoryProvider(MemoryProvider):
[uv_path, "pip", "install", "--python", sys.executable,
"--quiet", "--upgrade", f"hindsight-client>={_MIN_CLIENT_VERSION}"],
check=True, timeout=120, capture_output=True,
stdin=subprocess.DEVNULL,
)
logger.info("hindsight-client upgraded to >=%s", _MIN_CLIENT_VERSION)
except Exception as e:
-1
View File
@@ -416,7 +416,6 @@ def _ensure_sdk_installed() -> bool:
[sys.executable, "-m", "pip", "install", "honcho-ai>=2.0.1"],
capture_output=True,
text=True,
stdin=subprocess.DEVNULL,
)
if result.returncode == 0:
print(" Installed.\n")
-1
View File
@@ -628,7 +628,6 @@ class HonchoClientConfig:
root = subprocess.run(
["git", "rev-parse", "--show-toplevel"],
capture_output=True, text=True, cwd=cwd, timeout=5,
stdin=subprocess.DEVNULL,
)
if root.returncode == 0:
return Path(root.stdout.strip()).name
+1 -51
View File
@@ -10,39 +10,6 @@ logger = logging.getLogger(__name__)
_CACHE: list[str] | None = None
# Anthropic model families that still accept an explicit "disable thinking"
# request (the manual ``thinking: {type: "disabled"}`` form OpenRouter emits
# for ``reasoning: {enabled: false}``). Everything Claude 4.6 and newer —
# including future date-stamped / named models (fable, mythos-class, …) —
# mandates reasoning and returns HTTP 400 on any disable form. We therefore
# default *unknown* Anthropic models to "cannot disable" (the modern contract)
# and keep only this explicit legacy allowlist of models that can. Mirrors the
# default-to-newest philosophy in agent/anthropic_adapter._get_anthropic_max_output.
_ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS = (
"claude-3", # 3, 3.5, 3.7
"claude-opus-4-0", "claude-opus-4.0", "claude-opus-4-1", "claude-opus-4.1",
"claude-sonnet-4-0", "claude-sonnet-4.0",
"claude-opus-4-2025", "claude-sonnet-4-2025", # date-stamped 4.0 IDs
"claude-opus-4-5", "claude-opus-4.5",
"claude-sonnet-4-5", "claude-sonnet-4.5",
"claude-haiku-4-5", "claude-haiku-4.5",
)
def _anthropic_reasoning_is_mandatory(model: str | None) -> bool:
"""Return True for Anthropic models that reject any disable-thinking form.
Claude 4.6+ (adaptive thinking) and newer named models have no "off"
switch sending ``reasoning: {enabled: false}`` makes OpenRouter emit
``thinking: {type: "disabled"}``, which these models 400 on. Unknown /
new Anthropic model names default to mandatory so the next un-numbered
release doesn't reintroduce the 400.
"""
m = (model or "").lower()
if not m.startswith(("anthropic/", "claude")) and "claude" not in m:
return False
return not any(sub in m for sub in _ANTHROPIC_REASONING_OPTIONAL_SUBSTRINGS)
class OpenRouterProfile(ProviderProfile):
"""OpenRouter aggregator — provider preferences, reasoning config passthrough."""
@@ -117,24 +84,7 @@ class OpenRouterProfile(ProviderProfile):
"""
extra_body: dict[str, Any] = {}
if supports_reasoning:
# Reasoning-mandatory Anthropic models (Claude 4.6+ / fable /
# future named models) use *adaptive* thinking: the model decides
# how much to think, and OpenRouter ignores ``reasoning.effort`` for
# them entirely. Sending any ``reasoning`` field is therefore both
# pointless and actively harmful:
# - ``{enabled: false}`` → OpenRouter emits Anthropic's manual
# ``thinking: {type: "disabled"}``, which these models 400 on.
# - any enabled form, on a tool-continuation turn whose prior
# assistant tool_call carries no thinking block (chat_completions
# never replays signed thinking blocks), ALSO makes OpenRouter
# emit ``thinking: {type: "disabled"}`` → the same 400 on every
# turn after the first tool call.
# 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.
if _anthropic_reasoning_is_mandatory(model):
pass # omit reasoning entirely → adaptive default
elif reasoning_config is not None:
if reasoning_config is not None:
extra_body["reasoning"] = dict(reasoning_config)
else:
extra_body["reasoning"] = {"enabled": True, "effort": "medium"}
+57 -116
View File
@@ -9,7 +9,6 @@ import logging
import os
import threading
import tomllib
from collections.abc import Callable
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any, Optional
@@ -285,43 +284,6 @@ class _Runtime:
and callable(getattr(getattr(self.nemo_relay, "tools", None), "execute", None))
)
def _run_managed_with_downstream_preservation(
self,
next_call: Callable[[Any], Any],
normalize_payload: Callable[[Any], Any],
shape_response: Callable[[Any], Any],
make_managed_execute: Callable[[Callable[[Any], Any]], Any],
) -> Any:
# NeMo Relay's native managed execution may wrap a failing callback as an
# internal runtime error, hiding the real downstream provider/tool
# exception. Capture the original here and re-raise it after managed
# execution so Hermes retry classification still sees it. The LLM and tool
# paths share this scaffolding; they differ only in payload normalization,
# response shaping, and the Relay call itself.
raw_response: dict[str, Any] = {"set": False, "value": None}
callback_error: Exception | None = None
downstream_error: BaseException | None = None
def _impl(next_payload: Any) -> Any:
nonlocal callback_error, downstream_error
try:
raw = next_call(normalize_payload(next_payload))
except Exception as exc:
callback_error = exc
downstream_error = _original_downstream_error(exc)
raise
raw_response["set"] = True
raw_response["value"] = raw
return shape_response(raw)
try:
managed_result = _resolve_awaitable(make_managed_execute(_impl))
except Exception as exc:
if downstream_error is not None and _is_relay_wrapped_callback_error(exc, callback_error):
raise downstream_error
raise
return raw_response["value"] if raw_response["set"] else managed_result
def execute_llm(self, kwargs: dict[str, Any]) -> Any:
state = self.ensure_session(kwargs)
request_body = _jsonable(kwargs.get("request") or {})
@@ -330,37 +292,38 @@ class _Runtime:
if not callable(next_call):
return request_body
def _normalize(next_request: Any) -> Any:
raw_response: dict[str, Any] = {"set": False, "value": None}
def _impl(next_request: Any) -> Any:
next_body = getattr(next_request, "content", next_request)
return next_body if isinstance(next_body, dict) else request_body
raw = next_call(next_body if isinstance(next_body, dict) else request_body)
raw_response["set"] = True
raw_response["value"] = raw
return _llm_response_payload(raw)
def _make_managed(impl: Callable[[Any], Any]) -> Any:
async def _managed_execute() -> Any:
result = self.nemo_relay.llm.execute(
str(kwargs.get("provider") or "llm"),
request,
impl,
handle=state.handle,
data=_jsonable(
{
"turn_id": kwargs.get("turn_id"),
"api_request_id": kwargs.get("api_request_id"),
"api_call_count": kwargs.get("api_call_count"),
"mode": self.settings.adaptive_mode,
}
),
metadata=_metadata(kwargs),
model_name=str(kwargs.get("model") or ""),
)
if inspect.isawaitable(result):
return await result
return result
async def _managed_execute() -> Any:
result = self.nemo_relay.llm.execute(
str(kwargs.get("provider") or "llm"),
request,
_impl,
handle=state.handle,
data=_jsonable(
{
"turn_id": kwargs.get("turn_id"),
"api_request_id": kwargs.get("api_request_id"),
"api_call_count": kwargs.get("api_call_count"),
"mode": self.settings.adaptive_mode,
}
),
metadata=_metadata(kwargs),
model_name=str(kwargs.get("model") or ""),
)
if inspect.isawaitable(result):
return await result
return result
return _managed_execute()
return self._run_managed_with_downstream_preservation(
next_call, _normalize, _llm_response_payload, _make_managed
)
managed_result = _resolve_awaitable(_managed_execute())
return raw_response["value"] if raw_response["set"] else managed_result
def execute_tool(self, kwargs: dict[str, Any]) -> Any:
state = self.ensure_session(kwargs)
@@ -370,35 +333,37 @@ class _Runtime:
if not callable(next_call):
return args
def _normalize(next_args: Any) -> Any:
return next_args if isinstance(next_args, dict) else args
raw_response: dict[str, Any] = {"set": False, "value": None}
def _make_managed(impl: Callable[[Any], Any]) -> Any:
async def _managed_execute() -> Any:
result = self.nemo_relay.tools.execute(
tool_name,
args,
impl,
handle=state.handle,
data=_jsonable(
{
"turn_id": kwargs.get("turn_id"),
"api_request_id": kwargs.get("api_request_id"),
"tool_call_id": kwargs.get("tool_call_id"),
"mode": self.settings.adaptive_mode,
}
),
metadata=_metadata(kwargs),
)
if inspect.isawaitable(result):
return await result
return result
def _impl(next_args: Any) -> Any:
effective_args = next_args if isinstance(next_args, dict) else args
raw = next_call(effective_args)
raw_response["set"] = True
raw_response["value"] = raw
return _jsonable(raw)
return _managed_execute()
async def _managed_execute() -> Any:
result = self.nemo_relay.tools.execute(
tool_name,
args,
_impl,
handle=state.handle,
data=_jsonable(
{
"turn_id": kwargs.get("turn_id"),
"api_request_id": kwargs.get("api_request_id"),
"tool_call_id": kwargs.get("tool_call_id"),
"mode": self.settings.adaptive_mode,
}
),
metadata=_metadata(kwargs),
)
if inspect.isawaitable(result):
return await result
return result
return self._run_managed_with_downstream_preservation(
next_call, _normalize, _jsonable, _make_managed
)
managed_result = _resolve_awaitable(_managed_execute())
return raw_response["value"] if raw_response["set"] else managed_result
def register(ctx) -> None:
@@ -841,30 +806,6 @@ def _value(obj: Any, key: str, default: Any = None) -> Any:
return getattr(obj, key, default)
def _original_downstream_error(exc: Exception) -> BaseException:
# Hermes wraps downstream execution failures in a local/private exception
# class, so detect the wrapper by shape instead of importing it here.
original = getattr(exc, "original", None)
if exc.__class__.__name__ == "_DownstreamExecutionError" and isinstance(original, BaseException):
return original
return exc
def _is_relay_wrapped_callback_error(exc: Exception, callback_error: Exception | None) -> bool:
# NeMo Relay re-wraps a failing callback as ``RuntimeError("internal error:
# <ClassName>: <message>")``. Match by prefix rather than exact equality so a
# trailing traceback/suffix in a future Relay version doesn't silently defeat
# the unwrap; the class-name + message prefix still discriminates the real
# downstream failure from unrelated Relay-internal errors. If Relay drops the
# leading ``internal error:`` shape entirely, this returns False and Hermes
# falls back to surfacing Relay's error (the pre-fix behavior) rather than
# masking it.
if callback_error is None or not isinstance(exc, RuntimeError):
return False
expected = f"internal error: {callback_error.__class__.__name__}: {callback_error}"
return str(exc).startswith(expected)
def _llm_response_payload(response: Any) -> Any:
"""Return the LLM response shape NeMo Relay's ATIF conversion expects."""
payload = _jsonable(response)
-1
View File
@@ -520,7 +520,6 @@ class VoiceReceiver:
],
check=True,
timeout=10,
stdin=subprocess.DEVNULL,
)
finally:
try:
-1
View File
@@ -320,7 +320,6 @@ def decode_to_pcm(path: str, *, timeout: float = 30.0) -> Optional[bytes]:
],
capture_output=True,
timeout=timeout,
stdin=subprocess.DEVNULL,
)
except (subprocess.TimeoutExpired, FileNotFoundError, OSError) as e:
logger.warning("decode_to_pcm failed for %s: %s", path, e)
+7 -8
View File
@@ -111,7 +111,6 @@ All env vars are documented in `plugin.yaml`. The most important:
| `PHOTON_SIDECAR_PORT` | 8789 | Loopback port for the sidecar |
| `PHOTON_SIDECAR_AUTOSTART`| true | Spawn the sidecar on connect |
| `PHOTON_DASHBOARD_HOST` | https://app.photon.codes | Dashboard API host |
| `PHOTON_SPECTRUM_HOST` | https://spectrum.photon.codes | Spectrum API host |
| `PHOTON_HOME_CHANNEL` | your number (set by setup) | Default space for cron delivery — a space id, or a bare E.164 number (resolved to a DM) |
| `PHOTON_ALLOWED_USERS` | your number (set by setup) | Comma-separated E.164 allowlist |
| `PHOTON_REQUIRE_MENTION` | false | Gate group chats on a wake word |
@@ -119,14 +118,14 @@ All env vars are documented in `plugin.yaml`. The most important:
## Attachments & limitations
- **Inbound attachments and voice notes are downloaded.** The sidecar reads
the bytes (`content.read()`) and base64-inlines them on the NDJSON event; the
adapter caches them to the shared media cache and populates `media_urls` /
`media_types`, so the agent sees the real image/file or can transcribe the
voice note — parity with the BlueBubbles iMessage channel. Media larger than
- **Inbound attachments are downloaded.** The sidecar reads the bytes
(`content.read()`) and base64-inlines them on the NDJSON event; the adapter
caches them to the shared media cache and populates `media_urls` /
`media_types`, so the agent sees the real image/file (vision included) —
parity with the BlueBubbles iMessage channel. Attachments larger than
`PHOTON_MAX_INLINE_ATTACHMENT_BYTES` (default 20 MB), or any byte read that
fails, falls back to a text marker (`[Photon attachment received: …]` or
`[Photon voice received: …]`) so the agent still knows something arrived.
fails, fall back to a text marker (`[Photon attachment received: …]`) so the
agent still knows something arrived.
- **Outbound attachments are supported.** Images, voice notes, video, and
documents are sent via `space.send(attachment(...))` /
`space.send(voice(...))` through the sidecar's `/send-attachment`
+24 -121
View File
@@ -60,7 +60,6 @@ from gateway.platforms.base import (
MessageType,
SendResult,
)
from gateway.platforms.helpers import strip_markdown
from .auth import load_project_credentials
@@ -414,19 +413,14 @@ class PhotonAdapter(BasePlatformAdapter):
def _is_duplicate(self, msg_id: str) -> bool:
now = time.time()
seen = self._seen_messages
t = seen.get(msg_id)
if t is not None and now - t < _DEDUP_WINDOW_SECONDS:
return True # seen, unexpired
# New or expired: record and enforce a HARD size bound (evict oldest,
# insertion-order) so a burst of unique ids within the window can't grow
# the dict without limit — not just the expired-only prune.
if msg_id in seen:
del seen[msg_id] # refresh insertion order
seen[msg_id] = now
if len(seen) > _DEDUP_MAX_SIZE:
for old in list(seen.keys())[: len(seen) - _DEDUP_MAX_SIZE]:
del seen[old]
if len(self._seen_messages) > _DEDUP_MAX_SIZE:
cutoff = now - _DEDUP_WINDOW_SECONDS
self._seen_messages = {
k: v for k, v in self._seen_messages.items() if v > cutoff
}
if msg_id in self._seen_messages:
return True
self._seen_messages[msg_id] = now
return False
async def _dispatch_inbound(self, event: Dict[str, Any]) -> None:
@@ -440,15 +434,13 @@ class PhotonAdapter(BasePlatformAdapter):
"space": {"id": "...", "type": "dm"|"group", "phone": "+E164"},
"sender": {"id": "+E164"},
"content": {"type": "text", "text": "..."}
| {"type": "attachment"|"voice", "id", "name",
"mimeType", "size", "duration"?, "data"?,
"encoding"?},
| {"type": "attachment", "id", "name", "mimeType",
"size", "data"?, "encoding"?},
"timestamp": "2026-05-14T19:06:32.000Z"
Attachment and voice content carry the bytes inline as base64 ``data``
(with ``encoding == "base64"``) when the sidecar could read them
within its size cap; otherwise only metadata is present and we surface
a marker.
Attachment content carries the bytes inline as base64 ``data`` (with
``encoding == "base64"``) when the sidecar could read them within its
size cap; otherwise only metadata is present and we surface a marker.
}
"""
space = event.get("space") or {}
@@ -483,38 +475,23 @@ class PhotonAdapter(BasePlatformAdapter):
if ctype == "text":
text = content.get("text") or ""
mtype = MessageType.TEXT
elif ctype in {"attachment", "voice"}:
is_voice = ctype == "voice"
name = content.get("name") or ("voice" if is_voice else "(unnamed)")
elif ctype == "attachment":
name = content.get("name") or "(unnamed)"
mime = content.get("mimeType") or ""
mtype = MessageType.VOICE if is_voice else _attachment_message_type(mime)
cached = _cache_inbound_attachment(
content, name, mime, force_audio=is_voice
)
mtype = _attachment_message_type(mime)
cached = _cache_inbound_attachment(content, name, mime)
if cached:
media_urls.append(cached)
media_types.append(
mime or ("audio/mp4" if is_voice else "application/octet-stream")
)
media_types.append(mime or "application/octet-stream")
# The real bytes are attached, so the agent sees the media
# itself — a short marker is enough text, and it keeps group
# mention-gating consistent with plain messages.
text = "(voice)" if is_voice else "(attachment)"
text = "(attachment)"
else:
# No bytes (over the sidecar cap, a failed read, or a caching
# failure) — fall back to a metadata marker so the agent still
# knows something arrived.
label = "voice" if is_voice else "attachment"
duration = content.get("duration")
duration_text = (
f", duration: {duration}s"
if isinstance(duration, (int, float))
else ""
)
text = (
f"[Photon {label} received: {name} "
f"({mime or 'unknown MIME'}{duration_text})]"
)
text = f"[Photon attachment received: {name} ({mime})]"
else:
text = f"[Photon content type not handled: {ctype}]"
mtype = MessageType.TEXT
@@ -663,7 +640,7 @@ class PhotonAdapter(BasePlatformAdapter):
reply_to: Optional[str] = None,
metadata: Optional[Dict[str, Any]] = None,
) -> SendResult:
return await self._sidecar_send(chat_id, self.format_message(content))
return await self._sidecar_send(chat_id, content)
# -- Outbound media (parity with the BlueBubbles iMessage channel) -----
#
@@ -782,74 +759,6 @@ class PhotonAdapter(BasePlatformAdapter):
"""
return {"name": chat_id, "type": "dm", "id": chat_id}
def format_message(self, content: str) -> str:
return strip_markdown(content)
async def _send_with_retry(
self,
chat_id: str,
content: str,
reply_to: Optional[str] = None,
metadata: Any = None,
max_retries: int = 2,
base_delay: float = 2.0,
) -> SendResult:
"""Photon/iMessage is plain text, so never show the generic Markdown banner."""
text = self.format_message(content)
result = await self.send(
chat_id=chat_id,
content=text,
reply_to=reply_to,
metadata=metadata,
)
if result.success:
return result
error_str = result.error or ""
is_network = result.retryable or self._is_retryable_error(error_str)
if not is_network and self._is_timeout_error(error_str):
return result
if is_network:
for attempt in range(1, max_retries + 1):
delay = base_delay * (2 ** (attempt - 1))
logger.warning(
"[photon] Send failed (attempt %d/%d, retrying in %.1fs): %s",
attempt, max_retries, delay, error_str,
)
await asyncio.sleep(delay)
result = await self.send(
chat_id=chat_id,
content=text,
reply_to=reply_to,
metadata=metadata,
)
if result.success:
return result
error_str = result.error or ""
if not (result.retryable or self._is_retryable_error(error_str)):
break
else:
logger.error(
"[photon] Failed to deliver response after %d retries: %s",
max_retries, error_str,
)
return result
logger.warning(
"[photon] Send failed: %s - retrying plain-text message",
error_str,
)
fallback_result = await self.send(
chat_id=chat_id,
content=text[: self.MAX_MESSAGE_LENGTH],
reply_to=reply_to,
metadata=metadata,
)
if not fallback_result.success:
logger.error("[photon] Plain-text retry also failed: %s", fallback_result.error)
return fallback_result
async def _sidecar_send(self, space_id: str, text: str) -> SendResult:
if len(text) > self.MAX_MESSAGE_LENGTH:
logger.warning(
@@ -972,11 +881,7 @@ _AUDIO_EXT_BY_MIME = {
def _cache_inbound_attachment(
content: Dict[str, Any],
name: str,
mime: str,
*,
force_audio: bool = False,
content: Dict[str, Any], name: str, mime: str
) -> Optional[str]:
"""Decode a base64-inlined inbound attachment and cache it locally.
@@ -1014,10 +919,8 @@ def _cache_inbound_attachment(
# Bytes don't look like a supported image (e.g. HEIC magic) —
# still deliver them as a document rather than dropping them.
return cache_document_from_bytes(raw, name)
if force_audio or mime.startswith("audio/"):
ext = suffix or _AUDIO_EXT_BY_MIME.get(
mime, ".m4a" if force_audio else ".mp3"
)
if mime.startswith("audio/"):
ext = suffix or _AUDIO_EXT_BY_MIME.get(mime, ".mp3")
return cache_audio_from_bytes(raw, ext)
# Video, application/*, and everything else → document cache.
return cache_document_from_bytes(raw, name)
+20 -213
View File
@@ -27,9 +27,8 @@ Credential storage mirrors every other Hermes channel:
* runtime SDK creds -> ``~/.hermes/.env`` (``PHOTON_PROJECT_ID`` =
spectrumProjectId, ``PHOTON_PROJECT_SECRET``) via ``save_env_value``
* management metadata -> ``~/.hermes/auth.json`` under
``credential_pool.photon`` (device token),
``credential_pool.photon_project`` (dashboard id, spectrum id, name), and
``credential_pool.photon_user`` (operator number + assigned text line)
``credential_pool.photon`` (device token) and
``credential_pool.photon_project`` (dashboard id, spectrum id, name)
Reference: https://github.com/photon-hq/cli and
https://photon.codes/docs/api-reference/device-login/request-device-+-user-code
@@ -41,7 +40,6 @@ import logging
import os
import re
import time
from base64 import b64encode
from dataclasses import dataclass
from pathlib import Path
from typing import Any, Callable, Dict, List, Optional, Tuple
@@ -69,7 +67,6 @@ DEFAULT_CLIENT_ID = "photon-cli"
DEFAULT_SCOPE = "openid profile email"
DEFAULT_DASHBOARD_HOST = "https://app.photon.codes"
DEFAULT_SPECTRUM_HOST = "https://spectrum.photon.codes"
# Default name of the project Hermes provisions for the operator.
DEFAULT_PROJECT_NAME = "Hermes Agent"
@@ -208,30 +205,6 @@ def store_project_credentials(
_persist_runtime_env(spectrum_project_id, project_secret)
def store_user_numbers(
*,
phone_number: Optional[str] = None,
assigned_phone_number: Optional[str] = None,
user_id: Optional[str] = None,
dashboard_project_id: Optional[str] = None,
) -> None:
"""Persist non-secret Photon user numbers for offline ``status`` output."""
if not phone_number and not assigned_phone_number:
return
auth = _load_auth()
record: Dict[str, Any] = {"issued_at": int(time.time())}
if phone_number:
record["phone_number"] = phone_number
if assigned_phone_number:
record["assigned_phone_number"] = assigned_phone_number
if user_id:
record["user_id"] = user_id
if dashboard_project_id:
record["dashboard_project_id"] = dashboard_project_id
auth.setdefault("credential_pool", {})["photon_user"] = [record]
_save_auth(auth)
def _persist_runtime_env(spectrum_project_id: str, project_secret: str) -> None:
"""Write the SDK creds to ``~/.hermes/.env`` (canonical runtime store).
@@ -275,43 +248,10 @@ def _dashboard_host() -> str:
return (os.getenv("PHOTON_DASHBOARD_HOST") or DEFAULT_DASHBOARD_HOST).rstrip("/")
def _spectrum_host() -> str:
return (os.getenv("PHOTON_SPECTRUM_HOST") or DEFAULT_SPECTRUM_HOST).rstrip("/")
def _bearer(token: str) -> Dict[str, str]:
return {"Authorization": f"Bearer {token}"}
def _basic(project_id: str, project_secret: str) -> Dict[str, str]:
token = b64encode(f"{project_id}:{project_secret}".encode("utf-8")).decode("ascii")
return {"Authorization": f"Basic {token}"}
def _response_error_detail(resp: Any) -> str:
try:
data = resp.json()
except Exception:
data = None
if isinstance(data, dict):
for key in ("error", "message", "detail"):
val = data.get(key)
if val:
return str(val)
return json.dumps(data, sort_keys=True)[:500]
text = getattr(resp, "text", "") or ""
return text[:500] if text else "no response body"
def _raise_for_status(resp: Any, action: str) -> None:
status = getattr(resp, "status_code", 200)
if status < 400:
return
raise RuntimeError(
f"Photon {action} failed: HTTP {status}: {_response_error_detail(resp)}"
)
def request_device_code(
*, client_id: str = DEFAULT_CLIENT_ID, scope: Optional[str] = DEFAULT_SCOPE,
) -> DeviceCode:
@@ -619,11 +559,6 @@ def _unwrap_list(data: Any) -> List[Dict[str, Any]]:
inner = data.get(key)
if isinstance(inner, list):
return inner
if isinstance(inner, dict):
for nested_key in ("projects", "users", "lines", "items"):
nested = inner.get(nested_key)
if isinstance(nested, list):
return nested
return []
@@ -727,37 +662,37 @@ def regenerate_project_secret(token: str, project_id: str) -> str:
# ---------------------------------------------------------------------------
# Spectrum API: users
# Dashboard API: spectrum users
def _normalize_phone(phone: str) -> str:
"""Reduce a phone string to ``+`` and digits for dedup comparison."""
return re.sub(r"[^\d+]", "", phone or "")
def list_users(project_id: str, project_secret: str) -> List[Dict[str, Any]]:
"""GET Spectrum Cloud ``/projects/{id}/users/`` → ``SpectrumUser[]``."""
def list_users(token: str, project_id: str) -> List[Dict[str, Any]]:
"""GET ``/api/projects/{id}/spectrum/users`` → ``SpectrumUser[]``."""
if httpx is None:
raise RuntimeError("httpx is required for Photon")
url = f"{_spectrum_host()}/projects/{project_id}/users/"
resp = httpx.get(url, headers=_basic(project_id, project_secret), timeout=30.0)
_raise_for_status(resp, "list-users")
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users"
resp = httpx.get(url, headers=_bearer(token), timeout=30.0)
resp.raise_for_status()
return _unwrap_list(resp.json())
def find_user_by_phone(
project_id: str, project_secret: str, phone_number: str,
token: str, project_id: str, phone_number: str,
) -> Optional[Dict[str, Any]]:
"""Return an existing Spectrum user with the given phone number, or None."""
target = _normalize_phone(phone_number)
for user in list_users(project_id, project_secret):
for user in list_users(token, project_id):
if _normalize_phone(user.get("phoneNumber") or "") == target:
return user
return None
def create_user(
token: str,
project_id: str,
project_secret: str,
*,
phone_number: str,
first_name: Optional[str] = None,
@@ -765,42 +700,32 @@ def create_user(
email: Optional[str] = None,
send_invite: bool = False,
) -> Dict[str, Any]:
"""POST Spectrum Cloud ``/projects/{id}/users/`` and return the user."""
"""POST ``/api/projects/{id}/spectrum/users`` and return the created user."""
if httpx is None:
raise RuntimeError("httpx is required for Photon user creation")
if not E164_RE.match(phone_number):
raise ValueError(
f"phone_number must be E.164 (e.g. +15551234567); got {phone_number!r}"
)
url = f"{_spectrum_host()}/projects/{project_id}/users/"
body: Dict[str, Any] = {"type": "shared", "phoneNumber": phone_number}
if send_invite:
logger.debug("photon: send_invite is ignored by Spectrum shared-user creation")
url = f"{_dashboard_host()}/api/projects/{project_id}/spectrum/users"
body: Dict[str, Any] = {"phoneNumber": phone_number, "sendInvite": send_invite}
if first_name:
body["firstName"] = first_name
if last_name:
body["lastName"] = last_name
if email:
body["email"] = email
resp = httpx.post(
url,
json=body,
headers=_basic(project_id, project_secret),
timeout=30.0,
)
_raise_for_status(resp, "create-user")
resp = httpx.post(url, json=body, headers=_bearer(token), timeout=30.0)
resp.raise_for_status()
data = resp.json() or {}
if data.get("error"):
raise RuntimeError(f"Photon create-user failed: {data['error']}")
user = data.get("user") or data.get("data") or data
if isinstance(user, dict):
return user
raise RuntimeError("Photon create-user returned an unexpected response")
return data.get("user") or data
def register_user_if_absent(
token: str,
project_id: str,
project_secret: str,
*,
phone_number: str,
first_name: Optional[str] = None,
@@ -813,12 +738,11 @@ def register_user_if_absent(
same phone number already exists (the official CLI does no dedup, so we
add it here to make ``setup`` safely re-runnable).
"""
existing = find_user_by_phone(project_id, project_secret, phone_number)
existing = find_user_by_phone(token, project_id, phone_number)
if existing is not None:
return existing, False
user = create_user(
project_id,
project_secret,
token, project_id,
phone_number=phone_number,
first_name=first_name,
last_name=last_name,
@@ -842,104 +766,6 @@ def user_assigned_line(user: Optional[Dict[str, Any]]) -> Optional[str]:
return str(val) if val else None
def load_user_numbers() -> Tuple[Optional[str], Optional[str]]:
"""Return ``(operator_phone_number, assigned_phone_number)`` for status."""
auth = _load_auth()
user_entries = auth.get("credential_pool", {}).get("photon_user") or []
if isinstance(user_entries, list) and user_entries:
entry = user_entries[0] or {}
if isinstance(entry, dict):
phone = entry.get("phone_number") or entry.get("phoneNumber")
assigned = (
entry.get("assigned_phone_number")
or entry.get("assignedPhoneNumber")
)
if phone or assigned:
return (
str(phone) if phone else _configured_operator_phone(),
str(assigned) if assigned else None,
)
return _configured_operator_phone(), None
def refresh_user_numbers(
project_id: str, project_secret: str,
) -> Tuple[Optional[str], Optional[str]]:
"""Refresh cached user numbers from Photon without provisioning anything."""
phone, cached_assigned = load_user_numbers()
user: Optional[Dict[str, Any]] = None
if phone:
user = find_user_by_phone(project_id, project_secret, phone)
else:
users = list_users(project_id, project_secret)
if len(users) == 1:
user = users[0]
user_id = None
assigned: Optional[str] = cached_assigned
if user:
user_id = user.get("id")
dashboard_phone = _normalize_phone(str(user.get("phoneNumber") or ""))
if E164_RE.match(dashboard_phone):
phone = dashboard_phone
assigned = user_assigned_line(user)
dashboard_id = load_dashboard_project_id()
if not assigned:
dashboard_token = load_photon_token()
if dashboard_token and dashboard_id:
try:
line = get_imessage_line(
dashboard_token,
dashboard_id,
create_if_missing=False,
)
except Exception as e:
logger.debug(
"photon: could not refresh iMessage line for status: %s", e
)
else:
if line and line.get("phoneNumber"):
assigned = str(line["phoneNumber"])
store_user_numbers(
phone_number=phone,
assigned_phone_number=assigned,
user_id=str(user_id) if user_id else None,
dashboard_project_id=dashboard_id,
)
return phone, assigned
def _configured_operator_phone() -> Optional[str]:
"""Infer the operator's E.164 number from existing Photon env settings."""
home = _get_config_env_value("PHOTON_HOME_CHANNEL")
if home:
normalized = _normalize_phone(home)
if E164_RE.match(normalized):
return normalized
allowed = _get_config_env_value("PHOTON_ALLOWED_USERS")
if not allowed:
return None
candidates = []
for part in re.split(r"[,\s]+", allowed):
normalized = _normalize_phone(part)
if E164_RE.match(normalized):
candidates.append(normalized)
if len(candidates) == 1:
return candidates[0]
return None
def _get_config_env_value(key: str) -> Optional[str]:
try:
from hermes_cli.config import get_env_value
except Exception:
return os.getenv(key)
return get_env_value(key)
# ---------------------------------------------------------------------------
# Dashboard API: iMessage lines (the assigned number inventory)
@@ -1010,13 +836,6 @@ def print_credential_summary(emit: Any = print) -> None:
labels["spectrum_project_id"] = sid if sid else "✗ missing"
labels["dashboard_project_id"] = load_dashboard_project_id() or ""
labels["project_key"] = "✓ stored" if sec else "✗ missing"
phone, assigned = load_user_numbers()
labels["phone_number"] = (
phone if phone else "✗ missing (run `hermes photon setup --phone ...`)"
)
labels["assigned_phone_number"] = (
assigned if assigned else "✗ missing (run `hermes photon setup`)"
)
rows = [
"Photon iMessage status",
@@ -1025,8 +844,6 @@ def print_credential_summary(emit: Any = print) -> None:
" dashboard project : " + labels["dashboard_project_id"],
" spectrum project id : " + labels["spectrum_project_id"],
" project secret : " + labels["project_key"],
" my number : " + labels["phone_number"],
" assigned number : " + labels["assigned_phone_number"],
]
emit("\n".join(rows))
@@ -1047,19 +864,9 @@ def credential_summary() -> Dict[str, str]:
_sid, sec = load_project_credentials()
return "✓ stored" if sec else "✗ missing"
def _present_phone() -> str:
phone, _assigned = load_user_numbers()
return phone or "✗ missing (run `hermes photon setup --phone ...`)"
def _present_assigned_phone() -> str:
_phone, assigned = load_user_numbers()
return assigned or "✗ missing (run `hermes photon setup`)"
return {
"device_token": _present_token(),
"dashboard_project_id": load_dashboard_project_id() or "",
"spectrum_project_id": _present_spectrum_id(),
"project_key": _present_secret(),
"phone_number": _present_phone(),
"assigned_phone_number": _present_assigned_phone(),
}
+3 -35
View File
@@ -183,8 +183,6 @@ def _cmd_setup(args: argparse.Namespace) -> int:
)
)
agent_number = None
registered_phone = None
registered_user_id = None
if not phone:
print(" Skipped user registration (no phone given). Re-run with --phone later.")
else:
@@ -194,7 +192,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
email = args.email
try:
user, created = photon_auth.register_user_if_absent(
spectrum_id, secret,
token, dashboard_id,
phone_number=phone,
first_name=first_name,
last_name=args.last_name,
@@ -207,8 +205,6 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print(f" user registration failed: {e}", file=sys.stderr)
return 1
print(" ✓ phone registered" if created else " ✓ phone already registered")
registered_phone = phone
registered_user_id = user.get("id")
# The number to text the agent is the user's assigned iMessage line
# (the dashboard's "TEXTS ON" column). On shared-number plans there is
# no dedicated entry in /lines, so this per-user field is the source of
@@ -240,16 +236,6 @@ def _cmd_setup(args: argparse.Namespace) -> int:
print(color("└──────────────────────────────────────────────────────────────", Colors.GREEN))
else:
print(" No iMessage line assigned yet — check the Photon dashboard.")
if registered_phone:
try:
photon_auth.store_user_numbers(
phone_number=registered_phone,
assigned_phone_number=agent_number,
user_id=str(registered_user_id) if registered_user_id else None,
dashboard_project_id=dashboard_id,
)
except Exception as e:
print(f" (could not save Photon status metadata: {e})", file=sys.stderr)
# 6. Sidecar deps (spectrum-ts).
if args.skip_sidecar_install:
@@ -294,7 +280,6 @@ def _autoconfigure_access(phone: str) -> None:
def _cmd_status(_args: argparse.Namespace) -> int:
_refresh_status_numbers()
# Defer the credential rows to auth.print_credential_summary — its emit
# callback is the only sink that sees credential-derived strings, so
# cli.py keeps zero taint flow according to CodeQL.
@@ -306,19 +291,6 @@ def _cmd_status(_args: argparse.Namespace) -> int:
return 0
def _refresh_status_numbers() -> None:
phone, assigned = photon_auth.load_user_numbers()
if phone and assigned:
return
spectrum_id, project_secret = photon_auth.load_project_credentials()
if not spectrum_id or not project_secret:
return
try:
photon_auth.refresh_user_numbers(spectrum_id, project_secret)
except Exception as e:
print(f" (could not refresh Photon user numbers: {e})", file=sys.stderr)
def _cmd_install_sidecar(_args: argparse.Namespace) -> int:
return _install_sidecar()
@@ -332,13 +304,9 @@ def _install_sidecar() -> int:
file=sys.stderr,
)
return 1
# Always pull the newest published spectrum-ts so every setup runs against
# the latest SDK. `spectrum-ts@latest` bumps package.json + package-lock.json
# to the current release before installing — a plain `npm install` would
# stay pinned to whatever the committed lockfile already resolved.
print(f" $ cd {_SIDECAR_DIR} && {npm} install spectrum-ts@latest")
print(f" $ cd {_SIDECAR_DIR} && {npm} install")
proc = subprocess.run( # noqa: S603
[npm, "install", "spectrum-ts@latest"],
[npm, "install"],
cwd=str(_SIDECAR_DIR),
check=False,
)
-4
View File
@@ -46,10 +46,6 @@ optional_env:
description: "Photon Dashboard API host (default https://app.photon.codes)"
prompt: "Dashboard host"
password: false
- name: PHOTON_SPECTRUM_HOST
description: "Photon Spectrum API host (default https://spectrum.photon.codes)"
prompt: "Spectrum API host"
password: false
- name: PHOTON_ALLOWED_USERS
description: "Comma-separated E.164 phone numbers allowed to talk to the bot"
prompt: "Allowed users (comma-separated)"
+64 -117
View File
@@ -40,7 +40,6 @@
// PHOTON_SIDECAR_BIND (default 127.0.0.1)
import http from "node:http";
import crypto from "node:crypto";
import { once } from "node:events";
const projectId = process.env.PHOTON_PROJECT_ID;
@@ -49,11 +48,11 @@ const port = parseInt(process.env.PHOTON_SIDECAR_PORT || "8789", 10);
const bind = process.env.PHOTON_SIDECAR_BIND || "127.0.0.1";
const sharedToken = process.env.PHOTON_SIDECAR_TOKEN;
// Inbound binary content is read into memory and base64-inlined on the NDJSON
// Inbound attachments are read into memory and base64-inlined on the NDJSON
// event so the Python adapter can cache the real bytes (and the agent can see
// images / transcribe voice). Cap the size we inline — above it we forward
// metadata only and the adapter surfaces a text marker, so one large clip can't
// balloon a single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
// the image). Cap the size we inline — above it we forward metadata only and
// the adapter surfaces a text marker, so one large video can't balloon a
// single NDJSON line. Override via PHOTON_MAX_INLINE_ATTACHMENT_BYTES.
const MAX_INLINE_ATTACHMENT_BYTES =
Number(process.env.PHOTON_MAX_INLINE_ATTACHMENT_BYTES) || 20 * 1024 * 1024;
const DM_CHAT_GUID_RE = /^any;-;(\+\d{6,})$/;
@@ -93,7 +92,6 @@ const app = await Spectrum({
projectId,
projectSecret,
providers: [imessage.config()],
options: { flattenGroups: true },
});
// ---------------------------------------------------------------------------
@@ -165,57 +163,6 @@ async function deliver(line) {
}
}
async function normalizeBinaryContent(content) {
const meta = {
type: content.type,
id: content.id ?? null,
name: content.name ?? null,
mimeType: content.mimeType ?? null,
size: typeof content.size === "number" ? content.size : null,
};
if (content.type === "voice" && typeof content.duration === "number") {
meta.duration = content.duration;
}
// Read the bytes eagerly and base64-inline them as `data` so the Python
// adapter can cache the real file (the agent then sees images and can run
// STT on voice notes). Spectrum content objects may not outlive this stream
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap content (when
// size is known up front) is forwarded as metadata only and the adapter falls
// back to a text marker. A read failure must never break the inbound loop.
const label = `${content.type} ${meta.name ?? meta.id ?? "(unnamed)"}`;
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: ${label} (${meta.size} bytes) ` +
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
);
return meta;
}
if (typeof content.read === "function") {
try {
const buf = await content.read();
// Guard the case where size was unknown but the bytes turn out to be
// over the cap.
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: ${label} (${buf.length} bytes) ` +
`exceeds inline cap after read; forwarding metadata only`
);
return meta;
}
meta.data = Buffer.from(buf).toString("base64");
meta.encoding = "base64";
} catch (e) {
console.error(
`photon-sidecar: failed to read ${content.type} bytes ` +
"(forwarding metadata only): " +
(e && e.stack ? e.stack : String(e))
);
}
}
return meta;
}
async function normalizeContent(content) {
if (!content || typeof content !== "object") {
return { type: "unknown" };
@@ -223,8 +170,51 @@ async function normalizeContent(content) {
if (content.type === "text") {
return { type: "text", text: content.text || "" };
}
if (content.type === "attachment" || content.type === "voice") {
return await normalizeBinaryContent(content);
if (content.type === "attachment") {
const meta = {
type: "attachment",
id: content.id ?? null,
name: content.name ?? null,
mimeType: content.mimeType ?? null,
size: typeof content.size === "number" ? content.size : null,
};
// Read the bytes eagerly and base64-inline them as `data` so the Python
// adapter can cache the real file (the agent then sees the image itself).
// The spectrum-ts attachment object may not outlive this stream
// iteration, so a lazy/on-demand fetch isn't safe. Over-cap attachments
// (when size is known up front) are forwarded as metadata only and the
// adapter falls back to a text marker. A read failure must never break
// the inbound loop — we just drop `data` and forward metadata.
if (meta.size !== null && meta.size > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: attachment ${meta.name ?? meta.id} (${meta.size} bytes) ` +
`exceeds inline cap ${MAX_INLINE_ATTACHMENT_BYTES}; forwarding metadata only`
);
return meta;
}
if (typeof content.read === "function") {
try {
const buf = await content.read();
// Guard the case where size was unknown but the bytes turn out to be
// over the cap.
if (buf && buf.length > MAX_INLINE_ATTACHMENT_BYTES) {
console.error(
`photon-sidecar: attachment ${meta.name ?? meta.id} (${buf.length} bytes) ` +
`exceeds inline cap after read; forwarding metadata only`
);
return meta;
}
meta.data = Buffer.from(buf).toString("base64");
meta.encoding = "base64";
} catch (e) {
console.error(
"photon-sidecar: failed to read attachment bytes " +
"(forwarding metadata only): " +
(e && e.stack ? e.stack : String(e))
);
}
}
return meta;
}
return { type: content.type || "unknown" };
}
@@ -255,57 +245,32 @@ async function normalizeEvent(space, message) {
}
}
// spectrum-ts handles in-session gRPC reconnects internally, but if the async
// iterator itself throws or ends, this consumer would stop forever. Wrap it in
// a re-subscribe loop with capped exponential backoff + jitter so inbound
// always recovers (the adapter dedupes any catch-up replay).
(async () => {
let backoff = 1000;
for (;;) {
try {
for await (const [space, message] of app.messages) {
backoff = 1000; // healthy traffic — reset
// Only forward inbound messages (ignore our own outbound echoes).
if (message && message.direction && message.direction !== "inbound") {
continue;
}
rememberInboundSpace(space, message);
const event = await normalizeEvent(space, message);
if (!event) continue;
await deliver(JSON.stringify(event));
try {
for await (const [space, message] of app.messages) {
// Only forward inbound messages (ignore our own outbound echoes).
if (message && message.direction && message.direction !== "inbound") {
continue;
}
console.error("photon-sidecar: inbound stream ended — re-subscribing");
} catch (e) {
console.error(
"photon-sidecar: inbound stream errored — restarting: " +
(e && e.message ? e.message : String(e))
);
rememberInboundSpace(space, message);
const event = await normalizeEvent(space, message);
if (!event) continue;
await deliver(JSON.stringify(event));
}
await new Promise((r) =>
setTimeout(r, backoff + Math.random() * backoff * 0.2)
} catch (e) {
console.error(
"photon-sidecar: inbound stream errored: " +
(e && e.stack ? e.stack : String(e))
);
backoff = Math.min(backoff * 2, 30000);
}
})();
// ---------------------------------------------------------------------------
// HTTP control + inbound server (loopback only).
// Control-message bodies are tiny; cap the body so a compromised local peer
// can't OOM the sidecar by streaming an unbounded request (defence-in-depth on
// the loopback channel).
const MAX_BODY_BYTES = 2 * 1024 * 1024; // 2 MiB
async function readBody(req) {
const chunks = [];
let size = 0;
for await (const chunk of req) {
size += chunk.length;
if (size > MAX_BODY_BYTES) {
req.destroy();
throw new Error("request body too large");
}
chunks.push(chunk);
}
for await (const chunk of req) chunks.push(chunk);
const raw = Buffer.concat(chunks).toString("utf-8");
if (!raw) return {};
try {
@@ -412,16 +377,8 @@ async function resolveSpace(spaceId) {
throw new Error(`unable to resolve space id ${spaceId}`);
}
// Constant-time token comparison — don't leak the token via `!==` timing.
const _tokenBuf = Buffer.from(sharedToken);
function tokenOk(header) {
if (typeof header !== "string") return false;
const h = Buffer.from(header);
return h.length === _tokenBuf.length && crypto.timingSafeEqual(h, _tokenBuf);
}
const server = http.createServer(async (req, res) => {
if (!tokenOk(req.headers["x-hermes-sidecar-token"])) {
if (req.headers["x-hermes-sidecar-token"] !== sharedToken) {
return unauthorized(res);
}
// Long-lived inbound NDJSON stream.
@@ -530,13 +487,3 @@ async function shutdown(signal) {
process.on("SIGINT", () => shutdown("SIGINT"));
process.on("SIGTERM", () => shutdown("SIGTERM"));
// Don't let a stray promise rejection take the process down silently — handlers
// catch their own errors, so log and keep serving (Python supervises restart on
// a real fatal exit).
process.on("unhandledRejection", (reason) => {
console.error(
"photon-sidecar: unhandledRejection: " +
(reason && reason.stack ? reason.stack : String(reason))
);
});
+155 -77
View File
@@ -8,7 +8,7 @@
"name": "@hermes-agent/photon-sidecar",
"version": "0.2.0",
"dependencies": {
"spectrum-ts": "^1.18.0"
"spectrum-ts": "^1.17.1"
},
"engines": {
"node": ">=18.17"
@@ -119,15 +119,16 @@
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.218.0.tgz",
"integrity": "sha512-Qx+4rpVHzgg89dawcWRHyt+XRXeLnhFz/qBtvggmjkcgPUdr+NAB0/u/eIPA8yAeJV0J80Vz43JZCh/XFvZFGw==",
"version": "0.216.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-logs-otlp-http/-/exporter-logs-otlp-http-0.216.0.tgz",
"integrity": "sha512-8SUzQY/aExKkz6Ab3vOf6gu690Xk4wHH90dGwXinejQzazn5HCIRR7yPVU/2fEuiZ73R92MU4qI3djHfYP7NJg==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/api-logs": "0.216.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/sdk-logs": "0.218.0"
"@opentelemetry/otlp-exporter-base": "0.216.0",
"@opentelemetry/otlp-transformer": "0.216.0",
"@opentelemetry/sdk-logs": "0.216.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -136,42 +137,15 @@
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/api-logs": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz",
"integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/exporter-logs-otlp-http/node_modules/@opentelemetry/sdk-logs": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz",
"integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
}
},
"node_modules/@opentelemetry/exporter-trace-otlp-http": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.218.0.tgz",
"integrity": "sha512-8dqezsmPhtKitIK/eTipZhYl9EX2/gNQ5zUMhaz3uxEURwfkNf8IPvo6yNfrzbxdtpAOybS/+h7wmIWYqFSpiw==",
"version": "0.216.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/exporter-trace-otlp-http/-/exporter-trace-otlp-http-0.216.0.tgz",
"integrity": "sha512-DhWjvj0PUPFwFnhOEivpum8sJzj6FTuyx88zff+oHVLUhfd6cLyw4AIai/F4j0PZqYZBFuMT/OTMUd9wdXnBEQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/otlp-exporter-base": "0.216.0",
"@opentelemetry/otlp-transformer": "0.216.0",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
},
@@ -183,12 +157,13 @@
}
},
"node_modules/@opentelemetry/otlp-exporter-base": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.218.0.tgz",
"integrity": "sha512-ZwqpkNL5W7RyGJPDZ9g06DvKp8KFTWPJPN12anpMQYSKpTSU0z3EIZuPq9vPGpS8siFyOqDYDAuCwlNO9FqgbA==",
"version": "0.216.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-exporter-base/-/otlp-exporter-base-0.216.0.tgz",
"integrity": "sha512-sSnvb5f+FYa4mfYxj03rmmUh+aDwo3jok62dgIWUDw8ZCUPzEbgtv/YhZyKUSlKNNey7Uc5xmJgmtTLLIV6UDQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/otlp-transformer": "0.218.0"
"@opentelemetry/otlp-transformer": "0.216.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -198,16 +173,18 @@
}
},
"node_modules/@opentelemetry/otlp-transformer": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.218.0.tgz",
"integrity": "sha512-CFaKH87WAzjuJ4awowTTLzUvMfaRfiOFG5+qm5S5ncyalRtN4ecQ+YmuANJSCrVPuvZFEkUgKhBPBndxi3rHsQ==",
"version": "0.216.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/otlp-transformer/-/otlp-transformer-0.216.0.tgz",
"integrity": "sha512-g4Rb6sAsxQAo11eDjixfKxelruBsQFdJ8Wo23FCj7D6OXbidgXMu2xaRSYs4RdlomzAXSJuc86RcS3xmE8A6uA==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/api-logs": "0.216.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/sdk-logs": "0.218.0",
"@opentelemetry/sdk-logs": "0.216.0",
"@opentelemetry/sdk-metrics": "2.7.1",
"@opentelemetry/sdk-trace-base": "2.7.1"
"@opentelemetry/sdk-trace-base": "2.7.1",
"protobufjs": "8.0.1"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
@@ -216,32 +193,28 @@
"@opentelemetry/api": "^1.3.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/api-logs": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.218.0.tgz",
"integrity": "sha512-fmEWp5kXlGEc3i/lR698Hz41DfGyN4Tbe4g7L1AxSc7fF8Xeh/FQ9Quqpa9dVA413Q1Ad43QOLzU4JoXgbFPWw==",
"node_modules/@opentelemetry/otlp-transformer/node_modules/protobufjs": {
"version": "8.0.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.0.1.tgz",
"integrity": "sha512-NWWCCscLjs+cOKF/s/XVNFRW7Yih0fdH+9brffR5NZCy8k42yRdl5KlWKMVXuI1vfCoy4o1z80XR/W/QUb3V3w==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@opentelemetry/api": "^1.3.0"
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.4",
"@protobufjs/eventemitter": "^1.1.0",
"@protobufjs/fetch": "^1.1.0",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.0",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.0",
"@types/node": ">=13.7.0",
"long": "^5.0.0"
},
"engines": {
"node": ">=8.0.0"
}
},
"node_modules/@opentelemetry/otlp-transformer/node_modules/@opentelemetry/sdk-logs": {
"version": "0.218.0",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-logs/-/sdk-logs-0.218.0.tgz",
"integrity": "sha512-QvnNdugatFTVCJXH0Mcu7GOOJSylA9j127kIezOE4YwTI4YbowRons2K4WZTv5FMS8T4q9P0NdaRHdkSmeAIag==",
"dependencies": {
"@opentelemetry/api-logs": "0.218.0",
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1",
"@opentelemetry/semantic-conventions": "^1.29.0"
},
"engines": {
"node": "^18.19.0 || >=20.6.0"
},
"peerDependencies": {
"@opentelemetry/api": ">=1.4.0 <1.10.0"
"node": ">=12.0.0"
}
},
"node_modules/@opentelemetry/resources": {
@@ -282,6 +255,7 @@
"version": "2.7.1",
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-metrics/-/sdk-metrics-2.7.1.tgz",
"integrity": "sha512-MpDJdkiFDs3Pm1RHO3KByuZbuBdJEXEAkiC0+yJdsZGVCdf1RpHR6n+LHDcS7ffmfrt5kVCzJSCfm4z2C7v0uQ==",
"license": "Apache-2.0",
"dependencies": {
"@opentelemetry/core": "2.7.1",
"@opentelemetry/resources": "2.7.1"
@@ -428,12 +402,97 @@
"node": ">=18"
}
},
"node_modules/@photon-ai/whatsapp-business/node_modules/protobufjs": {
"version": "8.4.2",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.4.2.tgz",
"integrity": "sha512-64rfNzkWOZAIazXzpBFPWq6F9up6gMvTzjE2oWIzApx2N/dqVUEE7+bCn2+40780dFVtKOUab8QfxJ6KJDWbqA==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"long": "^5.3.2"
},
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/@protobufjs/aspromise": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz",
"integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/base64": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz",
"integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/codegen": {
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz",
"integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/eventemitter": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz",
"integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/fetch": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz",
"integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==",
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.1"
}
},
"node_modules/@protobufjs/float": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz",
"integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/inquire": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.2.tgz",
"integrity": "sha512-pa0vFRuws4wkvaXKK1uXZMAwAX4/t8ANaJo45iw/oQHNQ9q5xUzwgFmVJGXiga2BeN+zpX7Vf9vmsiIa2J+MUw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/path": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz",
"integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/pool": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz",
"integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==",
"license": "BSD-3-Clause"
},
"node_modules/@protobufjs/utf8": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.1.tgz",
"integrity": "sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==",
"license": "BSD-3-Clause"
},
"node_modules/@repeaterjs/repeater": {
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/@repeaterjs/repeater/-/repeater-3.0.6.tgz",
"integrity": "sha512-Javneu5lsuhwNCryN+pXH93VPQ8g0dBX7wItHFgYiwQmzE1sVdg5tWHiOgHywzL2W21XQopa7IwIEnNbmeUJYA==",
"license": "MIT"
},
"node_modules/@types/node": {
"version": "25.9.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.9.1.tgz",
"integrity": "sha512-xfrlY7UD5rMJk3ZVJP8BNzS28J36YJg+xp+LPXV1TdWxr8uMH5A860QNxYDGQe/ylDSgjxE52Q9VnO7p75tJxg==",
"license": "MIT",
"dependencies": {
"undici-types": ">=7.24.0 <7.24.7"
}
},
"node_modules/abort-controller-x": {
"version": "0.5.0",
"resolved": "https://registry.npmjs.org/abort-controller-x/-/abort-controller-x-0.5.0.tgz",
@@ -1247,10 +1306,23 @@
}
},
"node_modules/protobufjs": {
"version": "8.6.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-8.6.1.tgz",
"integrity": "sha512-s4qQPr4pU0W95iYnUInh95skjIg+3aM2sakYsw60QYanU+qWRDY2zQxOAQV6zU7ROJpSNDG9B+VSmk4dqdWWSA==",
"version": "7.6.1",
"resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.1.tgz",
"integrity": "sha512-4K0myLaWL5EteuSAro91EGFgcfVgxb64Jx+7oDAY6GOkXD4M69yuSEljNcInGVCA5sOPxmZ/EqDLj2x0Q0+Ygg==",
"hasInstallScript": true,
"license": "BSD-3-Clause",
"dependencies": {
"@protobufjs/aspromise": "^1.1.2",
"@protobufjs/base64": "^1.1.2",
"@protobufjs/codegen": "^2.0.5",
"@protobufjs/eventemitter": "^1.1.1",
"@protobufjs/fetch": "^1.1.1",
"@protobufjs/float": "^1.0.2",
"@protobufjs/inquire": "^1.1.2",
"@protobufjs/path": "^1.1.2",
"@protobufjs/pool": "^1.1.0",
"@protobufjs/utf8": "^1.1.1",
"@types/node": ">=13.7.0",
"long": "^5.3.2"
},
"engines": {
@@ -1579,6 +1651,12 @@
"node": ">=20.18.1"
}
},
"node_modules/undici-types": {
"version": "7.24.6",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.24.6.tgz",
"integrity": "sha512-WRNW+sJgj5OBN4/0JpHFqtqzhpbnV0GuB+OozA9gCL7a993SmU+1JBZCzLNxYsbMfIeDL+lTsphD5jN5N+n0zg==",
"license": "MIT"
},
"node_modules/util-deprecate": {
"version": "1.0.2",
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
@@ -12,13 +12,6 @@
"node": ">=18.17"
},
"dependencies": {
"spectrum-ts": "^1.18.0"
},
"overrides": {
"protobufjs": "8.6.1",
"@opentelemetry/otlp-transformer": "0.218.0",
"@opentelemetry/otlp-exporter-base": "0.218.0",
"@opentelemetry/exporter-trace-otlp-http": "0.218.0",
"@opentelemetry/exporter-logs-otlp-http": "0.218.0"
"spectrum-ts": "^1.17.1"
}
}
+3 -30
View File
@@ -61,16 +61,6 @@ dependencies = [
"prompt_toolkit==3.0.52",
# Cron scheduler (built-in feature — scheduled cron/interval jobs use croniter).
"croniter==6.0.0",
# ``packaging`` is imported directly on three production paths but was never
# declared, so it only reached users transitively (pip/uv pull it for other
# tools). The slim official Docker image ships without it, where the
# try/except-ImportError fallbacks silently degrade: Hindsight's
# ``_meets_minimum_version`` disables update_mode='append' (#40503),
# tools/lazy_deps.py treats every version constraint as satisfied, and
# hermes_cli/main.py drops to naive requirement parsing. Pure-Python
# py3-none-any wheel, no compiled extensions — safe to ship everywhere.
# Pinned to the version already resolved in uv.lock (no resolution churn).
"packaging==26.0",
# Markdown -> HTML conversion for rich message delivery (Matrix
# `formatted_body`, and the `send_message` tool's HTML path). Now on the
# DEFAULT delivery path, not matrix-specific: without it both
@@ -82,10 +72,7 @@ dependencies = [
# it out of the lazy-install path that exists only for the heavy matrix deps.
"Markdown==3.10.2",
# Skills Hub (GitHub App JWT auth — optional, only needed for bot identity)
"PyJWT[crypto]==2.13.0", # PYSEC-2026-175/177/178/179
# urllib3 2.7.0 fixes GHSA-mf9v-mfxr-j63j (decompression-bomb bypass)
# and GHSA-qccp-gfcp-xxvc (header leak across origins).
"urllib3>=2.7.0,<3",
"PyJWT[crypto]==2.12.1", # CVE-2026-32597
# Windows has no IANA tzdata shipped with the OS, so Python's ``zoneinfo``
# (PEP 615) raises ``ZoneInfoNotFoundError`` for every non-UTC timezone
# out of the box. ``tzdata`` ships the Olson database as a data package
@@ -285,20 +272,6 @@ py-modules = ["run_agent", "model_tools", "toolsets", "batch_runner", "trajector
# venv) drop the catalogs and gateway/CLI commands surface raw i18n keys like
# `gateway.reset.header_default` (#27632, #35374, #23943).
locales = ["locales/*.yaml"]
# Shipped MCP catalog (optional-mcps/<name>/manifest.yaml). Same bare-data-dir
# case as locales: data-files ships it in the wheel, `graft optional-mcps` in
# MANIFEST.in ships it in the sdist. Without this, `hermes mcp catalog` and the
# dashboard catalog screen come up empty on packaged installs even though the
# manifests exist in the repo (hermes_cli/mcp_catalog.py:_catalog_root resolves
# the packaged dir; list_catalog() returns [] when it's missing).
#
# data-files flattens every glob match into its single target dir, so each
# catalog entry needs its OWN target to preserve the per-entry directory the
# catalog iterates over (a shared `optional-mcps/*/*` glob would collapse all
# manifests into one colliding optional-mcps/manifest.yaml). One target per
# entry; tests/test_packaging_metadata.py enforces an entry per optional-mcps/<name>.
"optional-mcps/linear" = ["optional-mcps/linear/manifest.yaml"]
"optional-mcps/n8n" = ["optional-mcps/n8n/manifest.yaml"]
[tool.setuptools.package-data]
hermes_cli = ["web_dist/**/*", "tui_dist/**/*", "scripts/install.sh", "scripts/install.ps1"]
@@ -327,12 +300,12 @@ markers = [
"integration: marks tests requiring external services (API keys, Modal, etc.)",
"real_concurrent_gate: opt out of the autouse stub that disables _detect_concurrent_hermes_instances",
]
# pytest-timeout: per-test 30s hard cap with cross-platform thread method.
# pytest-timeout: per-test 30s hard cap with signal method.
# This is the fallback inside each per-file pytest subprocess (see
# scripts/run_tests_parallel.py). Per-file isolation gives every test
# file a fresh Python interpreter; pytest-timeout catches Python-level
# hangs within a file.
addopts = "-m 'not integration' --timeout=30 --timeout-method=thread"
addopts = "-m 'not integration' --timeout=30 --timeout-method=signal"
[tool.ty.environment]
python-version = "3.13"
-177
View File
@@ -1,177 +0,0 @@
#!/usr/bin/env python3
"""Check that subprocess calls in TUI-context code specify stdin=.
When Hermes runs in TUI mode, the gateway child process communicates with
the Node.js parent over a JSON-RPC protocol on stdin. Subprocess calls that
inherit this fd can cause the gateway to exit with stdin EOF during tool
execution (issue #14036, PR #39257).
This script checks that all subprocess.run() and subprocess.Popen() calls
in TUI-context files (agent/, tools/, plugins/, tui_gateway/) explicitly
set stdin= to prevent fd inheritance.
Exit codes:
0 all calls are safe
1 violations found
2 script error
Usage:
python scripts/check_subprocess_stdin.py [--fix]
With --fix, prints the commands to add stdin=subprocess.DEVNULL to each
violation (does not modify files).
"""
from __future__ import annotations
import os
import re
import sys
from pathlib import Path
# Directories that run inside the TUI gateway child process.
TUI_CONTEXT_DIRS = [
"agent/",
"tools/",
"plugins/",
"tui_gateway/",
]
# Files with intentional stdin= override (e.g. input= creates a pipe).
# Format: "filepath:line" or just "filepath" to skip the whole file.
KNOWN_SAFE = {
"agent/shell_hooks.py", # uses input=stdin_json, creates a pipe
"plugins/security-guidance/patterns.py", # subprocess mentions are in reminder strings, not calls
}
# Inline marker that exempts a single subprocess call from this check.
# Put it in a comment on (or within) the call when the process MUST inherit
# stdin — e.g. an interactive login the user explicitly invokes. Travels with
# the line, so it survives edits that shift line numbers (unlike a pinned
# file:line entry).
EXEMPT_MARKER = "noqa: subprocess-stdin"
# Directories to skip entirely.
SKIP_DIRS = {
"tests/",
"scripts/",
"skills/",
"optional-skills/",
"hermes_cli/",
"gateway/",
"cron/",
}
def find_subprocess_calls(content: str, filepath: str) -> list[dict]:
"""Find all subprocess.run/Popen calls missing stdin= in content."""
violations = []
lines = content.split("\n")
# Match only actual function calls — not comments, docstrings, or prose.
# The pattern requires an opening paren followed by an arg character
# (quote, bracket, letter, or closing paren for empty calls).
# This excludes ``subprocess.Popen(...)`` in docstrings and
# subprocess.run(...) in comments.
pattern = re.compile(r'subprocess\.(run|Popen)\s*\(["\'a-zA-Z_\[\(]')
for i, line in enumerate(lines):
# Skip comments.
stripped = line.lstrip()
if stripped.startswith("#"):
continue
# Skip lines where the match is inside backticks (docstring references).
if "``subprocess" in line:
continue
if not pattern.search(line):
continue
# Collect the full call (may span multiple lines).
call_start = i
paren_depth = 0
found_open = False
call_lines = []
for j in range(i, min(i + 30, len(lines))):
call_lines.append(lines[j])
for ch in lines[j]:
if ch == "(":
paren_depth += 1
found_open = True
elif ch == ")":
paren_depth -= 1
if found_open and paren_depth == 0:
call_text = "\n".join(call_lines)
# Already has stdin= → safe.
if "stdin=" in call_text:
break
# Has input= → creates a pipe, safe.
if "input=" in call_text:
break
# Inline exemption marker on the call itself or within
# the few comment lines immediately above it → the call
# intentionally inherits stdin.
window_start = max(0, i - 4)
preceding = "\n".join(lines[window_start:i])
if EXEMPT_MARKER in call_text or EXEMPT_MARKER in preceding:
break
violations.append({
"file": filepath,
"line": i + 1,
"snippet": line.strip()[:120],
})
break
else:
continue
break
return violations
def main() -> int:
fix_mode = "--fix" in sys.argv
repo_root = Path(__file__).resolve().parent.parent
os.chdir(repo_root)
all_violations = []
for tui_dir in TUI_CONTEXT_DIRS:
dirpath = repo_root / tui_dir
if not dirpath.exists():
continue
for py_file in dirpath.rglob("*.py"):
rel = str(py_file.relative_to(repo_root))
# Skip known-safe files.
if rel in KNOWN_SAFE:
continue
# Skip test files inside tools/ etc.
parts = py_file.parts
if any(skip.rstrip("/") in parts for skip in SKIP_DIRS):
continue
content = py_file.read_text()
violations = find_subprocess_calls(content, rel)
all_violations.extend(violations)
if all_violations:
print(f"{len(all_violations)} subprocess calls missing stdin=:")
for v in all_violations:
print(f" {v['file']}:{v['line']}: {v['snippet']}")
if fix_mode:
print("\nAdd stdin=subprocess.DEVNULL to each call above.")
return 1
else:
print("✅ All TUI-context subprocess calls have explicit stdin=")
return 0
if __name__ == "__main__":
sys.exit(main())
+12 -30
View File
@@ -1725,7 +1725,7 @@ function Write-BootstrapMarker {
function Copy-ConfigTemplates {
Write-Info "Setting up configuration files..."
# Create the HERMES_HOME directory structure ($HermesHome, default %LOCALAPPDATA%\hermes)
# Create ~/.hermes directory structure
New-Item -ItemType Directory -Force -Path "$HermesHome\cron" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\sessions" | Out-Null
New-Item -ItemType Directory -Force -Path "$HermesHome\logs" | Out-Null
@@ -1743,13 +1743,13 @@ function Copy-ConfigTemplates {
$examplePath = "$InstallDir\.env.example"
if (Test-Path $examplePath) {
Copy-Item $examplePath $envPath
Write-Success "Created $envPath from template"
Write-Success "Created ~/.hermes/.env from template"
} else {
New-Item -ItemType File -Force -Path $envPath | Out-Null
Write-Success "Created $envPath"
Write-Success "Created ~/.hermes/.env"
}
} else {
Write-Info "$envPath already exists, keeping it"
Write-Info "~/.hermes/.env already exists, keeping it"
}
# Create config.yaml
@@ -1758,10 +1758,10 @@ function Copy-ConfigTemplates {
$examplePath = "$InstallDir\cli-config.yaml.example"
if (Test-Path $examplePath) {
Copy-Item $examplePath $configPath
Write-Success "Created $configPath from template"
Write-Success "Created ~/.hermes/config.yaml from template"
}
} else {
Write-Info "$configPath already exists, keeping it"
Write-Info "~/.hermes/config.yaml already exists, keeping it"
}
# Create SOUL.md if it doesn't exist (global persona file).
@@ -1794,25 +1794,25 @@ Delete the contents (or this file) to use the default personality.
"@
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
[System.IO.File]::WriteAllText($soulPath, $soulContent, $utf8NoBom)
Write-Success "Created $soulPath (edit to customize personality)"
Write-Success "Created ~/.hermes/SOUL.md (edit to customize personality)"
}
Write-Success "Configuration directory ready: $HermesHome"
Write-Success "Configuration directory ready: ~/.hermes/"
# Seed bundled skills into $HermesHome\skills (manifest-based, one-time per skill)
Write-Info "Syncing bundled skills to $HermesHome\skills ..."
# Seed bundled skills into ~/.hermes/skills/ (manifest-based, one-time per skill)
Write-Info "Syncing bundled skills to ~/.hermes/skills/ ..."
$pythonExe = "$InstallDir\venv\Scripts\python.exe"
if (Test-Path $pythonExe) {
try {
& $pythonExe "$InstallDir\tools\skills_sync.py" 2>$null
Write-Success "Skills synced to $HermesHome\skills"
Write-Success "Skills synced to ~/.hermes/skills/"
} catch {
# Fallback: simple directory copy
$bundledSkills = "$InstallDir\skills"
$userSkills = "$HermesHome\skills"
if ((Test-Path $bundledSkills) -and -not (Get-ChildItem $userSkills -Exclude '.bundled_manifest' -ErrorAction SilentlyContinue)) {
Copy-Item -Path "$bundledSkills\*" -Destination $userSkills -Recurse -Force -ErrorAction SilentlyContinue
Write-Success "Skills copied to $HermesHome\skills"
Write-Success "Skills copied to ~/.hermes/skills/"
}
}
}
@@ -2248,24 +2248,6 @@ function Install-Desktop {
$code = $LASTEXITCODE
}
}
# Still failing and the user hasn't pinned their own mirror: GitHub's
# Electron release host is likely blocked/throttled (the repeating
# "retrying" log). Retry once via npmmirror.com — the de-facto Electron
# community mirror (Alibaba). @electron/get SHASUM-checks the download,
# but the SHASUMS come from the same mirror, so that guards against a
# corrupt/partial download, NOT a compromised mirror: an explicit trust
# trade-off we only make AFTER the canonical GitHub download has failed,
# and we never override a user-pinned ELECTRON_MIRROR.
if ($code -ne 0 -and -not $env:ELECTRON_MIRROR) {
$prevMirror = $env:ELECTRON_MIRROR
$env:ELECTRON_MIRROR = "https://npmmirror.com/mirrors/electron/"
Write-Warn "Desktop build still failing - the Electron download from GitHub looks blocked."
Write-Warn "Retrying once via a public Electron mirror ($($env:ELECTRON_MIRROR)):"
Write-Info " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
& $npmExe run pack 2>&1 | ForEach-Object { "$_" } | Tee-Object -FilePath $buildLog
$code = $LASTEXITCODE
$env:ELECTRON_MIRROR = $prevMirror
}
$ErrorActionPreference = $prevEAP
if ($code -ne 0) {
$errText = Get-Content $buildLog -Raw -ErrorAction SilentlyContinue
+9 -131
View File
@@ -2281,93 +2281,6 @@ postinstall_mode() {
fi
}
# Clear the cached Electron download + any half-written unpacked output so the
# next `npm run pack` re-downloads and re-stages from scratch. A corrupt zip in
# the per-user Electron download cache - most often a partial/resumed download
# that leaves concatenated junk - makes electron-builder's `unpack-electron`
# extract a tree MISSING the electron binary, so the `electron`->`Hermes` rename
# dies with ENOENT and every re-run repeats the broken extraction forever. This
# is the bash sibling of install.ps1's Clear-ElectronBuildCache and the Python
# _purge_electron_build_cache() used by `hermes desktop`; install.sh was the only
# build path lacking it. Echoes the removed paths (one per line); best-effort.
clear_electron_build_cache() {
local desktop_dir="$1"
local removed=""
# Per-user Electron download cache dirs, honoring the overrides @electron/get
# respects, then the platform defaults (macOS: ~/Library/Caches/electron,
# Linux: $XDG_CACHE_HOME/electron or ~/.cache/electron).
local cache_dirs=()
[ -n "${electron_config_cache:-}" ] && cache_dirs+=("$electron_config_cache")
[ -n "${ELECTRON_CACHE:-}" ] && cache_dirs+=("$ELECTRON_CACHE")
if [ "$OS" = "macos" ]; then
cache_dirs+=("$HOME/Library/Caches/electron")
else
[ -n "${XDG_CACHE_HOME:-}" ] && cache_dirs+=("$XDG_CACHE_HOME/electron")
cache_dirs+=("$HOME/.cache/electron")
fi
local dir zip
for dir in "${cache_dirs[@]}"; do
[ -d "$dir" ] || continue
# Recurse: the bad copy may be the top-level zip OR a copy inside an
# @electron/get hash subdir.
while IFS= read -r zip; do
[ -n "$zip" ] || continue
if rm -f "$zip" 2>/dev/null; then
removed="$removed$zip
"
fi
done <<EOF
$(find "$dir" -type f -name 'electron-*.zip' 2>/dev/null)
EOF
done
# A half-written unpacked dir from an interrupted prior pack poisons the
# rename even after the zip is fixed (mac-arm64-unpacked / linux-unpacked).
local release_dir="$desktop_dir/release"
if [ -d "$release_dir" ]; then
local unpacked
while IFS= read -r unpacked; do
[ -n "$unpacked" ] || continue
if rm -rf "$unpacked" 2>/dev/null; then
removed="$removed$unpacked
"
fi
done <<EOF
$(find "$release_dir" -maxdepth 1 -type d -name '*-unpacked' 2>/dev/null)
EOF
fi
printf '%s' "$removed"
}
# Run the desktop pack in $1 (the apps/desktop dir). `npm run pack` = tsc +
# vite build + electron-builder --dir, producing an unpacked app for the
# current OS. Signing auto-discovery is disabled so electron-builder falls back
# to an ad-hoc signature instead of grabbing an unrelated Developer ID from the
# keychain (a real signed/notarized .dmg needs Apple credentials — a separate
# release concern). Optional $2 = an ELECTRON_MIRROR base URL for this attempt,
# used as a fallback when the default GitHub release download is blocked.
_desktop_pack() {
local desktop_dir="$1"
local mirror="${2:-}"
if [ -n "$mirror" ]; then
( cd "$desktop_dir" && ELECTRON_MIRROR="$mirror" CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
else
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack )
fi
}
# Public Electron mirror used as a last-resort fallback when GitHub's release
# host is blocked/throttled (the repeating "retrying" symptom). npmmirror.com is
# the de-facto Electron community mirror (Alibaba). @electron/get SHASUM-checks
# the download, but the SHASUMS come from the same mirror — that guards against a
# corrupt/partial download, NOT a compromised mirror. Reaching for it is an
# explicit trust trade-off we only make AFTER the canonical GitHub download has
# failed, and we never override a user-pinned ELECTRON_MIRROR.
DESKTOP_ELECTRON_FALLBACK_MIRROR="https://npmmirror.com/mirrors/electron/"
# Build apps/desktop into a launchable native app. Mirrors install.ps1's
# Install-Desktop: a root-level npm install so the apps/* workspace resolves
# the desktop's own deps (Electron ~150MB), then `npm run pack`
@@ -2425,53 +2338,18 @@ install_desktop() {
}
log_success "Desktop workspace dependencies installed"
# 2. Build, with up to three escalating attempts so a transient/blocked
# Electron download self-heals instead of failing the whole install:
# a) plain `npm run pack` (downloads Electron from GitHub),
# b) on failure, purge a corrupt cached zip + stale unpacked dir and
# retry (matches install.ps1 / `hermes desktop`),
# c) on still-failing, fall back to a public Electron mirror — this is
# the GitHub-blocked/throttled case (the repeating "retrying" log).
# 2. Build. `npm run pack` = tsc + vite build + electron-builder --dir,
# producing an unpacked app for the current OS. We disable signing
# auto-discovery so electron-builder falls back to an ad-hoc signature
# instead of grabbing an unrelated Developer ID from the keychain; a
# real signed/notarized .dmg needs Apple credentials and is a separate
# release concern.
log_info "Building desktop app (this takes 1-3 minutes)..."
local pack_ok=false
if _desktop_pack "$desktop_dir"; then
pack_ok=true
else
# (b) Corrupt cached Electron zip is the most common self-healable cause.
local purged
purged="$(clear_electron_build_cache "$desktop_dir")"
if [ -n "$purged" ]; then
log_warn "Desktop build failed; cleared cached Electron download and retrying once..."
if _desktop_pack "$desktop_dir"; then
pack_ok=true
fi
fi
fi
# (c) Still failing and the user hasn't pinned their own mirror: the GitHub
# release host is likely blocked/throttled. Retry once via a public
# Electron mirror (@electron/get still SHASUM-verifies the download).
if [ "$pack_ok" = false ] && [ -z "${ELECTRON_MIRROR:-}" ]; then
log_warn "Desktop build still failing — the Electron download from GitHub looks blocked."
log_warn "Retrying once via a public Electron mirror ($DESKTOP_ELECTRON_FALLBACK_MIRROR)..."
log_warn " (set ELECTRON_MIRROR yourself to use a different/trusted mirror)"
if _desktop_pack "$desktop_dir" "$DESKTOP_ELECTRON_FALLBACK_MIRROR"; then
pack_ok=true
fi
fi
if [ "$pack_ok" = false ]; then
( cd "$desktop_dir" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack ) || {
log_error "Desktop app build failed"
# If the log shows repeated "retrying" lines fetching the Electron zip,
# the binary download is blocked/throttled (firewall, proxy, region) and
# the mirror fallback above also couldn't reach a host. Try a mirror you
# trust and rebuild (@electron/get honors ELECTRON_MIRROR):
log_info "If the log shows Electron download retries, rebuild via a reachable mirror:"
log_info " ELECTRON_MIRROR=<mirror-base-url> \\"
log_info " bash -c 'cd \"$desktop_dir\" && CSC_IDENTITY_AUTO_DISCOVERY=false npm run pack'"
log_info "Otherwise build manually: cd $desktop_dir && npm run pack"
log_info "Run manually: cd $desktop_dir && npm run pack"
return 1
fi
}
local app=""
if [ "$OS" = "linux" ]; then
-5
View File
@@ -45,7 +45,6 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
# Auto-extracted from noreply emails + manual overrides
AUTHOR_MAP = {
"philipadsouza@gmail.com": "PhilipAD",
"zhuhaoyu0909@icloud.com": "underthestars-zhy",
"raysun12142006@gmail.com": "yanxue06",
"alberto.regalado@ymail.com": "ARegalado1",
@@ -59,14 +58,12 @@ AUTHOR_MAP = {
"thomas.paquette@gmail.com": "RyTsYdUp",
"techxacm@gmail.com": "ProgramCaiCai",
"266365592+bmoore210@users.noreply.github.com": "bmoore210",
"157839748+psionic73@users.noreply.github.com": "psionic73",
"manishbyatroy@gmail.com": "manishbyatroy",
"chilltulpa@gmail.com": "TheGardenGallery",
"al@randomsnowflake.me": "randomsnowflake",
"zakame@zakame.net": "zakame",
"152110621+jiangkoumo@users.noreply.github.com": "jiangkoumo",
"834740219@qq.com": "ViewWay",
"matt@vestigial.dev": "m4dni5",
"harjoth.khara@gmail.com": "harjothkhara",
"129007007+HeLLGURD@users.noreply.github.com": "HeLLGURD",
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
@@ -105,7 +102,6 @@ AUTHOR_MAP = {
"metalclaudbot@gmail.com": "HashClawAI",
"tonybear55665566@gmail.com": "TonyPepeBear",
"kaspersniels@gmail.com": "nielskaspers",
"daxxpasquini@gmail.com": "bpasquini",
"kurobaryo@gmail.com": "kurobaryo",
"scubamount@users.noreply.github.com": "scubamount",
"251514042+youngstar-eth@users.noreply.github.com": "youngstar-eth",
@@ -1477,7 +1473,6 @@ AUTHOR_MAP = {
# v0.15.0 additions
"glen@workmanfirearms.com": "sgtworkman",
"jorge.fuenmayort@gmail.com": "jfuenmayor",
"josh.dow@prepad.io": "joshuadow", # PR #43004 salvage (desktop WS session rebind)
"mordred@inaugust.com": "emonty",
"rodrigoeq@hotmail.com": "rodrigoeqnit",
"soliva.johnpaul@icloud.com": "jonpol01",
-67
View File
@@ -1213,73 +1213,6 @@ class TestBuildAnthropicKwargs:
assert _supports_fast_mode("claude-haiku-4-5") is False
assert _supports_fast_mode("") is False
def test_fable_class_models_route_as_adaptive_thinking(self):
"""Invariant: unknown/new Claude models default to the modern (4.7+)
contract adaptive thinking, xhigh-capable, sampling-params-forbidden
without any per-model code change. Named models (claude-fable-5) and
hypothetical future ones must all classify modern; only the explicit
legacy list stays on the manual path.
"""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
_get_anthropic_max_output,
)
# New / unknown Claude models → modern contract by default.
for m in (
"claude-fable-5",
"anthropic/claude-fable-5",
"claude-saga-2", # hypothetical future named model
"anthropic/claude-opus-9", # hypothetical future numbered model
):
assert _supports_adaptive_thinking(m) is True, m
assert _supports_xhigh_effort(m) is True, m
assert _forbids_sampling_params(m) is True, m
# 1M-context reasoning model → highest output ceiling.
assert _get_anthropic_max_output("anthropic/claude-fable-5") == 128_000
def test_legacy_claude_stays_on_manual_thinking(self):
"""Older Claude families keep the legacy manual-thinking contract."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_forbids_sampling_params,
)
for m in (
"claude-3-5-sonnet",
"claude-3-7-sonnet",
"anthropic/claude-opus-4.5",
"anthropic/claude-sonnet-4.5",
"claude-haiku-4-5",
):
assert _supports_adaptive_thinking(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_claude_46_is_adaptive_but_not_xhigh_or_no_sampling(self):
"""4.6 is adaptive, but predates xhigh and still accepts sampling."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
)
for m in ("claude-opus-4.6", "claude-sonnet-4-6"):
assert _supports_adaptive_thinking(m) is True, m
assert _supports_xhigh_effort(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_non_claude_anthropic_models_use_manual_path(self):
"""Non-Claude Anthropic-Messages models (minimax, qwen3, kimi) must not
be misclassified as adaptive by the default-to-modern rule."""
from agent.anthropic_adapter import (
_supports_adaptive_thinking,
_supports_xhigh_effort,
_forbids_sampling_params,
)
for m in ("minimax-m2", "qwen3-max", "moonshotai/kimi-k2.5", "glm-4.6"):
assert _supports_adaptive_thinking(m) is False, m
assert _supports_xhigh_effort(m) is False, m
assert _forbids_sampling_params(m) is False, m
def test_fast_mode_omitted_for_unsupported_model(self):
"""fast_mode=True on Opus 4.7 must NOT inject speed=fast (API 400s)."""
kwargs = build_anthropic_kwargs(
-53
View File
@@ -220,59 +220,6 @@ class TestDefaultContextLengths:
f"{model_id}: expected {expected_ctx}, got {actual}"
)
def test_openrouter_live_metadata_beats_hardcoded_catchall(self):
"""OpenRouter-routed slugs resolve via the live OR catalog before the
hardcoded family catch-all.
Regression for the claude-fable-5 under-report: a brand-new Anthropic
slug that is absent from models.dev but present in OpenRouter's live
catalog (with a 1M window) used to fall through to the generic
``"claude": 200000`` entry, because the step-6 OR fallback was gated on
``not effective_provider`` and ``effective_provider`` is "openrouter"
for any OpenRouter selection. The dedicated step-5 OR branch must read
the live value instead.
"""
from agent.model_metadata import get_model_context_length
from unittest.mock import patch as mock_patch
or_url = "https://openrouter.ai/api/v1"
live = {
"anthropic/claude-fable-5": {"context_length": 1_000_000},
"anthropic/claude-haiku-4.5": {"context_length": 200_000},
}
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value=live), \
mock_patch("agent.model_metadata._query_ollama_api_show", return_value=None), \
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None), \
mock_patch("agent.models_dev.lookup_models_dev_context", return_value=None):
# The bug: would have returned 200_000 via the "claude" catch-all.
assert get_model_context_length(
"anthropic/claude-fable-5", base_url=or_url, provider="openrouter"
) == 1_000_000
# A genuinely-200k model still resolves to its real OR value — the
# fix reads per-model context, it does not blanket-bump to 1M.
assert get_model_context_length(
"anthropic/claude-haiku-4.5", base_url=or_url, provider="openrouter"
) == 200_000
def test_openrouter_kimi_32k_underreport_still_guarded(self):
"""The live OR branch keeps the Kimi-family 32k underreport guard:
a bogus 32768 from OpenRouter for a Kimi slug must NOT win it falls
through to the hardcoded default instead.
"""
from agent.model_metadata import get_model_context_length
from unittest.mock import patch as mock_patch
or_url = "https://openrouter.ai/api/v1"
live = {"moonshotai/kimi-k2.6": {"context_length": 32768}}
with mock_patch("agent.model_metadata.fetch_model_metadata", return_value=live), \
mock_patch("agent.model_metadata._query_ollama_api_show", return_value=None), \
mock_patch("agent.model_metadata.get_cached_context_length", return_value=None), \
mock_patch("agent.models_dev.lookup_models_dev_context", return_value=None):
ctx = get_model_context_length(
"moonshotai/kimi-k2.6", base_url=or_url, provider="openrouter"
)
assert ctx != 32768, "Kimi 32k OR underreport must not be accepted"
# =========================================================================
# Codex OAuth context-window resolution (provider="openai-codex")
@@ -196,40 +196,6 @@ class TestRunTurn:
# turn_id propagated for downstream session-DB linkage
assert r.turn_id == "turn-fake-001"
def test_token_usage_notification_is_captured(self):
client = FakeClient()
client.queue_notification(
"thread/tokenUsage/updated",
threadId="thread-fake-001",
turnId="turn-fake-001",
tokenUsage={
"last": {
"totalTokens": 130,
"inputTokens": 80,
"cachedInputTokens": 20,
"outputTokens": 25,
"reasoningOutputTokens": 5,
},
"total": {
"totalTokens": 500,
"inputTokens": 300,
"cachedInputTokens": 75,
"outputTokens": 100,
"reasoningOutputTokens": 25,
},
"modelContextWindow": 200000,
},
)
client.queue_notification(
"turn/completed",
threadId="t",
turn={"id": "tu1", "status": "completed", "error": None},
)
r = make_session(client).run_turn("hi", turn_timeout=2.0)
assert r.token_usage_last["totalTokens"] == 130
assert r.token_usage_total["totalTokens"] == 500
assert r.model_context_window == 200000
def test_rich_content_turn_is_collapsed_to_text_payload(self):
client = FakeClient()
client.queue_notification(
+1 -100
View File
@@ -159,106 +159,7 @@ caption
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/tmp/voice.ogg"]
assert voice is True
def test_gateway_auto_append_image_generate_json_path(self):
"""image_generate returns a local path in JSON (no MEDIA: tag); it is
auto-appended so delivery doesn't depend on the model restating it."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{"role": "user", "content": "Make me a cat"},
{
"role": "assistant",
"tool_calls": [
{"id": "call_img", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "call_img",
"content": '{"success": true, "image": "/tmp/gen/cat.png", "agent_visible_image": "/tmp/gen/cat.png"}',
},
{"role": "assistant", "content": "Here's your cat."},
]
tags, voice = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/tmp/gen/cat.png"]
assert voice is False
def test_gateway_auto_append_image_generate_prefers_host_path(self):
"""When host and sandbox paths differ, the host-deliverable path wins."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{"role": "user", "content": "Make me a dog"},
{
"role": "assistant",
"tool_calls": [
{"id": "call_img", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "call_img",
"content": '{"success": true, "host_image": "/host/dog.jpg", "image": "/host/dog.jpg", "agent_visible_image": "/sandbox/dog.jpg"}',
},
]
tags, _ = _collect_auto_append_media_tags(messages, history_offset=0)
assert tags == ["MEDIA:/host/dog.jpg"]
def test_gateway_auto_append_image_generate_failure_and_url_ignored(self):
"""Failed generations and remote URLs are not auto-delivered."""
from gateway.run import _collect_auto_append_media_tags
def _img_msgs(content):
return [
{
"role": "assistant",
"tool_calls": [
{"id": "c", "function": {"name": "image_generate"}}
],
},
{"role": "tool", "tool_call_id": "c", "content": content},
]
# Failed generation
tags, _ = _collect_auto_append_media_tags(
_img_msgs('{"success": false, "image": null, "error": "boom"}'),
history_offset=0,
)
assert tags == []
# Remote URL is not a local file path
tags, _ = _collect_auto_append_media_tags(
_img_msgs('{"success": true, "image": "https://fal.media/x/cat.png"}'),
history_offset=0,
)
assert tags == []
def test_gateway_auto_append_image_generate_dedupes_history(self):
"""A generated image path already in history is not re-sent."""
from gateway.run import _collect_auto_append_media_tags
messages = [
{
"role": "assistant",
"tool_calls": [
{"id": "c", "function": {"name": "image_generate"}}
],
},
{
"role": "tool",
"tool_call_id": "c",
"content": '{"success": true, "image": "/tmp/gen/cat.png"}',
},
]
tags, _ = _collect_auto_append_media_tags(
messages, history_offset=0, history_media_paths={"/tmp/gen/cat.png"}
)
assert tags == []
def test_media_tags_not_extracted_from_history(self):
"""MEDIA tags from previous turns should NOT be extracted again."""
# Simulate conversation history with a TTS call from a previous turn
+8 -63
View File
@@ -1295,12 +1295,10 @@ class TerminalCommandAgent:
@pytest.mark.asyncio
async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path):
"""Terminal progress on a markdown-capable (supports_code_blocks) gateway
renders a bare fenced code block no language tag (Slack mrkdwn would print
'bash' as a literal first code line). In non-verbose ("all"/"new") mode the
command is collapsed to a single line capped at tool_preview_length so a long
or multi-line command doesn't render as a huge block (#42634)."""
async def test_terminal_progress_is_truncated_preview_not_bash_block(monkeypatch, tmp_path):
"""Regression for #41215: terminal progress must render as a short truncated
preview, never the full command in a fenced ```bash block, even on a
markdown-capable (supports_code_blocks) gateway."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "all")
fake_dotenv = types.ModuleType("dotenv")
@@ -1330,71 +1328,18 @@ async def test_terminal_progress_renders_fenced_code_block(monkeypatch, tmp_path
context_prompt="",
history=[],
source=source,
session_id="sess-terminal-code-block",
session_id="sess-terminal-no-bash-block",
session_key="agent:main:telegram:dm:12345",
)
assert result["final_response"] == "done"
all_content = " ".join(call["content"] for call in adapter.sent)
all_content += " ".join(call["content"] for call in adapter.edits)
# Bare fenced block, no language tag (no '```bash').
assert "```" in all_content
# Compact truncated preview, not a fenced bash block.
assert "```bash" not in all_content
# Non-verbose collapses to the first line + truncation marker — the later
# command lines must NOT appear (this was the "huge block" regression).
assert "set -euo pipefail" in all_content
assert 'terminal: "' in all_content
# The full multi-line command body must not reach the chat.
assert "npm install -g hyperframes@latest" not in all_content
assert "node --version" not in all_content
# No truncated quoted preview for the terminal command.
assert 'terminal: "' not in all_content
@pytest.mark.asyncio
async def test_terminal_progress_verbose_shows_full_command(monkeypatch, tmp_path):
"""Verbose mode on a markdown-capable gateway renders the FULL multi-line
command in a bare fenced block (no truncation, no 'bash' tag). This is the
parity guarantee for #42634: verbose keeps full detail, non-verbose caps."""
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "verbose")
fake_dotenv = types.ModuleType("dotenv")
fake_dotenv.load_dotenv = lambda *args, **kwargs: None
monkeypatch.setitem(sys.modules, "dotenv", fake_dotenv)
fake_run_agent = types.ModuleType("run_agent")
fake_run_agent.AIAgent = TerminalCommandAgent
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
import tools.terminal_tool # noqa: F401 - register terminal emoji
adapter = CodeBlockProgressAdapter(platform=Platform.TELEGRAM)
runner = _make_runner(adapter)
gateway_run = importlib.import_module("gateway.run")
monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path)
monkeypatch.setattr(gateway_run, "_resolve_runtime_agent_kwargs", lambda: {"api_key": "***"})
source = SessionSource(
platform=Platform.TELEGRAM,
chat_id="12345",
chat_type="dm",
thread_id=None,
)
result = await runner._run_agent(
message="hello",
context_prompt="",
history=[],
source=source,
session_id="sess-terminal-code-block-verbose",
session_key="agent:main:telegram:dm:12345",
)
assert result["final_response"] == "done"
all_content = " ".join(call["content"] for call in adapter.sent)
all_content += " ".join(call["content"] for call in adapter.edits)
assert "```" in all_content
assert "```bash" not in all_content
# Full command body present — verbose is uncapped.
assert "npm install -g hyperframes@latest" in all_content
assert "node --version" in all_content
@pytest.mark.asyncio
+1 -11
View File
@@ -41,16 +41,6 @@ def _suppress_concurrent_hermes_gate(request, monkeypatch):
from hermes_cli import main as _cli_main
except Exception:
return
# raising=False: under pytest's per-test spawn isolation, a concurrent
# xdist worker importing a module that transitively touches hermes_cli.main
# can briefly expose a partially-initialized module object here — one where
# _detect_concurrent_hermes_instances isn't defined yet. A bare setattr
# would raise AttributeError and error the (unrelated) test. The attribute
# always exists once main.py finishes importing, so a no-op when it's
# transiently absent is the correct, race-free default.
monkeypatch.setattr(
_cli_main,
"_detect_concurrent_hermes_instances",
lambda *_a, **_k: [],
raising=False,
_cli_main, "_detect_concurrent_hermes_instances", lambda *_a, **_k: []
)
@@ -1,58 +0,0 @@
"""Regression tests for the Anthropic model-picker dropping curated aliases.
Bug newly-routed curated aliases vanished on a native Anthropic setup
``provider_model_ids("anthropic")`` returned the live ``/v1/models`` dump
verbatim whenever Anthropic credentials were configured. Anthropic's API
lags behind freshly-routed aliases (e.g. ``claude-fable-5``, which is
reachable on Anthropic before the models endpoint enumerates it), so the
curated entry disappeared from the picker. The picker now merges the
curated ``_PROVIDER_MODELS["anthropic"]`` list with the live catalog
curated entries first, live-only models appended, deduped mirroring the
OpenAI curated-merge philosophy.
"""
from unittest.mock import patch
from hermes_cli import models as M
def test_anthropic_curated_alias_survives_when_live_omits_it():
"""A curated alias missing from /v1/models still surfaces (first)."""
curated = M._PROVIDER_MODELS["anthropic"]
assert "claude-fable-5" in curated # sanity: the alias is curated
# Live catalog the API would actually return — no fable-5.
live = ["claude-opus-4-8", "claude-sonnet-4-6", "claude-haiku-4-5-20251001"]
with patch.object(M, "_fetch_anthropic_models", return_value=live):
result = M.provider_model_ids("anthropic")
assert "claude-fable-5" in result
# Curated order is preserved at the front.
assert result[:len(curated)] == list(curated)
def test_anthropic_merge_dedupes_overlap_and_appends_live_only():
"""Models in both lists appear once; live-only models are appended."""
live = [
"claude-opus-4-8", # overlaps curated
"claude-sonnet-4-6", # overlaps curated
"claude-future-9-99", # live-only, not curated
]
with patch.object(M, "_fetch_anthropic_models", return_value=live):
result = M.provider_model_ids("anthropic")
# No duplicates introduced by the merge.
assert result.count("claude-opus-4-8") == 1
# Live-only entry is preserved (discovery still works for unknown models).
assert "claude-future-9-99" in result
# Curated entries lead, live-only trails.
assert result.index("claude-fable-5") < result.index("claude-future-9-99")
def test_anthropic_falls_back_to_curated_when_live_unavailable():
"""No creds / live failure -> curated list verbatim (alias still present)."""
with patch.object(M, "_fetch_anthropic_models", return_value=None):
result = M.provider_model_ids("anthropic")
assert result == list(M._PROVIDER_MODELS["anthropic"])
assert "claude-fable-5" in result
+2 -34
View File
@@ -498,42 +498,11 @@ def test_gui_retries_pack_once_after_purging_build_cache(tmp_path, monkeypatch):
assert mock_run.call_args_list[2].args[0] == [str(packaged_exe)]
def test_gui_falls_back_to_mirror_when_purge_finds_nothing(tmp_path, monkeypatch, capsys):
"""Purge clears nothing (not a cache problem) → fall back to an Electron
mirror once before failing, so a GitHub-blocked download self-heals."""
def test_gui_does_not_retry_when_purge_finds_nothing(tmp_path, monkeypatch, capsys):
"""If the purge clears nothing, there's no point retrying — fail fast."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")
monkeypatch.delenv("ELECTRON_MIRROR", raising=False)
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
with patch("hermes_cli.main.shutil.which", return_value="/usr/bin/npm"), \
patch("hermes_cli.main._run_npm_install_deterministic", return_value=install_ok), \
patch("hermes_cli.main._desktop_macos_relaunchable_fixup"), \
patch("hermes_cli.main._purge_electron_build_cache", return_value=[]) as mock_purge, \
patch("hermes_cli.main.subprocess.run", side_effect=[pack_fail, pack_fail]) as mock_run, \
pytest.raises(SystemExit) as exc:
cli_main.cmd_gui(_ns())
assert exc.value.code == 1
mock_purge.assert_called_once()
# pack(fail) → purge(nothing) → pack via mirror(fail) = 2 subprocess.run calls
assert mock_run.call_count == 2
# The retry runs the same build but with ELECTRON_MIRROR injected.
assert "ELECTRON_MIRROR" not in (mock_run.call_args_list[0].kwargs.get("env") or {})
assert mock_run.call_args_list[1].kwargs["env"]["ELECTRON_MIRROR"]
assert "Desktop GUI build failed" in capsys.readouterr().out
def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsys):
"""A user-pinned ELECTRON_MIRROR is respected: no extra mirror fallback
attempt (and we never swap in our default mirror)."""
root = _make_desktop_tree(tmp_path)
monkeypatch.setattr(cli_main, "PROJECT_ROOT", root)
_make_packaged_executable(root, monkeypatch, platform="linux")
monkeypatch.setenv("ELECTRON_MIRROR", "https://mirror.example/electron/")
install_ok = subprocess.CompletedProcess(["npm", "ci"], 0)
pack_fail = subprocess.CompletedProcess(["npm", "run", "pack"], 1)
@@ -549,7 +518,6 @@ def test_gui_does_not_override_user_electron_mirror(tmp_path, monkeypatch, capsy
assert exc.value.code == 1
mock_purge.assert_called_once()
assert mock_run.call_count == 1
assert mock_run.call_args_list[0].kwargs["env"]["ELECTRON_MIRROR"] == "https://mirror.example/electron/"
assert "Desktop GUI build failed" in capsys.readouterr().out
+9 -191
View File
@@ -3,8 +3,6 @@
from __future__ import annotations
import logging
import os
import shutil
from pathlib import Path
from unittest.mock import MagicMock, patch
@@ -18,7 +16,6 @@ from hermes_cli.plugins_cmd import (
_repo_name_from_url,
_resolve_git_executable,
_resolve_git_url,
_resolve_subdir_within,
_sanitize_plugin_name,
)
@@ -100,127 +97,35 @@ class TestSanitizePluginName:
class TestResolveGitUrl:
"""Shorthand and full-URL resolution, with optional subdirectory."""
"""Shorthand and full-URL resolution."""
def test_owner_repo_shorthand(self):
url, subdir = _resolve_git_url("owner/repo")
url = _resolve_git_url("owner/repo")
assert url == "https://github.com/owner/repo.git"
assert subdir is None
def test_https_url_passthrough(self):
url, subdir = _resolve_git_url("https://github.com/x/y.git")
url = _resolve_git_url("https://github.com/x/y.git")
assert url == "https://github.com/x/y.git"
assert subdir is None
def test_ssh_url_passthrough(self):
url, subdir = _resolve_git_url("git@github.com:x/y.git")
url = _resolve_git_url("git@github.com:x/y.git")
assert url == "git@github.com:x/y.git"
assert subdir is None
def test_http_url_passthrough(self):
url, subdir = _resolve_git_url("http://example.com/repo.git")
url = _resolve_git_url("http://example.com/repo.git")
assert url == "http://example.com/repo.git"
assert subdir is None
def test_file_url_passthrough(self):
url, subdir = _resolve_git_url("file:///tmp/repo")
url = _resolve_git_url("file:///tmp/repo")
assert url == "file:///tmp/repo"
assert subdir is None
def test_invalid_single_word_raises(self):
with pytest.raises(ValueError, match="Invalid plugin identifier"):
_resolve_git_url("justoneword")
def test_shorthand_with_subdir(self):
url, subdir = _resolve_git_url("owner/repo/my-plugin")
assert url == "https://github.com/owner/repo.git"
assert subdir == "my-plugin"
def test_shorthand_with_nested_subdir(self):
url, subdir = _resolve_git_url("owner/repo/path/to/plugin")
assert url == "https://github.com/owner/repo.git"
assert subdir == "path/to/plugin"
def test_shorthand_with_subdir_trailing_slash(self):
url, subdir = _resolve_git_url("owner/repo/my-plugin/")
assert url == "https://github.com/owner/repo.git"
assert subdir == "my-plugin"
def test_https_url_with_subdir(self):
url, subdir = _resolve_git_url("https://github.com/owner/repo.git/my-plugin")
assert url == "https://github.com/owner/repo.git"
assert subdir == "my-plugin"
def test_https_url_with_nested_subdir(self):
url, subdir = _resolve_git_url(
"https://github.com/owner/repo.git/path/to/plugin"
)
assert url == "https://github.com/owner/repo.git"
assert subdir == "path/to/plugin"
def test_url_with_fragment_subdir(self):
url, subdir = _resolve_git_url("https://github.com/owner/repo.git#my-plugin")
assert url == "https://github.com/owner/repo.git"
assert subdir == "my-plugin"
def test_file_url_with_fragment_subdir(self):
url, subdir = _resolve_git_url("file:///tmp/repo#path/to/plugin")
assert url == "file:///tmp/repo"
assert subdir == "path/to/plugin"
def test_ssh_url_with_fragment_subdir(self):
url, subdir = _resolve_git_url("git@github.com:owner/repo.git#sub")
assert url == "git@github.com:owner/repo.git"
assert subdir == "sub"
# ── _resolve_subdir_within ──────────────────────────────────────────────────
class TestResolveSubdirWithin:
"""Subdirectory resolution stays within the clone and rejects traversal."""
def test_valid_subdir(self, tmp_path):
(tmp_path / "my-plugin").mkdir()
result = _resolve_subdir_within(tmp_path, "my-plugin")
assert result == (tmp_path / "my-plugin").resolve()
def test_valid_nested_subdir(self, tmp_path):
(tmp_path / "a" / "b" / "c").mkdir(parents=True)
result = _resolve_subdir_within(tmp_path, "a/b/c")
assert result == (tmp_path / "a" / "b" / "c").resolve()
def test_rejects_dot_dot_escape(self, tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
(tmp_path / "secret").mkdir()
with pytest.raises(PluginOperationError, match="escapes the repository"):
_resolve_subdir_within(clone, "../secret")
def test_rejects_absolute_path_escape(self, tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
# An absolute path resolves outside the clone root.
with pytest.raises(PluginOperationError, match="escapes the repository"):
_resolve_subdir_within(clone, "/etc")
def test_rejects_symlink_escape(self, tmp_path):
clone = tmp_path / "clone"
clone.mkdir()
outside = tmp_path / "outside"
outside.mkdir()
(clone / "link").symlink_to(outside)
with pytest.raises(PluginOperationError, match="escapes the repository"):
_resolve_subdir_within(clone, "link")
def test_rejects_missing_subdir(self, tmp_path):
with pytest.raises(PluginOperationError, match="does not exist"):
_resolve_subdir_within(tmp_path, "nope")
def test_rejects_file_not_dir(self, tmp_path):
(tmp_path / "afile").write_text("x")
with pytest.raises(PluginOperationError, match="not a directory"):
_resolve_subdir_within(tmp_path, "afile")
def test_invalid_three_parts_raises(self):
with pytest.raises(ValueError, match="Invalid plugin identifier"):
_resolve_git_url("a/b/c")
# ── _resolve_git_executable ─────────────────────────────────────────────────
@@ -793,90 +698,3 @@ class TestNoAutoActivation:
# The old code had: "Even with default config, check if a plugin registered one"
# The fix removes this. Verify it's gone.
assert "Even with default config, check if a plugin registered one" not in source
# ── End-to-end subdirectory install ──────────────────────────────────────────
class TestSubdirInstallE2E:
"""Install a plugin that lives in a subdirectory of a real local git repo."""
@staticmethod
def _make_repo_with_subdir_plugin(repo_root: Path) -> None:
"""Create a git repo where the plugin lives in ``./my-plugin/`` and the
repo root holds unrelated docs/tests."""
import subprocess as sp
repo_root.mkdir(parents=True, exist_ok=True)
# Root-level noise: docs + tests that should NOT be installed.
(repo_root / "README.md").write_text("# Monorepo docs\n")
(repo_root / "tests").mkdir()
(repo_root / "tests" / "test_x.py").write_text("def test_x():\n pass\n")
# The actual plugin in a subdirectory.
plugin_dir = repo_root / "my-plugin"
plugin_dir.mkdir()
(plugin_dir / "plugin.yaml").write_text(
"name: my-plugin\nmanifest_version: 1\ndescription: A subdir plugin\n"
)
(plugin_dir / "__init__.py").write_text("# plugin entry\n")
env = {
**os.environ,
"GIT_AUTHOR_NAME": "t",
"GIT_AUTHOR_EMAIL": "t@t",
"GIT_COMMITTER_NAME": "t",
"GIT_COMMITTER_EMAIL": "t@t",
}
sp.run(["git", "init", "-q"], cwd=repo_root, check=True, env=env)
sp.run(["git", "add", "-A"], cwd=repo_root, check=True, env=env)
sp.run(
["git", "commit", "-q", "-m", "init"],
cwd=repo_root,
check=True,
env=env,
)
def test_installs_only_the_subdir_plugin(self, tmp_path, monkeypatch):
if shutil.which("git") is None:
pytest.skip("git not available")
from hermes_cli import plugins_cmd as pc
repo_root = tmp_path / "monorepo"
self._make_repo_with_subdir_plugin(repo_root)
plugins_dir = tmp_path / "installed"
plugins_dir.mkdir()
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
identifier = f"file://{repo_root}#my-plugin"
target, manifest, name = pc._install_plugin_core(identifier, force=False)
# Installed under the plugin's own name, not the repo name.
assert name == "my-plugin"
assert manifest.get("name") == "my-plugin"
assert target == (plugins_dir / "my-plugin").resolve()
# The plugin's files are present...
assert (target / "plugin.yaml").exists()
assert (target / "__init__.py").exists()
# ...and the repo-root noise is NOT.
assert not (target / "README.md").exists()
assert not (target / "tests").exists()
def test_missing_subdir_raises(self, tmp_path, monkeypatch):
if shutil.which("git") is None:
pytest.skip("git not available")
from hermes_cli import plugins_cmd as pc
repo_root = tmp_path / "monorepo"
self._make_repo_with_subdir_plugin(repo_root)
plugins_dir = tmp_path / "installed"
plugins_dir.mkdir()
monkeypatch.setattr(pc, "_plugins_dir", lambda: plugins_dir)
identifier = f"file://{repo_root}#does-not-exist"
with pytest.raises(PluginOperationError, match="does not exist"):
pc._install_plugin_core(identifier, force=False)
-12
View File
@@ -442,18 +442,6 @@ class TestDeleteProfile:
with pytest.raises(FileNotFoundError):
delete_profile("nonexistent", yes=True)
def test_rmtree_failure_raises(self, profile_env):
profile_dir = create_profile("coder", no_alias=True)
set_active_profile("coder")
with patch("hermes_cli.profiles._cleanup_gateway_service"), \
patch("hermes_cli.profiles.shutil.rmtree", side_effect=PermissionError("locked")):
with pytest.raises(RuntimeError, match="Could not remove profile directory"):
delete_profile("coder", yes=True)
assert profile_dir.is_dir()
assert get_active_profile() == "default"
# ===================================================================
# TestListProfiles
-40
View File
@@ -896,46 +896,6 @@ def test_launch_tui_exports_model_provider_and_toolsets(monkeypatch, main_mod):
assert env["NODE_ENV"] == "production"
def test_launch_tui_applies_terminal_backend_config(
monkeypatch, main_mod, _isolate_hermes_home
):
captured = {}
config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml"
config_path.write_text(
"\n".join(
[
"terminal:",
" backend: docker",
" docker_image: example/hermes-tools:latest",
" docker_extra_args:",
" - --network=host",
]
),
encoding="utf-8",
)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False)
monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda tui_dir, tui_dev: (["node", "dist/entry.js"], Path(".")),
)
monkeypatch.setattr(
main_mod.subprocess,
"call",
lambda argv, cwd=None, env=None: captured.update({"env": env}) or 1,
)
with pytest.raises(SystemExit):
main_mod._launch_tui()
assert captured["env"]["TERMINAL_ENV"] == "docker"
assert captured["env"]["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest"
assert captured["env"]["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]'
def test_launch_tui_exit_code_42_relaunches_update(monkeypatch, main_mod):
from unittest.mock import patch
-33
View File
@@ -4146,39 +4146,6 @@ class TestPtyWebSocket:
assert env["HERMES_TUI_INLINE"] == "1"
assert env["HERMES_TUI_DISABLE_MOUSE"] == "1"
def test_resolve_chat_argv_applies_terminal_backend_config(
self, monkeypatch, _isolate_hermes_home
):
import hermes_cli.main as main_mod
config_path = Path(os.environ["HERMES_HOME"]) / "config.yaml"
config_path.write_text(
"\n".join(
[
"terminal:",
" backend: docker",
" docker_image: example/hermes-tools:latest",
" docker_extra_args:",
" - --network=host",
]
),
encoding="utf-8",
)
monkeypatch.delenv("TERMINAL_ENV", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_IMAGE", raising=False)
monkeypatch.delenv("TERMINAL_DOCKER_EXTRA_ARGS", raising=False)
monkeypatch.setattr(
main_mod,
"_make_tui_argv",
lambda project_root, tui_dev=False: (["node", "dist/entry.js"], "/tmp/ui-tui"),
)
_argv, _cwd, env = self.ws_module._resolve_chat_argv()
assert env["TERMINAL_ENV"] == "docker"
assert env["TERMINAL_DOCKER_IMAGE"] == "example/hermes-tools:latest"
assert env["TERMINAL_DOCKER_EXTRA_ARGS"] == '["--network=host"]'
def test_rejects_when_embedded_chat_disabled(self, monkeypatch):
monkeypatch.setattr(self.ws_module, "_DASHBOARD_EMBEDDED_CHAT_ENABLED", False)
from starlette.websockets import WebSocketDisconnect
+11 -78
View File
@@ -3,7 +3,6 @@ from __future__ import annotations
import json
import os
from base64 import b64encode
from pathlib import Path
from typing import Any, Dict
@@ -41,9 +40,6 @@ _PHOTON_ENV = (
"PHOTON_PROJECT_ID",
"PHOTON_PROJECT_SECRET",
"PHOTON_DASHBOARD_PROJECT_ID",
"PHOTON_SPECTRUM_HOST",
"PHOTON_ALLOWED_USERS",
"PHOTON_HOME_CHANNEL",
)
@@ -102,64 +98,6 @@ def test_store_project_credentials_writes_env(tmp_hermes_home: Path) -> None:
assert "PHOTON_PROJECT_SECRET=sek-ret" in env_text
def test_store_user_numbers_round_trip(tmp_hermes_home: Path) -> None:
photon_auth.store_user_numbers(
phone_number="+15551234567",
assigned_phone_number="+16282679185",
user_id="user-uuid",
dashboard_project_id="dash-uuid",
)
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned == "+16282679185"
summary = photon_auth.credential_summary()
assert summary["phone_number"] == "+15551234567"
assert summary["assigned_phone_number"] == "+16282679185"
rendered: list[str] = []
photon_auth.print_credential_summary(rendered.append)
assert " my number : +15551234567" in rendered[0]
assert " assigned number : +16282679185" in rendered[0]
def test_load_user_numbers_falls_back_to_home_channel(
tmp_hermes_home: Path,
) -> None:
from hermes_cli.config import save_env_value
save_env_value("PHOTON_HOME_CHANNEL", "+15551234567")
phone, assigned = photon_auth.load_user_numbers()
assert phone == "+15551234567"
assert assigned is None
def test_refresh_user_numbers_reads_existing_assignment(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
photon_auth.store_user_numbers(phone_number="+15551234567")
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
assert kwargs.get("headers", {}).get("Authorization") == (
"Basic " + b64encode(b"sp:secret").decode("ascii")
)
assert url.endswith("/projects/sp/users/")
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
"id": "user-uuid",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}]}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
phone, assigned = photon_auth.refresh_user_numbers("sp", "secret")
assert phone == "+15551234567"
assert assigned == "+16282679185"
assert photon_auth.load_user_numbers() == ("+15551234567", "+16282679185")
def test_load_project_credentials_env_override(
tmp_hermes_home: Path, monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -365,7 +303,7 @@ def test_regenerate_project_secret(monkeypatch: pytest.MonkeyPatch) -> None:
def test_create_user_rejects_invalid_phone() -> None:
with pytest.raises(ValueError, match="E.164"):
photon_auth.create_user("proj", "secret", phone_number="not-a-number")
photon_auth.create_user("tok", "proj", phone_number="not-a-number")
def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> None:
@@ -375,30 +313,27 @@ def test_create_user_posts_dashboard_shape(monkeypatch: pytest.MonkeyPatch) -> N
captured["url"] = url
captured["body"] = kwargs.get("json")
captured["headers"] = kwargs.get("headers")
return _FakeResponse(json_body={"succeed": True, "data": {
return _FakeResponse(json_body={"success": True, "user": {
"id": "user-uuid", "phoneNumber": "+15551234567",
}})
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
user = photon_auth.create_user("proj-id", "secret", phone_number="+15551234567")
user = photon_auth.create_user("tok", "proj-id", phone_number="+15551234567")
assert user["id"] == "user-uuid"
assert captured["body"]["type"] == "shared"
assert captured["body"]["phoneNumber"] == "+15551234567"
assert captured["headers"]["Authorization"] == (
"Basic " + b64encode(b"proj-id:secret").decode("ascii")
)
assert captured["url"].endswith("/projects/proj-id/users/")
assert captured["headers"]["Authorization"] == "Bearer tok"
assert "/projects/proj-id/spectrum/users" in captured["url"]
def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
posted = {"n": 0}
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"users": [{
return _FakeResponse(json_body=[{
"id": "u1",
"phoneNumber": "+1 (555) 123-4567",
"assignedPhoneNumber": "+16282679185",
}]}})
}])
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
posted["n"] += 1
@@ -408,7 +343,7 @@ def test_register_user_if_absent_dedup(monkeypatch: pytest.MonkeyPatch) -> None:
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
# Same number, different formatting — should match and NOT create.
user, created = photon_auth.register_user_if_absent(
"proj", "secret", phone_number="+15551234567",
"tok", "proj", phone_number="+15551234567",
)
assert created is False
assert user["id"] == "u1"
@@ -431,15 +366,15 @@ def test_user_assigned_line() -> None:
def test_register_user_if_absent_creates(monkeypatch: pytest.MonkeyPatch) -> None:
def fake_get(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"users": []}})
return _FakeResponse(json_body=[])
def fake_post(url: str, **kwargs: Any) -> _FakeResponse:
return _FakeResponse(json_body={"succeed": True, "data": {"id": "u-new"}})
return _FakeResponse(json_body={"success": True, "user": {"id": "u-new"}})
monkeypatch.setattr(photon_auth.httpx, "get", fake_get)
monkeypatch.setattr(photon_auth.httpx, "post", fake_post)
user, created = photon_auth.register_user_if_absent(
"proj", "secret", phone_number="+15551234567",
"tok", "proj", phone_number="+15551234567",
)
assert created is True
assert user["id"] == "u-new"
@@ -500,8 +435,6 @@ def test_credential_summary_no_secret_leak(
assert summary["project_key"].startswith("")
assert summary["spectrum_project_id"] == "sp-uuid"
assert summary["dashboard_project_id"] == "dash-uuid"
assert summary["phone_number"].startswith("✗ missing")
assert summary["assigned_phone_number"].startswith("✗ missing")
# ---------------------------------------------------------------------------
@@ -101,18 +101,6 @@ def _attachment_event(
}
def _voice_event(
content: Dict[str, Any], msg_id: str = "spc-msg-voice"
) -> Dict[str, Any]:
return {
"messageId": msg_id,
"space": {"id": "+15551234567", "type": "dm", "phone": "+15551234567"},
"sender": {"id": "+15551234567"},
"content": {"type": "voice", **content},
"timestamp": "2026-05-14T19:06:32.000Z",
}
@pytest.mark.asyncio
async def test_dispatch_attachment_without_bytes_surfaces_marker(
monkeypatch: pytest.MonkeyPatch,
@@ -168,64 +156,6 @@ async def test_dispatch_attachment_downloads_image(
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_dispatch_voice_downloads_audio(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Inbound Spectrum voice content is cached and routed to auto-STT."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
raw = b"OggS" + b"\x00" * 32
event = _voice_event(
{
"name": "note.ogg",
"mimeType": "audio/ogg",
"duration": 7,
"size": len(raw),
"data": base64.b64encode(raw).decode("ascii"),
"encoding": "base64",
}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert ev.message_type == MessageType.VOICE
assert ev.media_types == ["audio/ogg"]
assert len(ev.media_urls) == 1
cached = Path(ev.media_urls[0])
try:
assert cached.is_file()
assert cached.read_bytes() == raw
assert ev.text == "(voice)"
finally:
cached.unlink(missing_ok=True)
@pytest.mark.asyncio
async def test_dispatch_voice_without_bytes_surfaces_marker(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""Metadata-only voice still tells the agent a voice note arrived."""
adapter = _make_adapter(monkeypatch)
captured = _capture(adapter, monkeypatch)
event = _voice_event(
{"name": "note.m4a", "mimeType": "audio/mp4", "duration": 12, "size": 12345}
)
await adapter._dispatch_inbound(event)
assert len(captured) == 1
ev = captured[0]
assert "Photon voice received" in ev.text
assert "note.m4a" in ev.text
assert "duration: 12s" in ev.text
assert ev.message_type == MessageType.VOICE
assert ev.media_urls == []
assert ev.media_types == []
@pytest.mark.asyncio
async def test_dispatch_attachment_downloads_document(
monkeypatch: pytest.MonkeyPatch,
@@ -292,20 +222,6 @@ def test_is_duplicate_window(monkeypatch: pytest.MonkeyPatch) -> None:
assert adapter._is_duplicate("id-1") is True # still dup
def test_is_duplicate_hard_size_bound(monkeypatch: pytest.MonkeyPatch) -> None:
# A burst of unique ids within the window must not grow the dedup map past
# its bound — evict oldest (LRU), not only expired entries.
import plugins.platforms.photon.adapter as ad
monkeypatch.setattr(ad, "_DEDUP_MAX_SIZE", 5)
adapter = _make_adapter(monkeypatch)
for i in range(100):
adapter._is_duplicate(f"id-{i}")
assert len(adapter._seen_messages) <= 5
assert adapter._is_duplicate("id-99") is True # recent still deduped
assert adapter._is_duplicate("id-0") is False # oldest evicted
def test_check_requirements_without_node(monkeypatch: pytest.MonkeyPatch) -> None:
# If no node binary on PATH the adapter should refuse to start.
from plugins.platforms.photon import adapter as adapter_mod
-363
View File
@@ -12,7 +12,6 @@ import warnings
from pathlib import Path
from types import SimpleNamespace
import pytest
import yaml
from hermes_cli.plugins import PluginManager
@@ -154,33 +153,6 @@ def _fresh_plugin(monkeypatch, fake):
return plugin
def _wrapped_downstream_error(original):
class _DownstreamExecutionError(Exception):
def __init__(self, original):
super().__init__(str(original))
self.original = original
return _DownstreamExecutionError(original)
def _enable_adaptive_plugin(tmp_path, monkeypatch) -> None:
plugins_toml = tmp_path / "plugins.toml"
plugins_toml.write_text(
"""
version = 1
[[components]]
kind = "adaptive"
enabled = true
[components.config.tool_parallelism]
mode = "observe_only"
""",
encoding="utf-8",
)
monkeypatch.setenv("HERMES_NEMO_RELAY_PLUGINS_TOML", str(plugins_toml))
def test_manifest_fields():
data = yaml.safe_load((PLUGIN_DIR / "plugin.yaml").read_text())
assert data["name"] == "nemo_relay"
@@ -811,220 +783,6 @@ mode = "observe_only"
}
def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
def native_like_execute(name, request, func, **kwargs):
fake.events.append(("llm.execute.start", name, request.content, kwargs))
try:
return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content}))
except Exception as exc:
raise RuntimeError(f"internal error: {type(exc).__name__}: {exc}") from None
fake.llm.execute = native_like_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
class ProviderAuthError(Exception):
status_code = 403
provider_error = ProviderAuthError("provider auth failed")
def next_call(request):
raise _wrapped_downstream_error(provider_error)
with pytest.raises(ProviderAuthError) as caught:
plugin.on_llm_execution_middleware(
session_id="s1",
provider="anthropic",
model="demo-model",
request={"messages": [{"role": "user", "content": "hi"}]},
next_call=next_call,
)
assert caught.value is provider_error
assert caught.value.status_code == 403
def test_nemo_relay_adaptive_llm_execution_preserves_downstream_error_with_relay_suffix(
tmp_path, monkeypatch
):
# Guards the startswith (vs exact ==) match in _is_relay_wrapped_callback_error:
# Relay re-wraps the callback failure with its canonical prefix but APPENDS a
# trailing suffix. Exact equality would miss this and surface Relay's wrapper;
# prefix matching must still recover the original downstream error.
fake = _FakeNemoRelay()
def native_like_execute(name, request, func, **kwargs):
try:
return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content}))
except Exception as exc:
raise RuntimeError(f"internal error: {type(exc).__name__}: {exc} (retried 3x)") from None
fake.llm.execute = native_like_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
class ProviderAuthError(Exception):
status_code = 403
provider_error = ProviderAuthError("provider auth failed")
def next_call(request):
raise _wrapped_downstream_error(provider_error)
with pytest.raises(ProviderAuthError) as caught:
plugin.on_llm_execution_middleware(
session_id="s1",
provider="anthropic",
model="demo-model",
request={"messages": [{"role": "user", "content": "hi"}]},
next_call=next_call,
)
assert caught.value is provider_error
assert caught.value.status_code == 403
def test_nemo_relay_adaptive_llm_execution_keeps_unrelated_internal_error(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
relay_error = RuntimeError("internal error: relay setup failed")
def internal_error_execute(name, request, func, **kwargs):
raise relay_error
fake.llm.execute = internal_error_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
with pytest.raises(RuntimeError) as caught:
plugin.on_llm_execution_middleware(
session_id="s1",
provider="anthropic",
model="demo-model",
request={"messages": [{"role": "user", "content": "hi"}]},
next_call=lambda request: {"raw": request},
)
assert caught.value is relay_error
def test_nemo_relay_adaptive_llm_execution_keeps_wrapped_relay_error_after_downstream_failure(
tmp_path, monkeypatch
):
fake = _FakeNemoRelay()
relay_error = RuntimeError("internal error: RuntimeError: relay policy blocked after downstream")
def translated_execute(name, request, func, **kwargs):
try:
return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content}))
except Exception:
raise relay_error
fake.llm.execute = translated_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
def next_call(request):
raise _wrapped_downstream_error(RuntimeError("provider failed"))
with pytest.raises(RuntimeError) as caught:
plugin.on_llm_execution_middleware(
session_id="s1",
provider="anthropic",
model="demo-model",
request={"messages": [{"role": "user", "content": "hi"}]},
next_call=next_call,
)
assert caught.value is relay_error
def test_nemo_relay_adaptive_llm_execution_keeps_relay_translated_error(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
class RelayPolicyError(Exception):
pass
relay_error = RelayPolicyError("relay policy blocked")
def translated_execute(name, request, func, **kwargs):
try:
return func(_FakeLLMRequest(request.headers, {"intercepted": True, **request.content}))
except Exception:
raise relay_error
fake.llm.execute = translated_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
provider_error = RuntimeError("provider failed")
def next_call(request):
raise _wrapped_downstream_error(provider_error)
with pytest.raises(RelayPolicyError) as caught:
plugin.on_llm_execution_middleware(
session_id="s1",
provider="anthropic",
model="demo-model",
request={"messages": [{"role": "user", "content": "hi"}]},
next_call=next_call,
)
assert caught.value is relay_error
def test_nemo_relay_downstream_unwrap_matches_real_middleware_wrapper_shape(monkeypatch):
# Regression guard against core/plugin drift. The synthetic tests above model
# the downstream-error wrapper with a local class, so they keep passing even
# if core middleware renames its private ``_DownstreamExecutionError`` or drops
# ``.original`` -- the exact shape the plugin matches by name at
# ``_original_downstream_error``. Capture the wrapper the REAL
# ``hermes_cli.middleware._run_execution_chain`` hands to a middleware
# callback's ``next_call`` and assert the plugin's detector unwraps it to the
# original exception. If core middleware changes the wrapper shape, this fails
# here instead of silently defeating the unwrap in production.
from hermes_cli import middleware
from plugins.observability.nemo_relay import _original_downstream_error
class ProviderError(Exception):
status_code = 403
provider_error = ProviderError("provider auth failed")
captured: dict[str, Exception] = {}
def terminal_call(payload):
raise provider_error
def capturing_callback(**kwargs):
next_call = kwargs["next_call"]
try:
return next_call(kwargs.get("request"))
except Exception as exc:
captured["wrapper"] = exc
# Surface the original so the chain unwinds without re-wrapping noise.
raise _original_downstream_error(exc) from None
with pytest.raises(ProviderError) as caught:
middleware._run_execution_chain(
"llm",
[capturing_callback],
terminal_call,
request={"messages": []},
)
wrapper = captured["wrapper"]
# The wrapper the plugin sees must match what _original_downstream_error keys on.
assert wrapper.__class__.__name__ == "_DownstreamExecutionError"
assert isinstance(getattr(wrapper, "original", None), BaseException)
assert _original_downstream_error(wrapper) is provider_error
assert caught.value is provider_error
assert caught.value.status_code == 403
def _adaptive_llm_execute_mode(tmp_path, monkeypatch, plugins_toml_text: str) -> str:
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
@@ -1162,127 +920,6 @@ mode = "observe_only"
assert execute_start[3]["data"]["tool_call_id"] == "tool-1"
def test_nemo_relay_adaptive_tool_execution_preserves_downstream_error(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
def native_like_execute(name, args, func, **kwargs):
fake.events.append(("tool.execute.start", name, args, kwargs))
try:
return func({"intercepted": True, **args})
except Exception as exc:
raise RuntimeError(f"internal error: {type(exc).__name__}: {exc}") from None
fake.tools.execute = native_like_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
class ToolAuthError(Exception):
status_code = 403
tool_error = ToolAuthError("tool auth failed")
def next_call(args):
raise _wrapped_downstream_error(tool_error)
with pytest.raises(ToolAuthError) as caught:
plugin.on_tool_execution_middleware(
session_id="s1",
tool_name="terminal",
args={"command": "pwd"},
next_call=next_call,
)
assert caught.value is tool_error
assert caught.value.status_code == 403
def test_nemo_relay_adaptive_tool_execution_keeps_unrelated_internal_error(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
relay_error = RuntimeError("internal error: relay setup failed")
def internal_error_execute(name, args, func, **kwargs):
raise relay_error
fake.tools.execute = internal_error_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
with pytest.raises(RuntimeError) as caught:
plugin.on_tool_execution_middleware(
session_id="s1",
tool_name="terminal",
args={"command": "pwd"},
next_call=lambda args: {"raw": args},
)
assert caught.value is relay_error
def test_nemo_relay_adaptive_tool_execution_keeps_wrapped_relay_error_after_downstream_failure(
tmp_path, monkeypatch
):
fake = _FakeNemoRelay()
relay_error = RuntimeError("internal error: RuntimeError: relay policy blocked after downstream")
def translated_execute(name, args, func, **kwargs):
try:
return func({"intercepted": True, **args})
except Exception:
raise relay_error
fake.tools.execute = translated_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
def next_call(args):
raise _wrapped_downstream_error(RuntimeError("tool failed"))
with pytest.raises(RuntimeError) as caught:
plugin.on_tool_execution_middleware(
session_id="s1",
tool_name="terminal",
args={"command": "pwd"},
next_call=next_call,
)
assert caught.value is relay_error
def test_nemo_relay_adaptive_tool_execution_keeps_relay_translated_error(tmp_path, monkeypatch):
fake = _FakeNemoRelay()
class RelayPolicyError(Exception):
pass
relay_error = RelayPolicyError("relay policy blocked")
def translated_execute(name, args, func, **kwargs):
try:
return func({"intercepted": True, **args})
except Exception:
raise relay_error
fake.tools.execute = translated_execute
plugin = _fresh_plugin(monkeypatch, fake)
_enable_adaptive_plugin(tmp_path, monkeypatch)
tool_error = RuntimeError("tool failed")
def next_call(args):
raise _wrapped_downstream_error(tool_error)
with pytest.raises(RelayPolicyError) as caught:
plugin.on_tool_execution_middleware(
session_id="s1",
tool_name="terminal",
args={"command": "pwd"},
next_call=next_call,
)
assert caught.value is relay_error
def test_nemo_relay_tool_execution_middleware_calls_through_without_adaptive(monkeypatch):
fake = _FakeNemoRelay()
plugin = _fresh_plugin(monkeypatch, fake)
+4 -4
View File
@@ -124,11 +124,11 @@ class TestOpenRouterProfileParity:
def test_reasoning_full_config(self, transport):
rc = {"enabled": True, "effort": "high"}
legacy = transport.build_kwargs(
model="deepseek/deepseek-chat", messages=_msgs(), tools=None,
model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("openrouter"), supports_reasoning=True, reasoning_config=rc,
)
profile = transport.build_kwargs(
model="deepseek/deepseek-chat", messages=_msgs(), tools=None,
model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("openrouter"),
supports_reasoning=True, reasoning_config=rc,
)
@@ -136,11 +136,11 @@ class TestOpenRouterProfileParity:
def test_default_reasoning(self, transport):
legacy = transport.build_kwargs(
model="deepseek/deepseek-chat", messages=_msgs(), tools=None,
model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("openrouter"), supports_reasoning=True,
)
profile = transport.build_kwargs(
model="deepseek/deepseek-chat", messages=_msgs(), tools=None,
model="anthropic/claude-sonnet-4.6", messages=_msgs(), tools=None,
provider_profile=get_provider_profile("openrouter"),
supports_reasoning=True,
)
-71
View File
@@ -169,77 +169,6 @@ class TestOpenRouterProfile:
)
assert eb["reasoning"] == {"enabled": False}
def test_reasoning_disable_omitted_for_mandatory_anthropic(self):
"""Reasoning-mandatory Anthropic models (4.6+/fable) reject any disable
form: OpenRouter translates ``reasoning: {enabled: false}`` into
Anthropic's ``thinking: {type: disabled}``, which 400s. The profile must
omit ``reasoning`` so the model falls back to adaptive thinking instead.
"""
p = get_provider_profile("openrouter")
for model in (
"anthropic/claude-fable-5", # new named model
"anthropic/claude-some-future-7", # unknown → default mandatory
"anthropic/claude-opus-4.8",
"anthropic/claude-opus-4.6",
):
for cfg in ({"enabled": False}, {"effort": "none"}):
eb, _ = p.build_api_kwargs_extras(
reasoning_config=cfg,
supports_reasoning=True,
model=model,
)
assert "reasoning" not in eb, (model, cfg, eb)
def test_reasoning_disable_kept_for_legacy_anthropic(self):
"""Older Anthropic models still accept an explicit disable form, so the
profile must keep forwarding it."""
p = get_provider_profile("openrouter")
for model in (
"anthropic/claude-3.7-sonnet",
"anthropic/claude-opus-4.5",
"anthropic/claude-sonnet-4.5",
):
eb, _ = p.build_api_kwargs_extras(
reasoning_config={"enabled": False},
supports_reasoning=True,
model=model,
)
assert eb["reasoning"] == {"enabled": False}, (model, eb)
def test_reasoning_disable_kept_for_non_anthropic(self):
"""Non-Anthropic models (DeepSeek, Qwen, …) disable reasoning fine; the
Anthropic-mandatory guard must not touch them."""
p = get_provider_profile("openrouter")
for model in ("deepseek/deepseek-chat", "qwen/qwen3-max", "openai/gpt-5.4"):
eb, _ = p.build_api_kwargs_extras(
reasoning_config={"enabled": False},
supports_reasoning=True,
model=model,
)
assert eb["reasoning"] == {"enabled": False}, (model, eb)
def test_reasoning_omitted_for_mandatory_anthropic_even_when_enabled(self):
"""Reasoning-mandatory Anthropic models (4.6+/fable) use adaptive
thinking OpenRouter ignores reasoning.effort for them, and sending any
reasoning field makes OpenRouter emit thinking.type.disabled on
tool-continuation turns (whose assistant tool_calls carry no thinking
block), 400ing every turn after the first tool call. The profile must
omit reasoning entirely so the model defaults to adaptive.
"""
p = get_provider_profile("openrouter")
for cfg in (
{"enabled": True, "effort": "medium"},
{"enabled": True, "effort": "xhigh"},
{"effort": "high"},
{"enabled": True},
):
eb, _ = p.build_api_kwargs_extras(
reasoning_config=cfg,
supports_reasoning=True,
model="anthropic/claude-fable-5",
)
assert "reasoning" not in eb, (cfg, eb)
def test_default_reasoning(self):
p = get_provider_profile("openrouter")
eb, _ = p.build_api_kwargs_extras(supports_reasoning=True)
+2 -16
View File
@@ -160,7 +160,7 @@ class TestOpenRouterParity:
"""OpenRouter passes the FULL reasoning_config dict, not just effort."""
rc = {"enabled": True, "effort": "high"}
kw = transport.build_kwargs(
model="deepseek/deepseek-chat",
model="anthropic/claude-sonnet-4.6",
messages=_simple_messages(),
tools=None,
provider_profile=get_provider_profile("openrouter"),
@@ -169,24 +169,10 @@ class TestOpenRouterParity:
)
assert kw["extra_body"]["reasoning"] == rc
def test_reasoning_omitted_for_mandatory_anthropic(self, transport):
"""Adaptive-thinking Anthropic models (4.6+/fable) get NO reasoning
field sending one makes OpenRouter emit thinking.type.disabled on
tool-replay turns, which the model 400s on."""
kw = transport.build_kwargs(
model="anthropic/claude-sonnet-4.6",
messages=_simple_messages(),
tools=None,
provider_profile=get_provider_profile("openrouter"),
supports_reasoning=True,
reasoning_config={"enabled": True, "effort": "high"},
)
assert "reasoning" not in kw.get("extra_body", {})
def test_default_reasoning_when_no_config(self, transport):
"""When supports_reasoning=True but no config, adds default."""
kw = transport.build_kwargs(
model="deepseek/deepseek-chat",
model="anthropic/claude-sonnet-4.6",
messages=_simple_messages(),
tools=None,
provider_profile=get_provider_profile("openrouter"),
@@ -84,56 +84,6 @@ class TestRunConversationCodexPath:
assert result["codex_thread_id"] == "thread-stub-1"
assert result["codex_turn_id"] == "turn-stub-1"
def test_codex_app_server_token_usage_updates_session_accounting(self, monkeypatch):
def fake_run_turn(self, user_input: str, **kwargs):
return TurnResult(
final_text="done",
projected_messages=[{"role": "assistant", "content": "done"}],
turn_id="turn-usage-1",
thread_id="thread-usage-1",
token_usage_last={
"totalTokens": 130,
"inputTokens": 80,
"cachedInputTokens": 20,
"outputTokens": 25,
"reasoningOutputTokens": 5,
},
model_context_window=200000,
)
monkeypatch.setattr(CodexAppServerSession, "run_turn", fake_run_turn)
monkeypatch.setattr(
CodexAppServerSession, "ensure_started", lambda self: "thread-usage-1"
)
agent = _make_codex_agent()
with patch.object(agent, "_spawn_background_review", return_value=None):
result = agent.run_conversation("hello")
assert result["api_calls"] == 1
assert result["prompt_tokens"] == 100
assert result["completion_tokens"] == 25
assert result["total_tokens"] == 130
assert result["input_tokens"] == 80
assert result["output_tokens"] == 25
assert result["cache_read_tokens"] == 20
assert result["cache_write_tokens"] == 0
assert result["reasoning_tokens"] == 5
assert result["last_prompt_tokens"] == 100
assert agent.session_api_calls == 1
assert agent.session_prompt_tokens == 100
assert agent.session_completion_tokens == 25
assert agent.session_total_tokens == 130
assert agent.session_input_tokens == 80
assert agent.session_output_tokens == 25
assert agent.session_cache_read_tokens == 20
assert agent.session_cache_write_tokens == 0
assert agent.session_reasoning_tokens == 5
assert agent.context_compressor.last_prompt_tokens == 100
assert agent.context_compressor.last_completion_tokens == 25
assert agent.context_compressor.last_total_tokens == 130
assert agent.context_compressor.context_length == 200000
def test_projected_messages_are_spliced(self, fake_session):
agent = _make_codex_agent()
with patch.object(agent, "_spawn_background_review", return_value=None):
-37
View File
@@ -25,40 +25,3 @@ class TestParseOpenRouterOutputCap:
msg = ("maximum context length is 1000 tokens "
"(900 of text input, 200 of tool input, 0 in the output)")
assert parse_available_output_tokens_from_error(msg) is None
class TestParseCharBasedOutputCap:
"""LM Studio / llama.cpp report context in tokens but prompt in characters.
These servers send a hard 400 even on a trivial prompt when the default
output cap equals the context window (#42741): the request asks for the
whole window as output, leaving zero room for input.
"""
def test_char_based_output_cap_format(self):
msg = ("This model's maximum context length is 65536 tokens. However, "
"you requested 65536 output tokens and your prompt contains "
"77409 characters (more than 0 characters, which is the upper "
"bound for 0 input tokens). Please reduce the length of the "
"input prompt or the number of requested output tokens.")
# est input = ceil(77409 / 3) = 25803; available = 65536 - 25803 = 39733
assert parse_available_output_tokens_from_error(msg) == 39733
def test_char_based_leaves_room_for_input(self):
# The whole point: the retried output cap + the estimated input must
# fit inside the reported context window.
ctx = 65536
chars = 77409
available = parse_available_output_tokens_from_error(
f"maximum context length is {ctx} tokens. However, you requested "
f"{ctx} output tokens and your prompt contains {chars} characters."
)
assert available is not None
assert available + (chars + 2) // 3 <= ctx
def test_char_based_no_room_returns_none(self):
# Prompt larger than the window (in tokens) -> not an output-cap fix;
# let the prompt-too-long / compression path handle it.
msg = ("maximum context length is 1000 tokens. However, you requested "
"1000 output tokens and your prompt contains 9000 characters.")
assert parse_available_output_tokens_from_error(msg) is None
-75
View File
@@ -1,5 +1,4 @@
from pathlib import Path
import re
import tomllib
import pytest
@@ -15,22 +14,6 @@ find_packages = pytest.importorskip("setuptools", exc_type=ImportError).find_pac
REPO_ROOT = Path(__file__).resolve().parents[1]
def _distribution_name(requirement: str) -> str:
"""Extract the PEP 508 distribution name from a requirement string.
Robust to markers (``; python_version < '3.12'``), direct references
(``name @ https://...``), extras (``name[extra]``) and every version
operator (``==``, ``>=``, ``<=``, ``~=``, ``!=``, ``<``, ``>``), so a
future dep declared with any valid specifier shape doesn't silently
mis-parse here.
"""
spec = requirement.split(";", 1)[0] # drop environment markers
spec = spec.split("@", 1)[0] # drop direct-reference URLs
spec = spec.split("[", 1)[0] # drop extras
spec = re.split(r"[=<>!~]", spec, maxsplit=1)[0] # drop any version operator
return spec.strip().lower()
def _packages_find_include():
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
return data["tool"]["setuptools"]["packages"]["find"]["include"]
@@ -78,27 +61,6 @@ def test_every_on_disk_subpackage_is_covered_by_packages_find():
)
def test_packaging_declared_as_core_dependency():
"""Regression for #40503.
``packaging`` is imported directly on three production paths
(plugins/memory/hindsight/__init__.py, tools/lazy_deps.py,
hermes_cli/main.py) yet was undeclared, so it only reached users
transitively. The slim Docker image shipped without it, silently
disabling Hindsight append-mode and version-constraint checks. It must
be a declared core dependency so it installs everywhere and the
update-repair step (``_verify_core_dependencies_installed``) guards it.
"""
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
core = data["project"]["dependencies"]
names = {_distribution_name(dep) for dep in core}
assert "packaging" in names, (
"packaging is imported on production paths (hindsight version compare, "
"lazy_deps version constraints, requirement parsing) and must be a "
"declared core dependency, not a transitive — see #40503"
)
def test_faster_whisper_is_not_a_base_dependency():
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
deps = data["project"]["dependencies"]
@@ -264,40 +226,3 @@ def test_locale_catalogs_ship_in_both_wheel_and_sdist():
# Every on-disk catalog has the .yaml extension the globs above match.
on_disk = list((REPO_ROOT / "locales").glob("*.yaml"))
assert on_disk, "expected locales/*.yaml catalogs on disk"
def test_optional_mcps_manifests_ship_in_both_wheel_and_sdist():
"""Regression guard: the shipped MCP catalog must reach packaged installs.
hermes_cli/mcp_catalog.py resolves the catalog via get_optional_mcps_dir()
-> _get_packaged_data_dir("optional-mcps"), and list_catalog() returns []
when that directory is absent. optional-mcps/ is a bare data directory (no
__init__.py), invisible to packages.find and package-data. It must ship as
setuptools data-files (wheel) AND be grafted in MANIFEST.in (sdist), or
`hermes mcp catalog` and the dashboard catalog screen come up empty on
pip / Homebrew / Nix installs even though the manifests exist in the repo.
data-files flattens every glob match into its single target dir, so each
catalog entry needs its OWN target to preserve the optional-mcps/<name>/
directory the catalog iterates over. This asserts one target per on-disk
entry so a newly-added MCP can't silently miss the wheel.
"""
entries = sorted(
p.parent.name for p in (REPO_ROOT / "optional-mcps").glob("*/manifest.yaml")
)
assert entries, "expected optional-mcps/<name>/manifest.yaml on disk"
data = tomllib.loads((REPO_ROOT / "pyproject.toml").read_text(encoding="utf-8"))
data_files = data["tool"]["setuptools"].get("data-files", {})
for name in entries:
target = f"optional-mcps/{name}"
assert target in data_files, (
f"pyproject [tool.setuptools.data-files] must declare a '{target}' "
f"target so the wheel ships optional-mcps/{name}/manifest.yaml "
f"(data-files flattens globs, so each catalog entry needs its own target)"
)
manifest = (REPO_ROOT / "MANIFEST.in").read_text(encoding="utf-8")
assert "graft optional-mcps" in manifest, (
"MANIFEST.in must `graft optional-mcps` so the sdist ships MCP manifests"
)
-267
View File
@@ -902,115 +902,6 @@ def test_startup_runtime_detects_provider_for_model_env(monkeypatch):
)
def test_load_fallback_model_merges_chain_providers_first(monkeypatch):
# Parity with HermesCLI / gateway: fallback_providers stays first and keeps
# its order, with any distinct legacy fallback_model entry merged in after
# (deduped on provider/model/base_url).
fallback_chain = [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
]
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {
"fallback_model": {"provider": "legacy", "model": "legacy-model"},
"fallback_providers": fallback_chain,
},
)
assert server._load_fallback_model() == [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
{"provider": "legacy", "model": "legacy-model"},
]
def test_make_agent_passes_configured_fallback_chain(monkeypatch):
captured = {}
fallback_chain = [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
]
def fake_agent(**kwargs):
captured.update(kwargs)
return types.SimpleNamespace(model=kwargs.get("model"))
monkeypatch.delenv("HERMES_MODEL", raising=False)
monkeypatch.delenv("HERMES_INFERENCE_MODEL", raising=False)
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {
"model": {"default": "gpt-5.5", "provider": "openai-codex"},
"fallback_providers": fallback_chain,
},
)
monkeypatch.setattr(
"hermes_cli.runtime_provider.resolve_runtime_provider",
lambda requested=None, target_model=None: {
"provider": "openai-codex",
"base_url": "https://chatgpt.com/backend-api/codex",
"api_key": "token",
"api_mode": "codex_responses",
"credential_pool": None,
},
)
monkeypatch.setattr("run_agent.AIAgent", fake_agent)
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"])
monkeypatch.setattr(server, "_get_db", lambda: None)
agent = server._make_agent("sid", "session-key")
assert agent.model == "gpt-5.5"
assert captured["fallback_model"] == fallback_chain
assert captured["platform"] == "tui"
def test_background_agent_kwargs_preserves_full_fallback_chain(monkeypatch):
chain = [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
{"provider": "anthropic", "model": "claude-sonnet-4-6"},
]
agent = types.SimpleNamespace(
model="gpt-5.5",
provider="openai-codex",
_fallback_chain=chain,
)
monkeypatch.setattr(server, "_load_cfg", lambda: {"max_turns": 25})
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"])
monkeypatch.setattr(server, "_get_db", lambda: None)
kwargs = server._background_agent_kwargs(agent, "task-id")
assert kwargs["fallback_model"] == chain
def test_background_agent_kwargs_preserves_empty_fallback_chain(monkeypatch):
agent = types.SimpleNamespace(
model="gpt-5.5",
provider="anthropic",
_fallback_chain=[],
)
monkeypatch.setattr(
server,
"_load_cfg",
lambda: {
"max_turns": 25,
"fallback_providers": [
{"provider": "openrouter", "model": "openai/gpt-5.5"},
],
},
)
monkeypatch.setattr(server, "_load_enabled_toolsets", lambda: ["file"])
monkeypatch.setattr(server, "_get_db", lambda: None)
kwargs = server._background_agent_kwargs(agent, "task-id")
assert kwargs["fallback_model"] == []
def test_startup_runtime_resolves_short_alias_without_network(monkeypatch):
monkeypatch.setenv("HERMES_MODEL", "sonnet")
monkeypatch.delenv("HERMES_TUI_PROVIDER", raising=False)
@@ -2914,164 +2805,6 @@ def test_image_attach_accepts_unquoted_screenshot_path_with_spaces(monkeypatch):
assert len(server._sessions["sid"]["attached_images"]) == 1
def test_file_attach_uploads_remote_file_into_session_workspace(monkeypatch, tmp_path):
"""Remote case: client path doesn't exist on gateway → decode data_url bytes."""
workspace = tmp_path / "workspace"
workspace.mkdir()
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: None
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {
"session_id": "sid",
"path": "/Users/alice/Downloads/report.txt",
"name": "report.txt",
"data_url": "data:text/plain;base64,aGVsbG8gd29ybGQ=",
},
}
)
stored = workspace / ".hermes" / "desktop-attachments" / "report.txt"
assert resp["result"]["attached"] is True
assert resp["result"]["uploaded"] is True
assert resp["result"]["path"] == str(stored)
assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/report.txt"
assert stored.read_text(encoding="utf-8") == "hello world"
finally:
server._sessions.pop("sid", None)
def test_file_attach_copies_gateway_visible_file_outside_workspace(monkeypatch, tmp_path):
"""Local case: gateway can see the file but it's outside the workspace → copy in."""
workspace = tmp_path / "workspace"
workspace.mkdir()
source = tmp_path / "outside.txt"
source.write_text("outside workspace", encoding="utf-8")
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: source
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {"session_id": "sid", "path": str(source)},
}
)
stored = workspace / ".hermes" / "desktop-attachments" / "outside.txt"
assert resp["result"]["attached"] is True
assert resp["result"]["uploaded"] is True
assert resp["result"]["ref_text"] == "@file:.hermes/desktop-attachments/outside.txt"
assert stored.read_text(encoding="utf-8") == "outside workspace"
finally:
server._sessions.pop("sid", None)
def test_file_attach_uses_in_workspace_file_without_copying(monkeypatch, tmp_path):
"""Local case: file already inside the workspace → ref it directly, no copy."""
workspace = tmp_path / "workspace"
(workspace / "data").mkdir(parents=True)
source = workspace / "data" / "exam.csv"
source.write_text("a,b,c\n1,2,3\n", encoding="utf-8")
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: source
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {"session_id": "sid", "path": str(source)},
}
)
assert resp["result"]["attached"] is True
assert resp["result"]["uploaded"] is False
assert resp["result"]["ref_text"] == "@file:data/exam.csv"
# No copy: nothing staged under desktop-attachments.
assert not (workspace / ".hermes" / "desktop-attachments").exists()
finally:
server._sessions.pop("sid", None)
def test_file_attach_errors_when_unresolvable_and_no_bytes(monkeypatch, tmp_path):
"""Remote path not on gateway and no data_url → actionable error, not a stage."""
workspace = tmp_path / "workspace"
workspace.mkdir()
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: None
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {"session_id": "sid", "path": "/Users/alice/missing.txt"},
}
)
assert "error" in resp
assert "no data_url" in resp["error"]["message"]
finally:
server._sessions.pop("sid", None)
def test_file_attach_quotes_ref_with_spaces(monkeypatch, tmp_path):
"""Staged names with spaces must be backtick-quoted so the @file: ref parses."""
workspace = tmp_path / "workspace"
workspace.mkdir()
fake_cli = types.ModuleType("cli")
fake_cli._detect_file_drop = lambda raw: None
fake_cli._split_path_input = lambda raw: (raw, "")
fake_cli._resolve_attachment_path = lambda raw: None
server._sessions["sid"] = _session(cwd=str(workspace))
monkeypatch.setitem(sys.modules, "cli", fake_cli)
try:
resp = server.handle_request(
{
"id": "1",
"method": "file.attach",
"params": {
"session_id": "sid",
"name": "my exam schedule.csv",
"data_url": "data:text/csv;base64,YSxiCg==",
},
}
)
assert resp["result"]["attached"] is True
assert resp["result"]["ref_text"] == "@file:`.hermes/desktop-attachments/my exam schedule.csv`"
finally:
server._sessions.pop("sid", None)
def test_commands_catalog_surfaces_quick_commands(monkeypatch):
monkeypatch.setattr(
server,
+109 -464
View File
@@ -20,7 +20,7 @@ if _REPO_ROOT not in sys.path:
sys.path.insert(0, _REPO_ROOT)
import pytest
from unittest.mock import AsyncMock, MagicMock, patch
from unittest.mock import AsyncMock, MagicMock
from gateway.platforms.yuanbao import (
InboundContext,
@@ -38,9 +38,6 @@ from gateway.platforms.yuanbao import (
OwnerCommandMiddleware,
BuildSourceMiddleware,
GroupAtGuardMiddleware,
QuoteContextMiddleware,
MediaResolveMiddleware,
PatchAnchorsMiddleware,
DispatchMiddleware,
InboundPipelineBuilder,
YuanbaoAdapter,
@@ -311,7 +308,40 @@ class TestInboundPipeline:
# ============================================================
# 2. Individual Middleware Tests
# 2. InboundContext Tests
# ============================================================
class TestInboundContext:
def test_default_values(self):
"""InboundContext has sensible defaults."""
adapter = make_adapter()
ctx = InboundContext(adapter=adapter)
assert ctx.raw_frames == []
assert ctx.push is None
assert ctx.decoded_via == ""
assert ctx.from_account == ""
assert ctx.group_code == ""
assert ctx.msg_body == []
assert ctx.msg_id == ""
assert ctx.chat_id == ""
assert ctx.chat_type == ""
assert ctx.raw_text == ""
assert ctx.media_refs == []
assert ctx.owner_command is None
assert ctx.source is None
assert ctx.msg_type is None
def test_mutable_fields(self):
"""InboundContext fields are mutable."""
ctx = make_ctx()
ctx.from_account = "alice"
ctx.chat_type = "dm"
assert ctx.from_account == "alice"
assert ctx.chat_type == "dm"
# ============================================================
# 3. Individual Middleware Tests
# ============================================================
class TestDecodeMiddleware:
@@ -690,25 +720,31 @@ class TestCreateInboundPipeline:
expected = [
"decode",
"extract-fields",
"recall_guard",
"dedup",
"skip-self",
"chat-routing",
"access-guard",
"auto-sethome",
"extract-content",
"placeholder-filter",
"owner-command",
"build-source",
"group-at-guard",
"group-attribution",
"classify-msg-type",
"quote-context",
"media-resolve",
"patch-anchors",
"dispatch",
]
assert pipeline.middleware_names == expected
"""Pipeline can be customized after creation."""
pipeline = InboundPipelineBuilder.build()
async def custom_mw(ctx, next_fn):
await next_fn()
pipeline.use_before("dispatch", "custom", custom_mw)
assert "custom" in pipeline.middleware_names
idx_custom = pipeline.middleware_names.index("custom")
idx_dispatch = pipeline.middleware_names.index("dispatch")
assert idx_custom < idx_dispatch
# ============================================================
@@ -825,7 +861,19 @@ if __name__ == "__main__":
# ============================================================
class TestInboundMiddlewareABC:
"""Test the InboundMiddleware OOP protocol (callable + named)."""
"""Test the InboundMiddleware abstract base class."""
def test_cannot_instantiate_abc(self):
"""InboundMiddleware cannot be instantiated directly."""
with pytest.raises(TypeError):
InboundMiddleware()
def test_subclass_must_implement_handle(self):
"""Subclass without handle() raises TypeError."""
with pytest.raises(TypeError):
class BadMiddleware(InboundMiddleware):
name = "bad"
BadMiddleware()
def test_subclass_with_handle_works(self):
"""Subclass with handle() can be instantiated."""
@@ -852,14 +900,19 @@ class TestInboundMiddlewareABC:
assert ctx.raw_text == "called"
next_fn.assert_awaited_once()
def test_repr(self):
"""Middleware has a useful repr."""
class MyMW(InboundMiddleware):
name = "my-mw"
async def handle(self, ctx, next_fn):
pass
mw = MyMW()
assert "MyMW" in repr(mw)
assert "my-mw" in repr(mw)
class TestMiddlewareClasses:
"""Pin the canonical ``name`` of each concrete middleware class.
These names are referenced by ``InboundPipelineBuilder.build()`` ordering,
by ``use_before`` / ``use_after`` insertion in extensions, and by log
messages so they're a real downstream contract worth pinning.
"""
"""Test that all concrete middleware classes have correct names and are InboundMiddleware subclasses."""
MIDDLEWARE_CLASSES = [
(DecodeMiddleware, "decode"),
@@ -876,12 +929,23 @@ class TestMiddlewareClasses:
(DispatchMiddleware, "dispatch"),
]
@pytest.mark.parametrize("cls,expected_name", MIDDLEWARE_CLASSES)
def test_is_inbound_middleware(self, cls, expected_name):
"""Each middleware class is a subclass of InboundMiddleware."""
assert issubclass(cls, InboundMiddleware)
@pytest.mark.parametrize("cls,expected_name", MIDDLEWARE_CLASSES)
def test_has_correct_name(self, cls, expected_name):
"""Each middleware class has the expected name."""
mw = cls()
assert mw.name == expected_name
@pytest.mark.parametrize("cls,expected_name", MIDDLEWARE_CLASSES)
def test_is_callable(self, cls, expected_name):
"""Each middleware instance is callable."""
mw = cls()
assert callable(mw)
class TestPipelineOOPRegistration:
"""Test that InboundPipeline works with OOP middleware instances."""
@@ -927,457 +991,38 @@ class TestPipelineOOPRegistration:
await pipeline.execute(make_ctx())
assert order == ["oop", "func"]
def test_use_before_with_middleware_instance(self):
"""use_before works with OOP middleware instances."""
class MwA(InboundMiddleware):
name = "a"
async def handle(self, ctx, next_fn): await next_fn()
# ============================================================
# QuoteContextMiddleware Tests
# ============================================================
#
# Quote-media resolution used to depend on a process-local
# msg_id→resids cache populated by ExtractContentMiddleware. After #27866
# made gateway/run.py write @bot user transcript entries with
# message_id (symmetric with the observed-group writer at yuanbao.py:2091),
# QuoteContextMiddleware's transcript-lookup path covers every quote case
# we used to rely on the cache for, so the cache (and those tests) were
# removed. ``_extract_quote_context()`` is now a pure (quote_id, quote_text)
# extractor; quote media references are populated separately by
# ``_extract_media_refs_from_transcript()`` against the transcript store.
class MwB(InboundMiddleware):
name = "b"
async def handle(self, ctx, next_fn): await next_fn()
class TestQuoteContextMiddleware:
"""Tests for QuoteContextMiddleware._extract_quote_context."""
class MwC(InboundMiddleware):
name = "c"
async def handle(self, ctx, next_fn): await next_fn()
def test_extract_quote_context_no_cloud_data(self):
"""Returns (None, None) when cloud_custom_data is empty."""
result = QuoteContextMiddleware()._extract_quote_context("")
assert result == (None, None)
pipeline = InboundPipeline().use(MwA()).use(MwC())
pipeline.use_before("c", MwB())
assert pipeline.middleware_names == ["a", "b", "c"]
def test_extract_quote_context_no_quote_key(self):
"""Returns (None, None) when JSON has no 'quote' key."""
cloud_data = json.dumps({"foo": "bar"})
result = QuoteContextMiddleware()._extract_quote_context(cloud_data)
assert result == (None, None)
def test_use_after_with_middleware_instance(self):
"""use_after works with OOP middleware instances."""
class MwA(InboundMiddleware):
name = "a"
async def handle(self, ctx, next_fn): await next_fn()
def test_extract_quote_context_with_desc(self):
"""Extracts quote_id and quote_text from desc."""
cloud_data = json.dumps({
"quote": {
"id": "quoted-msg-001",
"desc": "Hello world",
"sender_nickname": "Alice",
}
})
quote_id, quote_text = QuoteContextMiddleware()._extract_quote_context(cloud_data)
assert quote_id == "quoted-msg-001"
assert quote_text == "Alice: Hello world"
class MwB(InboundMiddleware):
name = "b"
async def handle(self, ctx, next_fn): await next_fn()
def test_extract_quote_context_empty_desc(self):
"""When desc is empty, quote_text is None but quote_id is preserved."""
cloud_data = json.dumps({
"quote": {
"id": "quoted-msg-003",
"desc": "",
"sender_nickname": "Carol",
}
})
quote_id, quote_text = QuoteContextMiddleware()._extract_quote_context(cloud_data)
assert quote_id == "quoted-msg-003"
assert quote_text is None
class MwC(InboundMiddleware):
name = "c"
async def handle(self, ctx, next_fn): await next_fn()
def test_extract_quote_context_no_quote_id(self):
"""When quote.id is empty, quote_id is None."""
cloud_data = json.dumps({
"quote": {
"id": "",
"desc": "some text",
}
})
quote_id, _quote_text = QuoteContextMiddleware()._extract_quote_context(cloud_data)
assert quote_id is None
@pytest.mark.asyncio
async def test_handle_sets_ctx_fields(self):
"""QuoteContextMiddleware.handle() sets ctx.reply_to_message_id, reply_to_text, quote_media_refs.
With no transcript store wired up, quote_media_refs falls back to []
media resolution from transcript is covered by separate tests.
"""
cloud_data = json.dumps({
"quote": {
"id": "quoted-msg-004",
"desc": "Check this image",
"sender_nickname": "Dave",
}
})
adapter = make_adapter()
adapter._session_store = None # no transcript lookup path
ctx = make_ctx(adapter=adapter, cloud_custom_data=cloud_data)
next_fn = AsyncMock()
await QuoteContextMiddleware()(ctx, next_fn)
assert ctx.reply_to_message_id == "quoted-msg-004"
assert ctx.reply_to_text == "Dave: Check this image"
assert ctx.quote_media_refs == []
next_fn.assert_awaited_once()
# ============================================================
# MediaResolveMiddleware Tests
# ============================================================
#
# After the dispatch refactor, MediaResolveMiddleware is the single entry
# point for all inbound media downloads. It merges up to three sources
# into ``ctx.media_urls`` / ``ctx.media_types`` (deduped, in this order):
#
# 1) media carried by the current message itself (always),
# 2) quote_media_refs (when reply_to_message_id is set),
# 3) recent group-observed media (only when chat_type == "group" and
# no quote is present).
#
# Direct messages skip the observed backfill entirely.
class TestResolveYbresRefs:
"""Direct tests for ``MediaResolveMiddleware._resolve_ybres_refs``.
This classmethod is the shared engine for both ``_resolve_quote_media``
and ``_collect_observed_media``. Patching the two upstream callers from
routing tests doesn't exercise its filtering / error-swallowing
behavior, so we pin those contracts directly here.
"""
@pytest.mark.asyncio
async def test_resolves_each_ref_in_order(self):
"""Successful resolution returns ``(paths, mimes)`` aligned with input order."""
adapter = make_adapter()
refs = [
("rid-1", "image", "a.jpg"),
("rid-2", "file", "doc.pdf"),
]
with patch.object(
MediaResolveMiddleware, "_fetch_resource_url",
new=AsyncMock(side_effect=["https://fresh/1", "https://fresh/2"]),
) as p_fetch, patch.object(
MediaResolveMiddleware, "_download_and_cache",
new=AsyncMock(side_effect=[
("/cache/a.jpg", "image/jpeg"),
("/cache/doc.pdf", "application/pdf"),
]),
) as p_cache:
paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs(
adapter, refs, log_prefix="test",
)
assert paths == ["/cache/a.jpg", "/cache/doc.pdf"]
assert mimes == ["image/jpeg", "application/pdf"]
assert p_fetch.await_count == 2
# filename from the ref tuple is forwarded to download_and_cache
cache_kwargs = [c.kwargs for c in p_cache.await_args_list]
assert cache_kwargs[0]["file_name"] == "a.jpg"
assert cache_kwargs[0]["kind"] == "image"
assert cache_kwargs[0]["resource_id"] == "rid-1"
assert cache_kwargs[1]["file_name"] == "doc.pdf"
@pytest.mark.asyncio
async def test_skips_unresolvable_kinds(self):
"""Refs whose kind is outside ``_RESOLVABLE_MEDIA_KINDS`` are dropped silently."""
adapter = make_adapter()
refs = [
("rid-v", "video", ""), # not resolvable
("rid-i", "image", "ok.jpg"), # resolvable
("rid-?", "unknown", ""), # not resolvable
]
with patch.object(
MediaResolveMiddleware, "_fetch_resource_url",
new=AsyncMock(return_value="https://fresh/i"),
) as p_fetch, patch.object(
MediaResolveMiddleware, "_download_and_cache",
new=AsyncMock(return_value=("/cache/ok.jpg", "image/jpeg")),
) as p_cache:
paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs(
adapter, refs, log_prefix="test",
)
assert paths == ["/cache/ok.jpg"]
assert mimes == ["image/jpeg"]
# Only the resolvable ref hit the network.
p_fetch.assert_awaited_once()
p_cache.assert_awaited_once()
@pytest.mark.asyncio
async def test_fetch_failure_is_swallowed_per_ref(self):
"""If ``_fetch_resource_url`` raises, that ref is skipped — not the whole batch."""
adapter = make_adapter()
refs = [
("rid-bad", "image", ""),
("rid-ok", "image", ""),
]
with patch.object(
MediaResolveMiddleware, "_fetch_resource_url",
new=AsyncMock(side_effect=[RuntimeError("boom"), "https://fresh/ok"]),
), patch.object(
MediaResolveMiddleware, "_download_and_cache",
new=AsyncMock(return_value=("/cache/ok.jpg", "image/jpeg")),
) as p_cache:
paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs(
adapter, refs, log_prefix="test",
)
# bad ref dropped; good ref preserved
assert paths == ["/cache/ok.jpg"]
assert mimes == ["image/jpeg"]
# download_and_cache was only invoked for the surviving ref
p_cache.assert_awaited_once()
@pytest.mark.asyncio
async def test_cache_miss_drops_ref(self):
"""If ``_download_and_cache`` returns None, the ref is dropped."""
adapter = make_adapter()
refs = [("rid-1", "image", "")]
with patch.object(
MediaResolveMiddleware, "_fetch_resource_url",
new=AsyncMock(return_value="https://fresh/1"),
), patch.object(
MediaResolveMiddleware, "_download_and_cache",
new=AsyncMock(return_value=None),
):
paths, mimes = await MediaResolveMiddleware._resolve_ybres_refs(
adapter, refs, log_prefix="test",
)
assert paths == []
assert mimes == []
class TestMediaResolveMiddlewareRouting:
"""Branch-routing tests for MediaResolveMiddleware.handle()."""
def _make_resolved_ctx(self, *, chat_type: str, reply_to: str = None,
quote_media_refs=None, raw_text: str = "hello"):
adapter = make_adapter()
ctx = make_ctx(
adapter=adapter,
chat_type=chat_type,
reply_to_message_id=reply_to,
quote_media_refs=list(quote_media_refs or []),
raw_text=raw_text,
media_refs=[], # no own attachments by default
)
return adapter, ctx
@pytest.mark.asyncio
async def test_dm_no_quote_skips_observed_backfill(self):
"""In dm chats, observed-media backfill is never invoked."""
_adapter, ctx = self._make_resolved_ctx(chat_type="dm")
with patch.object(
MediaResolveMiddleware, "_resolve_media_urls",
new=AsyncMock(return_value=([], [])),
) as p_own, patch.object(
MediaResolveMiddleware, "_resolve_quote_media",
new=AsyncMock(return_value=([], [])),
) as p_quote, patch.object(
MediaResolveMiddleware, "_collect_observed_media",
new=AsyncMock(return_value=([], [])),
) as p_observed:
next_fn = AsyncMock()
await MediaResolveMiddleware()(ctx, next_fn)
p_own.assert_awaited_once()
p_quote.assert_not_awaited()
p_observed.assert_not_awaited()
next_fn.assert_awaited_once()
@pytest.mark.asyncio
async def test_dm_with_quote_resolves_quote_media_only(self):
"""In dm chats with a quote, only quote media (plus own) is resolved."""
_adapter, ctx = self._make_resolved_ctx(
chat_type="dm",
reply_to="quoted-001",
quote_media_refs=[("rid-q1", "image", "")],
)
with patch.object(
MediaResolveMiddleware, "_resolve_media_urls",
new=AsyncMock(return_value=([], [])),
) as p_own, patch.object(
MediaResolveMiddleware, "_resolve_quote_media",
new=AsyncMock(return_value=(["/cache/q1.jpg"], ["image/jpeg"])),
) as p_quote, patch.object(
MediaResolveMiddleware, "_collect_observed_media",
new=AsyncMock(return_value=([], [])),
) as p_observed:
next_fn = AsyncMock()
await MediaResolveMiddleware()(ctx, next_fn)
p_own.assert_awaited_once()
p_quote.assert_awaited_once()
p_observed.assert_not_awaited()
assert ctx.media_urls == ["/cache/q1.jpg"]
assert ctx.media_types == ["image/jpeg"]
@pytest.mark.asyncio
async def test_group_no_quote_runs_observed_backfill(self):
"""In group chats without quote, observed-media backfill is invoked."""
_adapter, ctx = self._make_resolved_ctx(chat_type="group")
with patch.object(
MediaResolveMiddleware, "_resolve_media_urls",
new=AsyncMock(return_value=([], [])),
), patch.object(
MediaResolveMiddleware, "_resolve_quote_media",
new=AsyncMock(return_value=([], [])),
) as p_quote, patch.object(
MediaResolveMiddleware, "_collect_observed_media",
new=AsyncMock(return_value=(["/cache/o1.jpg"], ["image/jpeg"])),
) as p_observed:
next_fn = AsyncMock()
await MediaResolveMiddleware()(ctx, next_fn)
p_quote.assert_not_awaited()
p_observed.assert_awaited_once()
assert ctx.media_urls == ["/cache/o1.jpg"]
@pytest.mark.asyncio
async def test_group_with_quote_skips_observed_backfill(self):
"""In group chats with a quote, only quote media is resolved (no backfill)."""
_adapter, ctx = self._make_resolved_ctx(
chat_type="group",
reply_to="quoted-002",
quote_media_refs=[("rid-q2", "image", "")],
)
with patch.object(
MediaResolveMiddleware, "_resolve_media_urls",
new=AsyncMock(return_value=([], [])),
), patch.object(
MediaResolveMiddleware, "_resolve_quote_media",
new=AsyncMock(return_value=(["/cache/q2.jpg"], ["image/jpeg"])),
) as p_quote, patch.object(
MediaResolveMiddleware, "_collect_observed_media",
new=AsyncMock(return_value=(["/cache/o2.jpg"], ["image/jpeg"])),
) as p_observed:
next_fn = AsyncMock()
await MediaResolveMiddleware()(ctx, next_fn)
p_quote.assert_awaited_once()
p_observed.assert_not_awaited()
assert ctx.media_urls == ["/cache/q2.jpg"]
@pytest.mark.asyncio
async def test_merges_and_dedupes_three_sources(self):
"""Own + quote sources are merged with dedup applied."""
_adapter, ctx = self._make_resolved_ctx(
chat_type="dm",
reply_to="quoted-003",
quote_media_refs=[("rid", "image", "")],
)
with patch.object(
MediaResolveMiddleware, "_resolve_media_urls",
new=AsyncMock(return_value=(["/cache/a.jpg"], ["image/jpeg"])),
), patch.object(
MediaResolveMiddleware, "_resolve_quote_media",
# Same path as own → must be deduped.
new=AsyncMock(return_value=(["/cache/a.jpg", "/cache/b.jpg"],
["image/jpeg", "image/png"])),
):
next_fn = AsyncMock()
await MediaResolveMiddleware()(ctx, next_fn)
assert ctx.media_urls == ["/cache/a.jpg", "/cache/b.jpg"]
assert ctx.media_types == ["image/jpeg", "image/png"]
@pytest.mark.asyncio
async def test_placeholder_recheck_uses_own_count_only(self):
"""Placeholder retry-skip uses ``own_count``, not the merged total.
A bare placeholder text (e.g. ``[image]``) accompanied only by a
quote-resolved image is still skippable quote media must not
flip a placeholder into a non-placeholder.
"""
_adapter, ctx = self._make_resolved_ctx(
chat_type="dm",
reply_to="quoted-004",
quote_media_refs=[("rid", "image", "")],
raw_text="[image]",
)
with patch.object(
MediaResolveMiddleware, "_resolve_media_urls",
new=AsyncMock(return_value=([], [])), # no own media
), patch.object(
MediaResolveMiddleware, "_resolve_quote_media",
new=AsyncMock(return_value=(["/cache/q.jpg"], ["image/jpeg"])),
), patch.object(
PlaceholderFilterMiddleware, "is_skippable_placeholder",
return_value=True,
) as p_check:
next_fn = AsyncMock()
await MediaResolveMiddleware()(ctx, next_fn)
# Pipeline short-circuited despite quote media being present.
next_fn.assert_not_awaited()
# And the second-pass check was called with own_count == 0.
p_check.assert_called_once()
_text_arg, count_arg = p_check.call_args.args
assert count_arg == 0
# ============================================================
# PatchAnchorsMiddleware Tests
# ============================================================
class TestPatchAnchorsMiddleware:
"""Tests for PatchAnchorsMiddleware._patch()."""
def test_no_op_when_text_or_urls_empty(self):
assert PatchAnchorsMiddleware._patch("", [], []) == ""
assert PatchAnchorsMiddleware._patch("hello", [], []) == "hello"
def test_replaces_image_anchor_with_local_path(self):
text = "look [image|ybres:abc] please"
out = PatchAnchorsMiddleware._patch(
text, ["/cache/x.jpg"], ["image/jpeg"],
)
assert out == "look [image: /cache/x.jpg] please"
def test_replaces_file_anchor_with_filename_label(self):
text = "see [file:doc.pdf|ybres:rid-1]"
out = PatchAnchorsMiddleware._patch(
text, ["/cache/doc.pdf"], ["application/pdf"],
)
assert "[file: doc.pdf → /cache/doc.pdf]" in out
def test_skips_non_local_paths(self):
"""URLs not starting with '/' are left untouched."""
text = "[image|ybres:abc]"
out = PatchAnchorsMiddleware._patch(
text, ["https://example.com/x.jpg"], ["image/jpeg"],
)
# Anchor preserved verbatim because the resolved url is remote.
assert out == text
def test_anchor_kind_image_requires_image_mime(self):
"""An [image|...] anchor with a non-image mime is left alone."""
text = "[image|ybres:rid]"
out = PatchAnchorsMiddleware._patch(
text, ["/cache/odd.bin"], ["application/octet-stream"],
)
assert out == text
@pytest.mark.asyncio
async def test_handle_writes_back_to_ctx(self):
adapter = make_adapter()
ctx = make_ctx(
adapter=adapter,
raw_text="hi [image|ybres:rid]",
media_urls=["/cache/y.png"],
media_types=["image/png"],
)
next_fn = AsyncMock()
await PatchAnchorsMiddleware()(ctx, next_fn)
assert ctx.raw_text == "hi [image: /cache/y.png]"
next_fn.assert_awaited_once()
pipeline = InboundPipeline().use(MwA()).use(MwC())
pipeline.use_after("a", MwB())
assert pipeline.middleware_names == ["a", "b", "c"]
-1
View File
@@ -109,7 +109,6 @@ def test_ensure_docker_available_uses_resolved_executable(monkeypatch):
"capture_output": True,
"text": True,
"timeout": 5,
"stdin": subprocess.DEVNULL,
})
]
+11 -77
View File
@@ -388,86 +388,20 @@ class TestSanePathIncludesHomebrew:
assert "/opt/homebrew/sbin" in _SANE_PATH
def test_make_run_env_appends_homebrew_on_minimal_path(self):
"""When PATH is minimal, _make_run_env appends missing sane entries."""
from tools.environments.local import _SANE_PATH, _make_run_env
"""When PATH is minimal (no /usr/bin), _make_run_env should append
_SANE_PATH which now includes Homebrew dirs."""
from tools.environments.local import _make_run_env
minimal_env = {"PATH": "/some/custom/bin"}
with patch.dict(os.environ, minimal_env, clear=True):
result = _make_run_env({})
path_entries = result["PATH"].split(":")
assert path_entries[0] == "/some/custom/bin"
for entry in _SANE_PATH.split(":"):
assert entry in path_entries
assert "/opt/homebrew/bin" in result["PATH"]
assert "/opt/homebrew/sbin" in result["PATH"]
def test_make_run_env_fills_missing_homebrew_when_usr_bin_present(self):
"""macOS launchd PATH can include /usr/bin while missing Homebrew."""
def test_make_run_env_does_not_duplicate_on_full_path(self):
"""When PATH already has /usr/bin, _make_run_env should not append."""
from tools.environments.local import _make_run_env
launchd_env = {"PATH": "/usr/local/bin:/usr/bin:/bin"}
with patch.dict(os.environ, launchd_env, clear=True):
full_env = {"PATH": "/usr/bin:/bin"}
with patch.dict(os.environ, full_env, clear=True):
result = _make_run_env({})
path_entries = result["PATH"].split(":")
assert "/opt/homebrew/bin" in path_entries
assert "/opt/homebrew/sbin" in path_entries
def test_make_run_env_does_not_duplicate_existing_sane_entries(self):
from tools.environments.local import _make_run_env
existing_env = {"PATH": "/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"}
with patch.dict(os.environ, existing_env, clear=True):
result = _make_run_env({})
path_entries = result["PATH"].split(":")
assert path_entries.count("/opt/homebrew/bin") == 1
assert path_entries.count("/usr/local/bin") == 1
assert path_entries.count("/usr/bin") == 1
def test_make_run_env_real_launchd_path_gains_homebrew(self):
"""The literal macOS launchd PATH is the production trigger for #35613."""
from tools.environments.local import _make_run_env
launchd_env = {"PATH": "/usr/bin:/bin:/usr/sbin:/sbin"}
with patch.dict(os.environ, launchd_env, clear=True):
result = _make_run_env({})
path_entries = result["PATH"].split(":")
assert "/opt/homebrew/bin" in path_entries
assert "/opt/homebrew/sbin" in path_entries
# Original entries keep their leading precedence.
assert path_entries[:4] == ["/usr/bin", "/bin", "/usr/sbin", "/sbin"]
def test_make_run_env_collapses_duplicate_caller_entries(self):
"""Duplicates already present in the caller PATH are de-duplicated."""
from tools.environments.local import _make_run_env
dup_env = {"PATH": "/usr/bin:/usr/bin:/custom/bin:/custom/bin:/bin"}
with patch.dict(os.environ, dup_env, clear=True):
result = _make_run_env({})
path_entries = result["PATH"].split(":")
assert path_entries.count("/usr/bin") == 1
assert path_entries.count("/custom/bin") == 1
# First-occurrence order is preserved for the caller entries.
assert path_entries[:3] == ["/usr/bin", "/custom/bin", "/bin"]
def test_make_run_env_strips_empty_path_entries(self):
"""Leading/trailing/double colons (== CWD on POSIX) are dropped."""
from tools.environments.local import _make_run_env
empty_env = {"PATH": "/usr/bin::/bin:"}
with patch.dict(os.environ, empty_env, clear=True):
result = _make_run_env({})
path_entries = result["PATH"].split(":")
assert "" not in path_entries
assert "/usr/bin" in path_entries
assert "/opt/homebrew/bin" in path_entries
def test_make_run_env_leaves_windows_path_unchanged(self, monkeypatch):
from tools.environments import local as local_mod
from tools.environments.local import _make_run_env
windows_env = {"PATH": r"C:\Windows\System32;C:\Program Files\Git\bin"}
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
with patch.dict(os.environ, windows_env, clear=True):
result = _make_run_env({})
assert result["PATH"] == windows_env["PATH"]
def test_make_run_env_preserves_windows_mixed_case_path_key(self, monkeypatch):
from tools.environments import local as local_mod
from tools.environments.local import _make_run_env
windows_env = {"Path": r"C:\Windows\System32;C:\Program Files\Git\bin"}
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
with patch.object(local_mod.os, "environ", windows_env):
result = _make_run_env({})
assert result["Path"] == windows_env["Path"]
assert "PATH" not in result
# Should keep existing PATH unchanged
assert result["PATH"] == "/usr/bin:/bin"
@@ -1,70 +0,0 @@
"""Verify that TUI-context subprocess calls specify stdin=.
This is the pytest wrapper for scripts/check_subprocess_stdin.py.
It runs as part of the test suite so CI catches regressions when new
subprocess calls are added without stdin=subprocess.DEVNULL.
"""
import importlib.util
import subprocess
import sys
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parents[2]
SCRIPT = REPO_ROOT / "scripts" / "check_subprocess_stdin.py"
def _load_guard():
spec = importlib.util.spec_from_file_location("_stdin_guard", SCRIPT)
assert spec is not None and spec.loader is not None
mod = importlib.util.module_from_spec(spec)
spec.loader.exec_module(mod)
return mod
def test_all_tui_subprocess_calls_have_stdin():
"""Every subprocess.run/Popen in TUI-context code must set stdin=."""
result = subprocess.run(
[sys.executable, str(SCRIPT)],
capture_output=True,
text=True,
timeout=30,
)
assert result.returncode == 0, (
f"subprocess stdin= check failed:\n{result.stdout}\n{result.stderr}"
)
def test_oauth_setup_token_keeps_inherited_stdin():
"""The interactive 'claude setup-token' login must NOT be muzzled.
Forcing stdin=subprocess.DEVNULL here would feed the OAuth prompt EOF and
break interactive token setup. A blanket DEVNULL sweep over TUI-context
subprocess calls must leave this one inheriting stdin. Regression guard for
the over-application caught while salvaging the stdin-EOF fix.
"""
src = (REPO_ROOT / "agent" / "anthropic_adapter.py").read_text()
assert 'subprocess.run([claude_path, "setup-token"])' in src, (
"interactive setup-token call changed shape; re-verify it still "
"inherits stdin (no stdin=subprocess.DEVNULL)"
)
assert 'subprocess.run([claude_path, "setup-token"], stdin' not in src, (
"setup-token must inherit stdin so the user can complete the OAuth "
"login prompt; do not add stdin=subprocess.DEVNULL"
)
def test_inline_noqa_marker_exempts_a_call():
"""The guard honors an inline 'noqa: subprocess-stdin' exemption marker."""
guard = _load_guard()
flagged = guard.find_subprocess_calls(
"import subprocess\nsubprocess.run(['ls'])\n", "x.py"
)
assert len(flagged) == 1, "unmarked missing-stdin call should be flagged"
exempt = guard.find_subprocess_calls(
"import subprocess\nsubprocess.run(['ls']) # noqa: subprocess-stdin\n",
"x.py",
)
assert exempt == [], "inline marker should exempt the call"
+12 -18
View File
@@ -7,9 +7,9 @@ at startup, by THREE separate code paths:
1. cli.py -> ``env_mappings`` dict (CLI / TUI startup)
2. gateway/run.py -> ``_terminal_env_map`` dict (gateway / messaging
platforms)
3. hermes_cli/config.py:set_config_value
-> bridges via the canonical ``TERMINAL_CONFIG_ENV_MAP``
(one-shot when the user runs ``hermes config set ``)
3. hermes_cli/config.py:save_config_value
-> ``_config_to_env_sync`` dict (one-shot when the
user runs ``hermes config set ``)
If any one of these is missing a key, the corresponding config.yaml setting
silently does nothing for that entry-point. This bug already shipped once
@@ -87,20 +87,14 @@ def _gateway_env_map_keys() -> set[str]:
def _save_config_env_sync_keys() -> set[str]:
"""terminal config keys bridged by ``hermes config set foo bar``.
``set_config_value`` no longer carries its own ``_config_to_env_sync``
dict it bridges through the canonical ``TERMINAL_CONFIG_ENV_MAP`` via
``terminal_config_env_var_for_key()`` (config.py), excluding ``cwd``
(handled separately). Read the live map so this test tracks the actual
source of truth that the config-set path uses, rather than a string
literal that the consolidation removed.
"""
"""terminal config keys bridged by ``hermes config set foo bar``."""
from hermes_cli import config as hc_config
# set_config_value bridges every TERMINAL_CONFIG_ENV_MAP key except
# terminal.cwd (see the ``key != "terminal.cwd"`` guard in
# set_config_value); mirror that exclusion here.
return {k for k in hc_config.TERMINAL_CONFIG_ENV_MAP if k != "cwd"}
source = inspect.getsource(hc_config.set_config_value)
keys = _extract_dict_keys(source, "_config_to_env_sync")
# set_config_value uses fully-qualified ``terminal.foo`` keys; strip the
# prefix so we can compare against the other two maps which use bare
# leaf keys.
return {k.split(".", 1)[1] for k in keys if k.startswith("terminal.")}
# Keys present in cli.py env_mappings but intentionally absent from
@@ -186,8 +180,8 @@ def test_save_config_set_supports_critical_bridged_keys():
missing = required - save_keys
assert not missing, (
f"`hermes config set terminal.X` doesn't sync these load-bearing "
f"keys to .env: {sorted(missing)}. Add them to TERMINAL_CONFIG_ENV_MAP "
f"in hermes_cli/config.py (set_config_value bridges through it)."
f"keys to .env: {sorted(missing)}. Add them to _config_to_env_sync "
f"in hermes_cli/config.py:set_config_value."
)
+6 -8
View File
@@ -841,15 +841,13 @@ class TestLocalEnvironmentWindowsTempDir:
class TestLocalEnvironmentPathInjectionGated:
"""Sane PATH completion must stay POSIX-only."""
"""The /usr/bin PATH injection in _make_run_env must be POSIX-only."""
def test_windows_path_is_left_unchanged(self, monkeypatch):
from tools.environments import local as local_mod
from tools.environments.local import _append_missing_sane_path_entries
monkeypatch.setattr(local_mod, "_IS_WINDOWS", True)
path = r"C:\Windows\System32;C:\Program Files\Git\bin"
assert _append_missing_sane_path_entries(path) == path
def test_source_gates_path_injection(self):
root = Path(__file__).resolve().parents[2]
source = (root / "tools" / "environments" / "local.py").read_text(encoding="utf-8")
# The fix wraps the injection in `if not _IS_WINDOWS`.
assert 'not _IS_WINDOWS and "/usr/bin" not in existing_path.split(":")' in source
# ---------------------------------------------------------------------------
-38
View File
@@ -613,44 +613,6 @@ def test_session_resume_live_payload_uses_current_history_with_ancestors(server,
]
def test_session_activate_rebinds_orphaned_ws_session_to_current_transport(server, monkeypatch):
"""Reconnect + activate must reattach a parked live session before orphan reap."""
class _Transport:
def write(self, _obj):
return True
sid = "runtime01"
old_transport = server._stdio_transport
new_transport = _Transport()
server._sessions[sid] = {
"agent": types.SimpleNamespace(model="test/model"),
"created_at": 123.0,
"history": [],
"history_lock": threading.RLock(),
"last_active": 123.0,
"running": False,
"session_key": "20260409_010101_abc123",
"transport": old_transport,
}
monkeypatch.setattr(server, "current_transport", lambda: new_transport)
monkeypatch.setattr(server, "_get_db", lambda: None)
monkeypatch.setattr(
server,
"_session_info",
lambda _agent, _session=None: {"model": "test/model"},
)
resp = server.handle_request(
{"id": "activate", "method": "session.activate", "params": {"session_id": sid}}
)
assert "error" not in resp
assert resp["result"]["session_id"] == sid
assert server._sessions[sid]["transport"] is new_transport
assert not server._ws_session_is_orphaned(server._sessions[sid])
def test_session_branch_persists_branched_from_marker(server, monkeypatch):
"""TUI /branch must persist a _branched_from marker so the branch stays
visible in /resume and /sessions.

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