Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ea0efea2bd |
@@ -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,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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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}]"
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 361 KiB After Width: | Height: | Size: 78 KiB |
|
Before Width: | Height: | Size: 561 KiB After Width: | Height: | Size: 674 KiB |
@@ -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}`
|
||||
|
||||
|
Before Width: | Height: | Size: 528 KiB After Width: | Height: | Size: 1.1 MiB |
@@ -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>
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -1224,7 +1224,6 @@ export const ja = defineLocale({
|
||||
sessionRunning: 'セッション実行中',
|
||||
needsInput: '入力が必要です',
|
||||
waitingForAnswer: '回答を待っています',
|
||||
handoffOrigin: platform => `${platform} から引き継ぎ`,
|
||||
renamed: '名前を変更しました',
|
||||
renameFailed: '名前の変更に失敗しました',
|
||||
renameTitle: 'セッションの名前を変更',
|
||||
|
||||
@@ -838,7 +838,6 @@ export interface Translations {
|
||||
sessionRunning: string
|
||||
needsInput: string
|
||||
waitingForAnswer: string
|
||||
handoffOrigin: (platform: string) => string
|
||||
renamed: string
|
||||
renameFailed: string
|
||||
renameTitle: string
|
||||
|
||||
@@ -1190,7 +1190,6 @@ export const zhHant = defineLocale({
|
||||
sessionRunning: '工作階段執行中',
|
||||
needsInput: '需要您的輸入',
|
||||
waitingForAnswer: '等待您的回答',
|
||||
handoffOrigin: platform => `從 ${platform} 轉接`,
|
||||
renamed: '已重新命名',
|
||||
renameFailed: '重新命名失敗',
|
||||
renameTitle: '重新命名工作階段',
|
||||
|
||||
@@ -1268,7 +1268,6 @@ export const zh: Translations = {
|
||||
sessionRunning: '会话运行中',
|
||||
needsInput: '需要你输入',
|
||||
waitingForAnswer: '正在等待你的回答',
|
||||
handoffOrigin: platform => `从 ${platform} 转接`,
|
||||
renamed: '已重命名',
|
||||
renameFailed: '重命名失败',
|
||||
renameTitle: '重命名会话',
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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'
|
||||
|
||||
/**
|
||||
|
||||
@@ -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. */
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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,
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}")
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}"
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -520,7 +520,6 @@ class VoiceReceiver:
|
||||
],
|
||||
check=True,
|
||||
timeout=10,
|
||||
stdin=subprocess.DEVNULL,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(),
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)"
|
||||
|
||||
@@ -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))
|
||||
);
|
||||
});
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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())
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -109,7 +109,6 @@ def test_ensure_docker_available_uses_resolved_executable(monkeypatch):
|
||||
"capture_output": True,
|
||||
"text": True,
|
||||
"timeout": 5,
|
||||
"stdin": subprocess.DEVNULL,
|
||||
})
|
||||
]
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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."
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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.
|
||||
|
||||