Compare commits
69
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2e48537cf1 | ||
|
|
af1477d812 | ||
|
|
bf8effad02 | ||
|
|
817f392311 | ||
|
|
2b67e96aec | ||
|
|
abd69b8117 | ||
|
|
da28d5d113 | ||
|
|
1fa761f8de | ||
|
|
069bfd6545 | ||
|
|
1d584a301e | ||
|
|
57c2a55be4 | ||
|
|
0a865e5948 | ||
|
|
c8e5f34f24 | ||
|
|
7d11fa4e9e | ||
|
|
7c0605bf22 | ||
|
|
819def44c7 | ||
|
|
08890d77e6 | ||
|
|
425e777f54 | ||
|
|
7be22e37e1 | ||
|
|
28902dc890 | ||
|
|
63097ee0d7 | ||
|
|
6e2fd955ca | ||
|
|
78c11d99e3 | ||
|
|
957a8ffa88 | ||
|
|
cc14b74718 | ||
|
|
9b5f7b63c6 | ||
|
|
d146b85173 | ||
|
|
28bf8fb47d | ||
|
|
3380563d94 | ||
|
|
ad7436a5d9 | ||
|
|
fc46354580 | ||
|
|
1185dfd773 | ||
|
|
f82cb48120 | ||
|
|
4fd9397ae3 | ||
|
|
45f9099e51 | ||
|
|
4373e802a1 | ||
|
|
d206e1f51d | ||
|
|
16fb573bae | ||
|
|
6f43ff5572 | ||
|
|
eed61a1251 | ||
|
|
74c5158b10 | ||
|
|
6724daa2c2 | ||
|
|
aa53a78d67 | ||
|
|
0333a99925 | ||
|
|
5acd185f7c | ||
|
|
39a35b784f | ||
|
|
2667601c05 | ||
|
|
643dc82793 | ||
|
|
e256f4aae4 | ||
|
|
cb125c2b3f | ||
|
|
a59d5e37e8 | ||
|
|
4b646bc21e | ||
|
|
62b4618e9a | ||
|
|
2abcae9678 | ||
|
|
c814d3d1dd | ||
|
|
573b964dc7 | ||
|
|
aa0798352a | ||
|
|
311ff967de | ||
|
|
bd66e7e3fb | ||
|
|
2681c5a12d | ||
|
|
fa2aba90b4 | ||
|
|
5b857201b7 | ||
|
|
905ed413d1 | ||
|
|
bea6c1c01f | ||
|
|
a5e9b17ce3 | ||
|
|
5d6c16e972 | ||
|
|
266b5a19f1 | ||
|
|
202e318cb1 | ||
|
|
2d474e39c7 |
@@ -11,8 +11,20 @@ on:
|
||||
- 'optional-skills/**'
|
||||
- '.github/workflows/deploy-site.yml'
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
skills_index_run_id:
|
||||
description: 'Optional Build Skills Index run ID whose skills-index artifact should be deployed'
|
||||
required: false
|
||||
type: string
|
||||
rebuild_skills_index:
|
||||
description: 'Force a fresh multi-source crawl instead of reusing the latest healthy index'
|
||||
required: false
|
||||
default: false
|
||||
type: boolean
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
actions: read
|
||||
pages: write
|
||||
id-token: write
|
||||
|
||||
@@ -55,26 +67,81 @@ jobs:
|
||||
- name: Install PyYAML for skill extraction
|
||||
run: pip install pyyaml==6.0.2 httpx==0.28.1
|
||||
|
||||
- name: Build skills index (unified multi-source catalog)
|
||||
- name: Prepare skills index (unified multi-source catalog)
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
GH_TOKEN: ${{ github.token }}
|
||||
GITHUB_TOKEN: ${{ github.token }}
|
||||
SKILLS_INDEX_RUN_ID: ${{ github.event.inputs.skills_index_run_id || '' }}
|
||||
REBUILD_SKILLS_INDEX: ${{ github.event.inputs.rebuild_skills_index || 'false' }}
|
||||
run: |
|
||||
# Rebuild the unified catalog. The file is gitignored, so a fresh
|
||||
# checkout starts without it and we want the freshest crawl in
|
||||
# every deploy.
|
||||
# The unified external catalog is expensive to crawl and can burn
|
||||
# through the repository installation's GitHub API quota when several
|
||||
# docs deploys land close together. Normal docs deploys therefore
|
||||
# reuse the latest healthy catalog: first the artifact from a
|
||||
# scheduled skills-index run, then the currently live index. Only a
|
||||
# manual force rebuild does a fresh crawl here.
|
||||
#
|
||||
# This MUST be fatal. build_skills_index.py runs a health check and
|
||||
# exits non-zero WITHOUT writing the output file when a source
|
||||
# collapses (e.g. a GitHub API rate limit zeroes the github /
|
||||
# claude-marketplace / well-known taps all at once). Letting the
|
||||
# deploy continue would either (a) ship a degenerate index missing
|
||||
# whole hubs — the June 2026 regression where OpenAI/Anthropic/
|
||||
# HuggingFace/NVIDIA tabs vanished — or (b) fall through to a
|
||||
# local-only catalog. Failing here keeps the last good deployment
|
||||
# live (GitHub Pages serves the previous build) instead of
|
||||
# publishing a broken catalog. Re-run the workflow once the
|
||||
# transient rate limit clears.
|
||||
# If we do crawl, the build remains fatal. build_skills_index.py runs
|
||||
# the health check BEFORE writing and exits non-zero on source
|
||||
# collapse, keeping the last good Pages deployment live instead of
|
||||
# publishing a degenerate catalog.
|
||||
set -euo pipefail
|
||||
INDEX_PATH="website/static/api/skills-index.json"
|
||||
mkdir -p "$(dirname "$INDEX_PATH")"
|
||||
|
||||
validate_index() {
|
||||
python3 - "$INDEX_PATH" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
path = Path(sys.argv[1])
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except Exception as exc:
|
||||
print(f"invalid skills index JSON: {exc}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
skills = data.get("skills")
|
||||
if not isinstance(skills, list) or len(skills) < 1500:
|
||||
count = len(skills) if isinstance(skills, list) else "missing"
|
||||
print(f"skills index too small: {count}", file=sys.stderr)
|
||||
sys.exit(1)
|
||||
print(f"skills index ready: {len(skills)} skills")
|
||||
PY
|
||||
}
|
||||
|
||||
if [ "$REBUILD_SKILLS_INDEX" = "true" ]; then
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
exit 0
|
||||
fi
|
||||
|
||||
if [ -n "$SKILLS_INDEX_RUN_ID" ]; then
|
||||
tmpdir="$(mktemp -d)"
|
||||
echo "Downloading skills-index artifact from run $SKILLS_INDEX_RUN_ID"
|
||||
if gh run download "$SKILLS_INDEX_RUN_ID" --name skills-index --dir "$tmpdir"; then
|
||||
candidate="$(find "$tmpdir" -name skills-index.json -type f | head -n 1 || true)"
|
||||
if [ -n "$candidate" ]; then
|
||||
cp "$candidate" "$INDEX_PATH"
|
||||
if validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "::warning::Could not use skills-index artifact from run $SKILLS_INDEX_RUN_ID; trying live index"
|
||||
fi
|
||||
|
||||
echo "Downloading currently live skills index"
|
||||
if curl -fsSL --retry 3 --retry-delay 5 \
|
||||
"https://hermes-agent.nousresearch.com/docs/api/skills-index.json" \
|
||||
-o "$INDEX_PATH" && validate_index; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
echo "::warning::Live skills index unavailable or unhealthy; falling back to a fresh crawl"
|
||||
rm -f "$INDEX_PATH"
|
||||
python3 scripts/build_skills_index.py
|
||||
validate_index
|
||||
|
||||
- name: Extract skill metadata for dashboard
|
||||
run: python3 website/scripts/extract-skills.py
|
||||
|
||||
@@ -53,4 +53,4 @@ jobs:
|
||||
- name: Trigger Deploy Site workflow
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }}
|
||||
run: gh workflow run deploy-site.yml --repo ${{ github.repository }} -f skills_index_run_id=${{ github.run_id }}
|
||||
|
||||
@@ -824,6 +824,7 @@ class HermesACPAgent(acp.Agent):
|
||||
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
enabled_toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"],
|
||||
@@ -839,6 +840,7 @@ class HermesACPAgent(acp.Agent):
|
||||
state.agent.valid_tool_names = {
|
||||
tool["function"]["name"] for tool in state.agent.tools or []
|
||||
}
|
||||
inject_memory_provider_tools(state.agent)
|
||||
invalidate = getattr(state.agent, "_invalidate_system_prompt", None)
|
||||
if callable(invalidate):
|
||||
invalidate()
|
||||
@@ -1779,10 +1781,25 @@ class HermesACPAgent(acp.Agent):
|
||||
def _cmd_tools(self, args: str, state: SessionState) -> str:
|
||||
try:
|
||||
from model_tools import get_tool_definitions
|
||||
from types import SimpleNamespace
|
||||
from agent.memory_manager import inject_memory_provider_tools
|
||||
|
||||
toolsets = _expand_acp_enabled_toolsets(
|
||||
getattr(state.agent, "enabled_toolsets", None) or ["hermes-acp"]
|
||||
)
|
||||
tools = get_tool_definitions(enabled_toolsets=toolsets, quiet_mode=True)
|
||||
tool_view = SimpleNamespace(
|
||||
tools=list(tools or []),
|
||||
valid_tool_names={
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools or []
|
||||
if isinstance(tool, dict)
|
||||
},
|
||||
enabled_toolsets=toolsets,
|
||||
_memory_manager=getattr(state.agent, "_memory_manager", None),
|
||||
)
|
||||
inject_memory_provider_tools(tool_view)
|
||||
tools = tool_view.tools
|
||||
if not tools:
|
||||
return "No tools available."
|
||||
lines = [f"Available tools ({len(tools)}):"]
|
||||
|
||||
+2
-32
@@ -1193,38 +1193,8 @@ def init_agent(
|
||||
_ra().logger.warning("Memory provider plugin init failed: %s", _mpe)
|
||||
agent._memory_manager = None
|
||||
|
||||
# Inject memory provider tool schemas into the tool surface.
|
||||
# Skip tools whose names already exist (plugins may register the
|
||||
# same tools via ctx.register_tool(), which lands in agent.tools
|
||||
# through _ra().get_tool_definitions()). Duplicate function names cause
|
||||
# 400 errors on providers that enforce unique names (e.g. Xiaomi
|
||||
# MiMo via Nous Portal).
|
||||
#
|
||||
# Respect the platform's enabled_toolsets configuration (#5544):
|
||||
# enabled_toolsets is None → no filter, inject (backward compat)
|
||||
# "memory" in enabled_toolsets → user opted in, inject
|
||||
# otherwise (incl. []) → user excluded memory, skip injection
|
||||
#
|
||||
# Without this gate, `platform_toolsets: telegram: []` still leaks memory
|
||||
# provider tools (fact_store, etc.) into the tool surface — a 10x latency
|
||||
# penalty on local models and a frequent trigger of tool-call loops.
|
||||
if agent._memory_manager and agent.tools is not None and (
|
||||
agent.enabled_toolsets is None or "memory" in agent.enabled_toolsets
|
||||
):
|
||||
_existing_tool_names = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in agent.tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in agent._memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing_tool_names:
|
||||
continue # already registered via plugin path
|
||||
_wrapped = {"type": "function", "function": _schema}
|
||||
agent.tools.append(_wrapped)
|
||||
if _tname:
|
||||
agent.valid_tool_names.add(_tname)
|
||||
_existing_tool_names.add(_tname)
|
||||
from agent.memory_manager import inject_memory_provider_tools as _inject_memory_provider_tools
|
||||
_inject_memory_provider_tools(agent)
|
||||
|
||||
# Skills config: nudge interval for skill creation reminders
|
||||
agent._skill_nudge_interval = 10
|
||||
|
||||
@@ -881,6 +881,8 @@ def try_recover_primary_transport(
|
||||
|
||||
def drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
drop_codex_reasoning_items: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Drop thinking-only assistant turns; merge any adjacent user messages left behind.
|
||||
|
||||
@@ -902,7 +904,13 @@ def drop_thinking_only_and_merge_users(
|
||||
return messages
|
||||
|
||||
# Pass 1: drop thinking-only assistant turns.
|
||||
kept = [m for m in messages if not _ra().AIAgent._is_thinking_only_assistant(m)]
|
||||
kept = [
|
||||
m for m in messages
|
||||
if not _ra().AIAgent._is_thinking_only_assistant(
|
||||
m,
|
||||
drop_codex_reasoning_items=drop_codex_reasoning_items,
|
||||
)
|
||||
]
|
||||
dropped = len(messages) - len(kept)
|
||||
if dropped == 0:
|
||||
return messages
|
||||
|
||||
@@ -3190,7 +3190,7 @@ def _resolve_auto(main_runtime: Optional[Dict[str, Any]] = None) -> Tuple[Option
|
||||
if (main_provider and main_model
|
||||
and main_provider not in {"auto", ""}):
|
||||
resolved_provider = main_provider
|
||||
explicit_base_url = None
|
||||
explicit_base_url = runtime_base_url or None
|
||||
explicit_api_key = None
|
||||
if runtime_base_url and (main_provider == "custom" or main_provider.startswith("custom:")):
|
||||
resolved_provider = "custom"
|
||||
@@ -5004,7 +5004,7 @@ def _build_call_kwargs(
|
||||
|
||||
# Provider-specific extra_body
|
||||
merged_extra = dict(extra_body or {})
|
||||
if provider == "nous" or auxiliary_is_nous:
|
||||
if provider == "nous":
|
||||
merged_extra.setdefault("tags", []).extend(_nous_portal_tags())
|
||||
if merged_extra:
|
||||
kwargs["extra_body"] = merged_extra
|
||||
|
||||
@@ -935,11 +935,14 @@ def build_converse_kwargs(
|
||||
if system_prompt:
|
||||
kwargs["system"] = system_prompt
|
||||
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
from agent.anthropic_adapter import _forbids_sampling_params
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
if not _forbids_sampling_params(model):
|
||||
if temperature is not None:
|
||||
kwargs["inferenceConfig"]["temperature"] = temperature
|
||||
|
||||
if top_p is not None:
|
||||
kwargs["inferenceConfig"]["topP"] = top_p
|
||||
|
||||
if stop_sequences:
|
||||
kwargs["inferenceConfig"]["stopSequences"] = stop_sequences
|
||||
|
||||
@@ -70,6 +70,21 @@ _TOOL_CALL_LEAK_PATTERN = re.compile(
|
||||
r"(?:^|[\s>|])to=functions\.[A-Za-z_][\w.]*",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_TOOL_CALL_LEAK_SCAN_LIMIT = 8192
|
||||
|
||||
|
||||
def _scan_for_leaked_tool_call(text: str) -> bool:
|
||||
"""Return True if Codex leaked a Harmony tool-call marker near the start.
|
||||
|
||||
Real leaked tool-call serializations begin with the Harmony marker. Bound
|
||||
the regex to a prefix window so multi-megabyte successful assistant output
|
||||
never spends unbounded GIL time proving it does not contain a leak.
|
||||
"""
|
||||
return bool(
|
||||
_TOOL_CALL_LEAK_PATTERN.search(
|
||||
text[:_TOOL_CALL_LEAK_SCAN_LIMIT + 64]
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -1081,6 +1096,7 @@ def _normalize_codex_response(
|
||||
message_items_raw: List[Dict[str, Any]] = []
|
||||
tool_calls: List[Any] = []
|
||||
has_incomplete_items = response_status in {"queued", "in_progress", "incomplete"}
|
||||
saw_streaming_or_item_incomplete = response_status in {"queued", "in_progress"}
|
||||
saw_commentary_phase = False
|
||||
saw_final_answer_phase = False
|
||||
saw_reasoning_item = False
|
||||
@@ -1095,6 +1111,7 @@ def _normalize_codex_response(
|
||||
|
||||
if item_status in {"queued", "in_progress", "incomplete"}:
|
||||
has_incomplete_items = True
|
||||
saw_streaming_or_item_incomplete = True
|
||||
|
||||
if item_type == "message":
|
||||
item_phase = getattr(item, "phase", None)
|
||||
@@ -1225,7 +1242,7 @@ def _normalize_codex_response(
|
||||
# ``function_call`` item. The existing loop already handles message
|
||||
# append, dedup, and retry budget.
|
||||
leaked_tool_call_text = False
|
||||
if final_text and not tool_calls and _TOOL_CALL_LEAK_PATTERN.search(final_text):
|
||||
if final_text and not tool_calls and _scan_for_leaked_tool_call(final_text):
|
||||
leaked_tool_call_text = True
|
||||
logger.warning(
|
||||
"Codex response contains leaked tool-call text in assistant content "
|
||||
@@ -1252,7 +1269,9 @@ def _normalize_codex_response(
|
||||
finish_reason = "tool_calls"
|
||||
elif leaked_tool_call_text:
|
||||
finish_reason = "incomplete"
|
||||
elif has_incomplete_items or (saw_commentary_phase and not saw_final_answer_phase):
|
||||
elif saw_streaming_or_item_incomplete:
|
||||
finish_reason = "incomplete"
|
||||
elif (has_incomplete_items or saw_commentary_phase) and not saw_final_answer_phase:
|
||||
finish_reason = "incomplete"
|
||||
elif (reasoning_items_raw or reasoning_parts or saw_reasoning_item) and not final_text:
|
||||
# Response contains only reasoning (encrypted thinking state and/or
|
||||
|
||||
@@ -707,7 +707,10 @@ def run_conversation(
|
||||
# a thinking-only turn. Runs on the per-call copy only — the
|
||||
# stored conversation history keeps the reasoning block for the
|
||||
# UI transcript and session persistence.
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(api_messages)
|
||||
api_messages = agent._drop_thinking_only_and_merge_users(
|
||||
api_messages,
|
||||
drop_codex_reasoning_items=agent.api_mode != "codex_responses",
|
||||
)
|
||||
|
||||
# Normalize message whitespace and tool-call JSON for consistent
|
||||
# prefix matching. Ensures bit-perfect prefixes across turns,
|
||||
|
||||
@@ -41,6 +41,16 @@ DEFAULT_GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta"
|
||||
GEMINI_DEFAULT_MAX_OUTPUT_TOKENS = 65535
|
||||
|
||||
|
||||
def bare_gemini_model_id(model: str) -> str:
|
||||
"""Strip Gemini's own provider prefix from an aggregator-style model id."""
|
||||
name = (model or "").strip()
|
||||
lowered = name.lower()
|
||||
for prefix in ("google/", "gemini/"):
|
||||
if lowered.startswith(prefix):
|
||||
return name[len(prefix):].strip() or name
|
||||
return name
|
||||
|
||||
|
||||
def is_native_gemini_base_url(base_url: str) -> bool:
|
||||
"""Return True when the endpoint speaks Gemini's native REST API."""
|
||||
normalized = str(base_url or "").strip().rstrip("/").lower()
|
||||
@@ -330,7 +340,7 @@ def _build_gemini_contents(messages: List[Dict[str, Any]]) -> tuple[List[Dict[st
|
||||
system_instruction = None
|
||||
joined_system = "\n".join(part for part in system_text_parts if part).strip()
|
||||
if joined_system:
|
||||
system_instruction = {"parts": [{"text": joined_system}]}
|
||||
system_instruction = {"role": "system", "parts": [{"text": joined_system}]}
|
||||
return contents, system_instruction
|
||||
|
||||
|
||||
@@ -914,6 +924,7 @@ class GeminiNativeClient:
|
||||
thinking_config=thinking_config,
|
||||
)
|
||||
|
||||
model = bare_gemini_model_id(model)
|
||||
if stream:
|
||||
return self._stream_completion(model=model, request=request, timeout=timeout)
|
||||
|
||||
|
||||
@@ -44,6 +44,66 @@ logger = logging.getLogger(__name__)
|
||||
_SYNC_DRAIN_TIMEOUT_S = 5.0
|
||||
|
||||
|
||||
def memory_provider_tools_enabled(enabled_toolsets: Optional[List[str]]) -> bool:
|
||||
"""Return whether external memory-provider tools should be exposed."""
|
||||
if enabled_toolsets is None:
|
||||
return True
|
||||
if not enabled_toolsets:
|
||||
return False
|
||||
if "memory" in enabled_toolsets:
|
||||
return True
|
||||
|
||||
try:
|
||||
from toolsets import resolve_toolset
|
||||
|
||||
return any("memory" in resolve_toolset(name) for name in enabled_toolsets)
|
||||
except Exception:
|
||||
logger.debug("Failed to resolve enabled toolsets for memory-provider tools", exc_info=True)
|
||||
return False
|
||||
|
||||
|
||||
def inject_memory_provider_tools(agent: Any) -> int:
|
||||
"""Append external memory-provider tool schemas to an agent tool surface."""
|
||||
memory_manager = getattr(agent, "_memory_manager", None)
|
||||
tools = getattr(agent, "tools", None)
|
||||
if not memory_manager or tools is None:
|
||||
return 0
|
||||
|
||||
existing_tool_names = {
|
||||
tool.get("function", {}).get("name")
|
||||
for tool in tools
|
||||
if isinstance(tool, dict)
|
||||
}
|
||||
if (
|
||||
"memory" not in existing_tool_names
|
||||
and not memory_provider_tools_enabled(getattr(agent, "enabled_toolsets", None))
|
||||
):
|
||||
return 0
|
||||
|
||||
get_schemas = getattr(memory_manager, "get_all_tool_schemas", None)
|
||||
if not callable(get_schemas):
|
||||
return 0
|
||||
|
||||
valid_tool_names = getattr(agent, "valid_tool_names", None)
|
||||
if valid_tool_names is None:
|
||||
valid_tool_names = set()
|
||||
agent.valid_tool_names = valid_tool_names
|
||||
|
||||
added = 0
|
||||
for schema in get_schemas():
|
||||
if not isinstance(schema, dict):
|
||||
continue
|
||||
tool_name = schema.get("name", "")
|
||||
if not tool_name or tool_name in existing_tool_names:
|
||||
continue
|
||||
tools.append({"type": "function", "function": schema})
|
||||
valid_tool_names.add(tool_name)
|
||||
existing_tool_names.add(tool_name)
|
||||
added += 1
|
||||
|
||||
return added
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Context fencing helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -135,7 +135,14 @@ def _repair_schema(node: Any, is_schema: bool = True) -> Any:
|
||||
|
||||
def _fill_missing_type(node: Dict[str, Any]) -> Dict[str, Any]:
|
||||
"""Infer a reasonable ``type`` if this schema node has none."""
|
||||
if "type" in node and node["type"] not in {None, ""}:
|
||||
node_type = node.get("type")
|
||||
if isinstance(node_type, list):
|
||||
concrete = next(
|
||||
(t for t in node_type if isinstance(t, str) and t not in {"", "null"}),
|
||||
"string",
|
||||
)
|
||||
return {**node, "type": concrete}
|
||||
if "type" in node and node_type not in {None, ""}:
|
||||
return node
|
||||
|
||||
# Heuristic: presence of ``properties`` → object, ``items`` → array, ``enum``
|
||||
|
||||
+14
-5
@@ -508,13 +508,22 @@ PLATFORM_HINTS = {
|
||||
),
|
||||
"telegram": (
|
||||
"You are on a text messaging communication platform, Telegram. "
|
||||
"Standard markdown is automatically converted to Telegram format. "
|
||||
"Standard Markdown is automatically converted to Telegram formatting. "
|
||||
"Supported: **bold**, *italic*, ~~strikethrough~~, ||spoiler||, "
|
||||
"`inline code`, ```code blocks```, [links](url), and ## headers. "
|
||||
"Telegram has NO table syntax — prefer bullet lists or labeled "
|
||||
"key: value pairs over pipe tables (any tables you do emit are "
|
||||
"auto-rewritten into row-group bullets, which you can produce "
|
||||
"directly for cleaner output). "
|
||||
"Telegram now supports rich Markdown, so lean into it: whenever it "
|
||||
"makes the answer clearer or easier to scan, actively reach for real "
|
||||
"Markdown tables (pipe `| col | col |` syntax), bullet and numbered "
|
||||
"lists, task lists (`- [ ]` / `- [x]`), headings, nested blockquotes, "
|
||||
"collapsible details, footnotes/references, math/formulas (`$...$`, "
|
||||
"`$$...$$`), underline, subscript/superscript, marked (highlighted) "
|
||||
"text, and anchors. Default to structured formatting over dense "
|
||||
"paragraphs for any comparison, set of steps, key/value summary, or "
|
||||
"tabular data. Prefer real Markdown tables and task lists over "
|
||||
"hand-built bullet substitutes when presenting structured data; these "
|
||||
"degrade gracefully (tables become readable bullet groups) when rich "
|
||||
"rendering is unavailable, but advanced constructs like math and "
|
||||
"collapsible details may render as plain source text in that case. "
|
||||
"You can send media files natively: to deliver a file to the user, "
|
||||
"include MEDIA:/absolute/path/to/file in your response. Images "
|
||||
"(.png, .jpg, .webp) appear as photos, audio (.ogg) sends as voice "
|
||||
|
||||
@@ -218,22 +218,10 @@ class ResponsesApiTransport(ProviderTransport):
|
||||
kwargs.pop("timeout", None)
|
||||
|
||||
if is_codex_backend:
|
||||
prompt_cache_key = kwargs.get("prompt_cache_key")
|
||||
cache_scope_id = str(prompt_cache_key or session_id or "").strip()
|
||||
if cache_scope_id:
|
||||
existing_extra_headers = kwargs.get("extra_headers")
|
||||
merged_extra_headers: Dict[str, str] = {}
|
||||
if isinstance(existing_extra_headers, dict):
|
||||
merged_extra_headers.update(
|
||||
{
|
||||
str(key): str(value)
|
||||
for key, value in existing_extra_headers.items()
|
||||
if key and value is not None
|
||||
}
|
||||
)
|
||||
merged_extra_headers["session_id"] = cache_scope_id
|
||||
merged_extra_headers["x-client-request-id"] = cache_scope_id
|
||||
kwargs["extra_headers"] = merged_extra_headers
|
||||
# chatgpt.com/backend-api/codex rejects body-level
|
||||
# ``extra_headers`` with HTTP 400. Correlation/cache routing for
|
||||
# this backend must not be sent through the Responses payload.
|
||||
kwargs.pop("extra_headers", None)
|
||||
|
||||
max_tokens = params.get("max_tokens")
|
||||
if max_tokens is not None and not is_codex_backend:
|
||||
|
||||
@@ -3,8 +3,9 @@
|
||||
//! Driven when the installer is launched as `Hermes-Setup.exe --update` (see
|
||||
//! `AppMode` in lib.rs). The desktop app hands off to us — it exits, then we:
|
||||
//!
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so the venv
|
||||
//! shim is free; otherwise `hermes update` aborts with exit code 2),
|
||||
//! 1. wait for the old Hermes desktop process to fully exit (so both the
|
||||
//! venv shim and packaged app.asar are free; otherwise `hermes update`
|
||||
//! or repair bootstrap can race locked files),
|
||||
//! 2. run `hermes update --yes --gateway` (Python/repo update; this does NOT
|
||||
//! rebuild apps/desktop by design — see cmd_update in hermes_cli/main.py),
|
||||
//! 3. run `hermes desktop --build-only` (the rebuild step update skips),
|
||||
@@ -38,8 +39,8 @@ use crate::events::{BootstrapEvent, LogStream, StageInfo, StageState};
|
||||
/// hermes_cli/main.py (sys.exit(2)). We surface a targeted message for this.
|
||||
const UPDATE_EXIT_CONCURRENT: i32 = 2;
|
||||
|
||||
/// How long to wait for the old desktop process to release the venv shim
|
||||
/// before giving up and letting `hermes update`'s own guard decide.
|
||||
/// How long to wait for the old desktop process to release files under the
|
||||
/// install tree before giving up and letting `hermes update`'s own guard decide.
|
||||
const DESKTOP_EXIT_WAIT: Duration = Duration::from_secs(20);
|
||||
const DESKTOP_EXIT_POLL: Duration = Duration::from_millis(500);
|
||||
|
||||
@@ -150,8 +151,10 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
// ---- pre-step: wait for the old desktop to die -----------------------
|
||||
// The desktop exec'd us then called app.exit(), but process teardown is
|
||||
// async on Windows. If it still holds the venv shim, `hermes update`
|
||||
// aborts with exit 2. Give it a bounded window to clear.
|
||||
wait_for_venv_free(&install_root, &app).await;
|
||||
// aborts with exit 2. If it still holds the packaged app.asar,
|
||||
// install.ps1's repair/re-clone path cannot move/remove the install tree.
|
||||
// Give both handles a bounded window to clear.
|
||||
wait_for_install_locks_free(&install_root, &app, "update").await;
|
||||
|
||||
// ---- stage 1: hermes update -----------------------------------------
|
||||
// Pass --branch so `hermes update` targets the branch this installer was
|
||||
@@ -173,8 +176,8 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
vec!["update".into(), "--yes".into(), "--gateway".into()];
|
||||
// --force skips `hermes update`'s Windows running-exe guard (which would
|
||||
// `sys.exit(2)` and dead-end the handoff). By contract the desktop has
|
||||
// already exited and waited for the venv shim to unlock before launching
|
||||
// us, and wait_for_venv_free below force-kills any straggler — so by the
|
||||
// already exited and waited for the install locks to clear before launching
|
||||
// us, and wait_for_install_locks_free below force-kills any straggler — so by the
|
||||
// time `hermes update` runs there is no legitimate hermes.exe to protect,
|
||||
// and the guard would only produce a false "Hermes is still running" stop.
|
||||
update_args.push("--force".into());
|
||||
@@ -391,48 +394,57 @@ async fn run_update(app: AppHandle) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Poll until the venv shim is no longer locked (Windows) or a bounded timeout
|
||||
/// elapses. On non-Windows this is a short fixed grace since file locking
|
||||
/// isn't the failure mode there.
|
||||
async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
let shim = venv_hermes(install_root);
|
||||
/// Poll until the venv shim AND packaged desktop app bundle are no longer locked
|
||||
/// (Windows) or a bounded timeout elapses. On non-Windows this is a short fixed
|
||||
/// grace since file locking isn't the failure mode there.
|
||||
pub(crate) async fn wait_for_install_locks_free(install_root: &Path, app: &AppHandle, stage: &str) {
|
||||
let lock_targets = install_lock_probe_paths(install_root);
|
||||
let deadline = Instant::now() + DESKTOP_EXIT_WAIT;
|
||||
|
||||
emit_log(app, Some("update"), LogStream::Stdout, "[update] waiting for Hermes to exit…");
|
||||
emit_log(app, Some(stage), LogStream::Stdout, "[handoff] waiting for Hermes to exit…");
|
||||
|
||||
loop {
|
||||
if !is_locked(&shim) {
|
||||
let locked = locked_paths(&lock_targets);
|
||||
if locked.is_empty() {
|
||||
return;
|
||||
}
|
||||
if Instant::now() >= deadline {
|
||||
// Last resort: a backend hermes.exe (or a grandchild it spawned)
|
||||
// is still holding the shim. The desktop should have reaped its
|
||||
// tree before handing off, but SIGTERM races / detached
|
||||
// grandchildren / AV handles can leave a straggler. Rather than
|
||||
// "proceed anyway" straight into uv's "Access is denied", force-kill
|
||||
// every hermes.exe except ourselves, then give the OS a beat to
|
||||
// unload the image.
|
||||
// Last resort: a backend hermes.exe (or the desktop Hermes.exe
|
||||
// itself) is still holding one of the update-sensitive files. The
|
||||
// desktop should have reaped its tree before handing off, but
|
||||
// SIGTERM races / detached grandchildren / AV handles can leave a
|
||||
// straggler. Rather than "proceed anyway" straight into uv's
|
||||
// "Access is denied" or install.ps1's locked app.asar failure,
|
||||
// force-kill every Hermes.exe except ourselves, then give the OS a
|
||||
// beat to unload the image.
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] Hermes still holding the venv shim; force-killing stragglers…",
|
||||
&format!(
|
||||
"[handoff] Hermes still holding install files ({}); force-killing stragglers…",
|
||||
format_locked_paths(&locked)
|
||||
),
|
||||
);
|
||||
force_kill_other_hermes();
|
||||
tokio::time::sleep(Duration::from_millis(800)).await;
|
||||
if !is_locked(&shim) {
|
||||
let locked_after_kill = locked_paths(&lock_targets);
|
||||
if locked_after_kill.is_empty() {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim freed after force-kill",
|
||||
"[handoff] install files freed after force-kill",
|
||||
);
|
||||
} else {
|
||||
emit_log(
|
||||
app,
|
||||
Some("update"),
|
||||
Some(stage),
|
||||
LogStream::Stdout,
|
||||
"[update] venv shim still locked; proceeding (--force + quarantine will handle it)",
|
||||
&format!(
|
||||
"[handoff] install files still locked ({}); proceeding (--force + quarantine will handle it)",
|
||||
format_locked_paths(&locked_after_kill)
|
||||
),
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -441,13 +453,44 @@ async fn wait_for_venv_free(install_root: &Path, app: &AppHandle) {
|
||||
}
|
||||
}
|
||||
|
||||
fn install_lock_probe_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let mut paths = vec![venv_hermes(install_root)];
|
||||
paths.extend(desktop_app_payload_paths(install_root));
|
||||
paths
|
||||
}
|
||||
|
||||
fn desktop_app_payload_paths(install_root: &Path) -> Vec<PathBuf> {
|
||||
let release = install_root.join("apps").join("desktop").join("release");
|
||||
if cfg!(target_os = "windows") {
|
||||
vec![
|
||||
release.join("win-unpacked").join("resources").join("app.asar"),
|
||||
release.join("win-arm64-unpacked").join("resources").join("app.asar"),
|
||||
]
|
||||
} else if cfg!(target_os = "macos") {
|
||||
vec![
|
||||
release.join("mac").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
release.join("mac-arm64").join("Hermes.app").join("Contents").join("Resources").join("app.asar"),
|
||||
]
|
||||
} else {
|
||||
vec![release.join("linux-unpacked").join("resources").join("app.asar")]
|
||||
}
|
||||
}
|
||||
|
||||
fn locked_paths(paths: &[PathBuf]) -> Vec<PathBuf> {
|
||||
paths.iter().filter(|p| is_locked(p)).cloned().collect()
|
||||
}
|
||||
|
||||
fn format_locked_paths(paths: &[PathBuf]) -> String {
|
||||
paths.iter().map(|p| p.display().to_string()).collect::<Vec<_>>().join(", ")
|
||||
}
|
||||
|
||||
/// Force-kill any `hermes.exe` other than this process. Windows-only; a no-op
|
||||
/// elsewhere (POSIX has no mandatory-lock contention). We can't selectively
|
||||
/// target "the backend" by PID here — the desktop already exited and we never
|
||||
/// knew its children — so we kill the whole `hermes.exe` image tree via
|
||||
/// taskkill, excluding our own PID.
|
||||
///
|
||||
/// Safe w.r.t. our own update child: this runs inside `wait_for_venv_free`,
|
||||
/// Safe w.r.t. our own update child: this runs inside the install-lock wait,
|
||||
/// which completes BEFORE we spawn `venv\Scripts\hermes.exe update`. At this
|
||||
/// point no update-driven hermes.exe exists yet, so the only hermes.exe images
|
||||
/// are stragglers from the old desktop — exactly what we want gone. (`/FI PID
|
||||
@@ -891,6 +934,29 @@ mod tests {
|
||||
assert!(!is_locked(Path::new("/nonexistent/does/not/exist/xyz")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn lock_probe_paths_include_desktop_app_payload() {
|
||||
let root = Path::new("/x/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(
|
||||
probes.iter().any(|p| p == &venv_hermes(root)),
|
||||
"venv shim remains part of the update lock probe"
|
||||
);
|
||||
assert!(
|
||||
probes.iter().any(|p| p.ends_with(Path::new("resources/app.asar"))),
|
||||
"packaged app.asar must be probed so repair/re-clone waits for the old desktop to exit"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn locked_paths_ignores_missing_payloads() {
|
||||
let root = Path::new("/nonexistent/hermes-agent");
|
||||
let probes = install_lock_probe_paths(root);
|
||||
|
||||
assert!(locked_paths(&probes).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parses_update_branch_from_space_or_equals_args() {
|
||||
assert_eq!(
|
||||
|
||||
@@ -1835,6 +1835,44 @@ async function applyUpdates(opts = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function handOffWindowsBootstrapRecovery(reason) {
|
||||
if (!IS_WINDOWS || !IS_PACKAGED) return false
|
||||
|
||||
const updater = resolveUpdaterBinary()
|
||||
if (!updater) return false
|
||||
|
||||
const updateRoot = resolveUpdateRoot()
|
||||
const { branch: configuredBranch } = readDesktopUpdateConfig()
|
||||
const branch = directoryExists(path.join(updateRoot, '.git'))
|
||||
? await resolveHealedBranch(updateRoot, configuredBranch || DEFAULT_UPDATE_BRANCH)
|
||||
: configuredBranch || DEFAULT_UPDATE_BRANCH
|
||||
const venvBin = path.join(updateRoot, 'venv', IS_WINDOWS ? 'Scripts' : 'bin')
|
||||
const venvHermes = path.join(venvBin, IS_WINDOWS ? 'hermes.exe' : 'hermes')
|
||||
const updaterArgs = fileExists(venvHermes) ? ['--update', '--branch', branch] : ['--repair', '--branch', branch]
|
||||
|
||||
await releaseBackendLockForUpdate(updateRoot)
|
||||
|
||||
const child = spawn(updater, updaterArgs, {
|
||||
cwd: HERMES_HOME,
|
||||
env: {
|
||||
...process.env,
|
||||
HERMES_HOME,
|
||||
PATH: [path.join(HERMES_HOME, 'node', 'bin'), venvBin, process.env.PATH].filter(Boolean).join(path.delimiter)
|
||||
},
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
windowsHide: false
|
||||
})
|
||||
child.unref()
|
||||
|
||||
rememberLog(`[bootstrap] handed off ${reason} recovery to updater: ${updater} ${updaterArgs.join(' ')}; exiting desktop to release app.asar`)
|
||||
setTimeout(() => {
|
||||
app.quit()
|
||||
}, 600)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
// Resolve the hermes CLI to drive an in-app update: prefer the venv shim in
|
||||
// the install we're updating, fall back to `hermes` on PATH.
|
||||
function resolveHermesCliBinary(updateRoot) {
|
||||
@@ -2432,6 +2470,14 @@ async function ensureRuntime(backend) {
|
||||
if (backend.kind === 'bootstrap-needed') {
|
||||
rememberLog('[bootstrap] no Hermes install found; starting first-launch bootstrap')
|
||||
|
||||
if (await handOffWindowsBootstrapRecovery('bootstrap-needed')) {
|
||||
const handoffError = new Error('Hermes recovery was handed off to Hermes Setup. The desktop will restart when recovery completes.')
|
||||
handoffError.isBootstrapFailure = true
|
||||
handoffError.bootstrapHandedOff = true
|
||||
bootstrapFailure = handoffError
|
||||
throw handoffError
|
||||
}
|
||||
|
||||
// Eagerly flip the bootstrap UI state to 'active' so the renderer
|
||||
// shows the install overlay BEFORE the runner finishes fetching the
|
||||
// manifest (which on slow networks can take tens of seconds and would
|
||||
|
||||
@@ -42,6 +42,9 @@ test('intentional or interactive desktop child processes stay documented', () =>
|
||||
const source = readElectronFile('main.cjs')
|
||||
|
||||
assert.match(source, /windowsHide: false/)
|
||||
assert.match(source, /handOffWindowsBootstrapRecovery/)
|
||||
assert.match(source, /'--repair', '--branch'/)
|
||||
assert.match(source, /'--update', '--branch'/)
|
||||
assert.match(source, /nodePty\.spawn\(command, args/)
|
||||
assert.match(source, /spawn\('cmd\.exe', \['\/c', 'start'/)
|
||||
})
|
||||
|
||||
@@ -85,6 +85,8 @@ import {
|
||||
import { QueuePanel } from './queue-panel'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
placeCaretEnd,
|
||||
refChipElement,
|
||||
@@ -135,6 +137,12 @@ function slashChipKindForItem(item: Unstable_TriggerItem): SlashChipKind {
|
||||
return 'command'
|
||||
}
|
||||
|
||||
/** A `/` query is at its arg stage once it's past the command name. */
|
||||
const slashArgStage = (query: string) => query.includes(' ')
|
||||
|
||||
/** The `/command` token of a slash query (`personality x` → `/personality`). */
|
||||
const slashCommandToken = (query: string) => `/${query.split(/\s+/, 1)[0]?.toLowerCase() ?? ''}`
|
||||
|
||||
interface QueueEditState {
|
||||
attachments: ComposerAttachment[]
|
||||
draft: string
|
||||
@@ -532,48 +540,6 @@ export function ChatBar({
|
||||
})
|
||||
}, [])
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
document.execCommand('insertText', false, pastedText)
|
||||
const nextDraft = composerPlainText(event.currentTarget)
|
||||
draftRef.current = nextDraft
|
||||
aui.composer().setText(nextDraft)
|
||||
}
|
||||
|
||||
const [trigger, setTrigger] = useState<TriggerState | null>(null)
|
||||
const [triggerActive, setTriggerActive] = useState(0)
|
||||
const [triggerItems, setTriggerItems] = useState<readonly Unstable_TriggerItem[]>([])
|
||||
@@ -610,7 +576,15 @@ export function ChatBar({
|
||||
}
|
||||
|
||||
const before = textBeforeCaret(editor)
|
||||
const detected = detectTrigger(before ?? composerPlainText(editor))
|
||||
const found = detectTrigger(before ?? composerPlainText(editor))
|
||||
|
||||
// The arg-stage popover is only useful for commands with an options screen.
|
||||
// For a no-arg command it would dead-end on "No matches", so drop it — the
|
||||
// directive is already complete.
|
||||
const detected =
|
||||
found?.kind === '/' && slashArgStage(found.query) && !desktopSlashCommandTakesArgs(slashCommandToken(found.query))
|
||||
? null
|
||||
: found
|
||||
|
||||
setTrigger(detected)
|
||||
|
||||
@@ -650,6 +624,46 @@ export function ChatBar({
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const handlePaste = (event: ClipboardEvent<HTMLDivElement>) => {
|
||||
const imageBlobs = extractClipboardImageBlobs(event.clipboardData)
|
||||
|
||||
if (imageBlobs.length > 0) {
|
||||
event.preventDefault()
|
||||
|
||||
if (onAttachImageBlob) {
|
||||
triggerHaptic('selection')
|
||||
|
||||
for (const blob of imageBlobs) {
|
||||
void onAttachImageBlob(blob)
|
||||
}
|
||||
}
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Trim surrounding whitespace so a copy that dragged along leading/trailing
|
||||
// blank lines (common when selecting from terminals, code blocks, web pages)
|
||||
// doesn't dump multiline padding into the composer. Internal newlines are
|
||||
// preserved — only the edges are cleaned up.
|
||||
const pastedText = event.clipboardData.getData('text').trim()
|
||||
|
||||
if (!pastedText) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
if (DATA_IMAGE_URL_RE.test(pastedText)) {
|
||||
event.preventDefault()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
event.preventDefault()
|
||||
insertPlainTextAtCaret(event.currentTarget, pastedText)
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
}
|
||||
|
||||
const triggerAdapter: Unstable_TriggerAdapter | null =
|
||||
trigger?.kind === '@' ? at.adapter : trigger?.kind === '/' ? slash.adapter : null
|
||||
|
||||
@@ -665,6 +679,12 @@ export function ChatBar({
|
||||
|
||||
const triggerLoading = trigger?.kind === '@' ? at.loading : trigger?.kind === '/' ? slash.loading : false
|
||||
|
||||
// Suppress the "No matches" empty state once a slash command is past its name:
|
||||
// a no-arg command has nothing to offer, and a fully-typed arg commits on
|
||||
// Space/Tab — neither should dead-end on a popover.
|
||||
const argStageEmpty =
|
||||
trigger?.kind === '/' && slashArgStage(trigger.query) && !triggerLoading && !triggerItems.length
|
||||
|
||||
const closeTrigger = () => {
|
||||
setTrigger(null)
|
||||
setTriggerItems([])
|
||||
@@ -675,6 +695,25 @@ export function ChatBar({
|
||||
setTriggerActive(idx => Math.min(idx, Math.max(0, triggerItems.length - 1)))
|
||||
}, [triggerItems.length])
|
||||
|
||||
// Commit the literally-typed `/command arg` as a directive chip — used when
|
||||
// the completion list is empty because the arg is already fully typed (the
|
||||
// backend completer drops exact matches). Reuses the chip path via a
|
||||
// synthetic item whose serialized form is the verbatim text.
|
||||
const commitTypedSlashDirective = () => {
|
||||
if (trigger?.kind !== '/') {
|
||||
return
|
||||
}
|
||||
|
||||
const text = `/${trigger.query.trimEnd()}`
|
||||
|
||||
replaceTriggerWithChip({
|
||||
id: text,
|
||||
type: 'slash',
|
||||
label: text.slice(1),
|
||||
metadata: { command: slashCommandToken(trigger.query), display: text, meta: '', group: '', action: '', rawText: text }
|
||||
})
|
||||
}
|
||||
|
||||
const replaceTriggerWithChip = (item: Unstable_TriggerItem) => {
|
||||
const editor = editorRef.current
|
||||
|
||||
@@ -793,6 +832,18 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
// Non-collapsed Backspace/Delete: native selection-delete is ~O(n²) on large
|
||||
// drafts (Ctrl+A → Delete froze ~1.3s). Collapsed carets fall through.
|
||||
if (
|
||||
(event.key === 'Backspace' || event.key === 'Delete') &&
|
||||
deleteSelectionInEditor(event.currentTarget)
|
||||
) {
|
||||
event.preventDefault()
|
||||
flushEditorToDraft(event.currentTarget)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// Cmd/Ctrl+Shift+K drains the next queued message. Plain Cmd/Ctrl+K is
|
||||
// reserved for the global command palette.
|
||||
if ((event.metaKey || event.ctrlKey) && !event.altKey && event.shiftKey && event.key.toLowerCase() === 'k') {
|
||||
@@ -822,7 +873,15 @@ export function ChatBar({
|
||||
return
|
||||
}
|
||||
|
||||
if (event.key === 'Enter' || event.key === 'Tab') {
|
||||
// Enter / Tab / Space all accept the highlighted item: a no-arg command
|
||||
// commits its directive chip, an arg-taking command expands to its
|
||||
// options step, and an arg option commits the full `/cmd arg` chip. Space
|
||||
// is slash-only (an `@` mention takes a literal space) and gated to a
|
||||
// non-empty query so a bare `/ ` still types a space.
|
||||
const acceptOnSpace = event.key === ' ' && trigger.kind === '/' && Boolean(trigger.query.trim())
|
||||
const accept = event.key === 'Enter' || event.key === 'Tab' || acceptOnSpace
|
||||
|
||||
if (accept) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
const item = triggerItems[triggerActive]
|
||||
@@ -843,6 +902,24 @@ export function ChatBar({
|
||||
}
|
||||
}
|
||||
|
||||
// Arg stage with nothing left to suggest — a fully-typed arg the backend
|
||||
// completer no longer echoes (it drops the exact match), e.g.
|
||||
// `/personality creative`. Space/Tab still commit what's typed as a single
|
||||
// directive chip; Enter falls through to submit (send it as-is).
|
||||
if (
|
||||
trigger?.kind === '/' &&
|
||||
!triggerItems.length &&
|
||||
(event.key === ' ' || event.key === 'Tab') &&
|
||||
slashArgStage(trigger.query) &&
|
||||
trigger.query.trim()
|
||||
) {
|
||||
event.preventDefault()
|
||||
triggerKeyConsumedRef.current = true
|
||||
commitTypedSlashDirective()
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
// ArrowUp/ArrowDown navigate, in priority order: the queue (edit entries in
|
||||
// place) then sent-message history. The history ring is derived from live
|
||||
// session messages each press — single source of truth, no mirror.
|
||||
@@ -1765,7 +1842,7 @@ export function ChatBar({
|
||||
ref={composerRef}
|
||||
>
|
||||
{showHelpHint && <HelpHint />}
|
||||
{trigger && (
|
||||
{trigger && !argStageEmpty && (
|
||||
<ComposerTriggerPopover
|
||||
activeIndex={triggerActive}
|
||||
items={triggerItems}
|
||||
|
||||
@@ -3,12 +3,24 @@ import { describe, expect, it } from 'vitest'
|
||||
import { insertInlineRefsIntoEditor } from './inline-refs'
|
||||
import {
|
||||
composerPlainText,
|
||||
deleteSelectionInEditor,
|
||||
insertPlainTextAtCaret,
|
||||
normalizeComposerEditorDom,
|
||||
refChipElement,
|
||||
renderComposerContents,
|
||||
RICH_INPUT_SLOT
|
||||
} from './rich-editor'
|
||||
|
||||
const caretIn = (editor: HTMLElement) => {
|
||||
const range = document.createRange()
|
||||
const selection = window.getSelection()!
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
range.collapse(false)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
}
|
||||
|
||||
describe('renderComposerContents', () => {
|
||||
it('renders refs and raw text without interpreting user text as HTML', () => {
|
||||
const editor = document.createElement('div')
|
||||
@@ -59,3 +71,64 @@ describe('insertInlineRefsIntoEditor', () => {
|
||||
expect(composerPlainText(editor)).toBe('@file:`src/foo.ts` ')
|
||||
})
|
||||
})
|
||||
|
||||
describe('insertPlainTextAtCaret', () => {
|
||||
it('inserts multiline text as text nodes + br', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
document.body.append(editor)
|
||||
caretIn(editor)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'one\ntwo\nthree')
|
||||
|
||||
expect(editor.querySelectorAll('br').length).toBe(2)
|
||||
expect(composerPlainText(editor)).toBe('one\ntwo\nthree')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
|
||||
it('replaces the selected span', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'abXYef'
|
||||
document.body.append(editor)
|
||||
|
||||
const text = editor.firstChild!
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.setStart(text, 2)
|
||||
range.setEnd(text, 4)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
insertPlainTextAtCaret(editor, 'cd')
|
||||
|
||||
expect(composerPlainText(editor)).toBe('abcdef')
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
describe('deleteSelectionInEditor', () => {
|
||||
it('clears a non-collapsed range and leaves a collapsed caret', () => {
|
||||
const editor = document.createElement('div')
|
||||
editor.dataset.slot = RICH_INPUT_SLOT
|
||||
editor.textContent = 'hello world'
|
||||
document.body.append(editor)
|
||||
|
||||
const selection = window.getSelection()!
|
||||
const range = document.createRange()
|
||||
|
||||
range.selectNodeContents(editor)
|
||||
selection.removeAllRanges()
|
||||
selection.addRange(range)
|
||||
|
||||
expect(deleteSelectionInEditor(editor)).toBe(true)
|
||||
expect(composerPlainText(editor)).toBe('')
|
||||
expect(selection.getRangeAt(0).collapsed).toBe(true)
|
||||
expect(deleteSelectionInEditor(editor)).toBe(false)
|
||||
|
||||
editor.remove()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -132,6 +132,63 @@ export function renderComposerContents(target: HTMLElement, text: string) {
|
||||
appendComposerContents(target, text)
|
||||
}
|
||||
|
||||
/** Caret range when the selection lives inside `editor`; else null. */
|
||||
function composerSelectionRange(editor: HTMLElement) {
|
||||
const selection = window.getSelection()
|
||||
const range = selection?.rangeCount ? selection.getRangeAt(0) : null
|
||||
|
||||
if (!selection || !range || !editor.contains(range.commonAncestorContainer)) {
|
||||
return null
|
||||
}
|
||||
|
||||
return { range, selection }
|
||||
}
|
||||
|
||||
/** Insert plain text at the caret (replacing any selection). Pastes use this
|
||||
* instead of `execCommand('insertText')` — Chromium's editing pipeline is
|
||||
* ~O(n²) on large multiline blobs. */
|
||||
export function insertPlainTextAtCaret(editor: HTMLElement, text: string) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
const fragment = document.createDocumentFragment()
|
||||
|
||||
appendTextWithBreaks(fragment, text)
|
||||
|
||||
const tail = fragment.lastChild
|
||||
|
||||
if (hit) {
|
||||
hit.range.deleteContents()
|
||||
hit.range.insertNode(fragment)
|
||||
} else {
|
||||
editor.append(fragment)
|
||||
}
|
||||
|
||||
if (tail) {
|
||||
const caret = document.createRange()
|
||||
caret.setStartAfter(tail)
|
||||
caret.collapse(true)
|
||||
const selection = hit?.selection ?? window.getSelection()
|
||||
selection?.removeAllRanges()
|
||||
selection?.addRange(caret)
|
||||
}
|
||||
}
|
||||
|
||||
/** Remove a non-collapsed selection in-editor. Skips collapsed carets so word/
|
||||
* line delete (Opt/Cmd+Backspace) stays native. Returns whether anything ran. */
|
||||
export function deleteSelectionInEditor(editor: HTMLElement) {
|
||||
const hit = composerSelectionRange(editor)
|
||||
|
||||
if (!hit || hit.range.collapsed) {
|
||||
return false
|
||||
}
|
||||
|
||||
hit.range.deleteContents()
|
||||
hit.range.collapse(true)
|
||||
hit.selection.removeAllRanges()
|
||||
hit.selection.addRange(hit.range)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
/** Serialize a draft string into chip-HTML for the contenteditable surface. */
|
||||
export function composerHtml(text: string) {
|
||||
let cursor = 0
|
||||
|
||||
@@ -284,6 +284,7 @@ export function ProfileRail() {
|
||||
selectProfile(name)
|
||||
}}
|
||||
open={createOpen}
|
||||
profiles={profiles}
|
||||
/>
|
||||
|
||||
<RenameProfileDialog
|
||||
|
||||
@@ -2,14 +2,15 @@ import { useEffect, useState } from 'react'
|
||||
|
||||
import { ActionStatus } from '@/components/ui/action-status'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle } from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import { createProfile, updateProfileSoul } from '@/hermes'
|
||||
import { useI18n } from '@/i18n'
|
||||
import { AlertTriangle } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import type { ProfileInfo } from '@/types/hermes'
|
||||
|
||||
const PROFILE_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/
|
||||
|
||||
@@ -23,16 +24,18 @@ export function isValidProfileName(name: string): boolean {
|
||||
export function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreated,
|
||||
open
|
||||
open,
|
||||
profiles = []
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreated?: (name: string) => Promise<void> | void
|
||||
open: boolean
|
||||
profiles?: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [soul, setSoul] = useState('')
|
||||
const [status, setStatus] = useState<'done' | 'idle' | 'saving'>('idle')
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
@@ -43,7 +46,7 @@ export function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setCloneFrom('default')
|
||||
setSoul('')
|
||||
setError(null)
|
||||
setStatus('idle')
|
||||
@@ -66,7 +69,7 @@ export function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
|
||||
if (soul.trim()) {
|
||||
await updateProfileSoul(trimmed, soul)
|
||||
@@ -107,17 +110,25 @@ export function CreateProfileDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer select-none items-start gap-2.5 px-0.5 py-1">
|
||||
<Checkbox
|
||||
checked={cloneFromDefault}
|
||||
className="mt-0.5 shrink-0"
|
||||
onCheckedChange={checked => setCloneFromDefault(checked === true)}
|
||||
/>
|
||||
<span className="grid gap-0.5 leading-snug">
|
||||
<span className="text-sm font-medium">{p.cloneFromDefault}</span>
|
||||
<span className="text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-soul">
|
||||
@@ -127,7 +138,7 @@ export function CreateProfileDialog({
|
||||
className="min-h-28 font-mono text-xs leading-5"
|
||||
id="new-profile-soul"
|
||||
onChange={event => setSoul(event.target.value)}
|
||||
placeholder={p.soulPlaceholder(cloneFromDefault ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
placeholder={p.soulPlaceholder(cloneFrom ? p.soulPlaceholderCloned : p.soulPlaceholderEmpty)}
|
||||
value={soul}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
DialogTitle
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
import {
|
||||
createProfile,
|
||||
@@ -82,14 +83,14 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
}, [profiles, selectedName])
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async (name: string, cloneFromDefault: boolean) => {
|
||||
async (name: string, cloneFrom: null | string) => {
|
||||
const trimmed = name.trim()
|
||||
|
||||
if (!isValidProfileName(trimmed)) {
|
||||
throw new Error(p.nameHint)
|
||||
}
|
||||
|
||||
await createProfile({ name: trimmed, clone_from_default: cloneFromDefault })
|
||||
await createProfile({ name: trimmed, clone_from: cloneFrom })
|
||||
notify({ kind: 'success', title: p.created, message: trimmed })
|
||||
setSelectedName(trimmed)
|
||||
await refresh()
|
||||
@@ -180,8 +181,9 @@ export function ProfilesView({ onClose }: ProfilesViewProps) {
|
||||
|
||||
<CreateProfileDialog
|
||||
onClose={() => setCreateOpen(false)}
|
||||
onCreate={async (name, cloneFromDefault) => handleCreate(name, cloneFromDefault)}
|
||||
onCreate={async (name, cloneFrom) => handleCreate(name, cloneFrom)}
|
||||
open={createOpen}
|
||||
profiles={profiles ?? []}
|
||||
/>
|
||||
|
||||
<Dialog onOpenChange={open => !open && !deleting && setPendingDelete(null)} open={pendingDelete !== null}>
|
||||
@@ -453,16 +455,18 @@ function SoulEditor({ profileName }: { profileName: string }) {
|
||||
function CreateProfileDialog({
|
||||
onClose,
|
||||
onCreate,
|
||||
open
|
||||
open,
|
||||
profiles
|
||||
}: {
|
||||
onClose: () => void
|
||||
onCreate: (name: string, cloneFromDefault: boolean) => Promise<void>
|
||||
onCreate: (name: string, cloneFrom: null | string) => Promise<void>
|
||||
open: boolean
|
||||
profiles: ProfileInfo[]
|
||||
}) {
|
||||
const { t } = useI18n()
|
||||
const p = t.profiles
|
||||
const [name, setName] = useState('')
|
||||
const [cloneFromDefault, setCloneFromDefault] = useState(true)
|
||||
const [cloneFrom, setCloneFrom] = useState<null | string>('default')
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [error, setError] = useState<null | string>(null)
|
||||
|
||||
@@ -472,7 +476,7 @@ function CreateProfileDialog({
|
||||
}
|
||||
|
||||
setName('')
|
||||
setCloneFromDefault(true)
|
||||
setCloneFrom('default')
|
||||
setError(null)
|
||||
setSaving(false)
|
||||
}, [open])
|
||||
@@ -493,7 +497,7 @@ function CreateProfileDialog({
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
await onCreate(trimmed, cloneFromDefault)
|
||||
await onCreate(trimmed, cloneFrom)
|
||||
onClose()
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : p.failedCreate)
|
||||
@@ -528,18 +532,25 @@ function CreateProfileDialog({
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<label className="flex cursor-pointer items-center gap-2 rounded-md border border-border/40 bg-background/50 px-3 py-2 text-sm">
|
||||
<input
|
||||
checked={cloneFromDefault}
|
||||
className="size-4 accent-primary"
|
||||
onChange={event => setCloneFromDefault(event.target.checked)}
|
||||
type="checkbox"
|
||||
/>
|
||||
<span>
|
||||
<span className="font-medium">{p.cloneFromDefault}</span>
|
||||
<span className="ml-2 text-xs text-muted-foreground">{p.cloneFromDefaultDesc}</span>
|
||||
</span>
|
||||
</label>
|
||||
<div className="grid gap-1.5">
|
||||
<label className="text-xs font-medium" htmlFor="new-profile-clone-from">
|
||||
{p.cloneFrom}
|
||||
</label>
|
||||
<Select onValueChange={value => setCloneFrom(value === '__none__' ? null : value)} value={cloneFrom ?? '__none__'}>
|
||||
<SelectTrigger className="h-9 rounded-md" id="new-profile-clone-from">
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="__none__">{p.cloneFromNone}</SelectItem>
|
||||
{profiles.map(profile => (
|
||||
<SelectItem key={profile.name} value={profile.name}>
|
||||
{profile.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="text-xs text-muted-foreground">{p.cloneFromDesc}</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/10 px-3 py-2 text-xs text-destructive">
|
||||
|
||||
@@ -84,6 +84,19 @@ describe('PendingToolApproval', () => {
|
||||
expect($approvalRequest.get()).toBeNull()
|
||||
})
|
||||
|
||||
it('reveals the full command inline when the Command toggle is clicked', () => {
|
||||
const longCommand = 'python -c "' + 'x'.repeat(400) + '"'
|
||||
setRequest(longCommand)
|
||||
render(<PendingToolApproval part={part('terminal')} />)
|
||||
|
||||
// Collapsed by default: the full command is not in the DOM yet.
|
||||
expect(screen.queryByText(longCommand)).toBeNull()
|
||||
|
||||
fireEvent.click(screen.getByRole('button', { name: /Command/ }))
|
||||
|
||||
expect(screen.getByText(longCommand)).toBeTruthy()
|
||||
})
|
||||
|
||||
it('sends choice "deny" on Reject', async () => {
|
||||
const request = mockGateway()
|
||||
setRequest()
|
||||
|
||||
@@ -16,6 +16,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigge
|
||||
import { useI18n } from '@/i18n'
|
||||
import { triggerHaptic } from '@/lib/haptics'
|
||||
import { ChevronDown, Loader2 } from '@/lib/icons'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { $gateway } from '@/store/gateway'
|
||||
import { notifyError } from '@/store/notifications'
|
||||
import { $approvalRequest, type ApprovalRequest, clearApprovalRequest } from '@/store/prompts'
|
||||
@@ -60,9 +61,15 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
// "Always allow" persists the pattern to ~/.hermes/config.yaml permanently, so
|
||||
// it goes through a confirm step rather than firing straight from the menu.
|
||||
const [confirmAlways, setConfirmAlways] = useState(false)
|
||||
// The pending tool row only shows a single truncated line of the command, and
|
||||
// a pending row can't be expanded (no result yet), so the full command was
|
||||
// previously only reachable via the "Always allow" modal. Let the user reveal
|
||||
// it inline instead — "expand, Run" (2 clicks) rather than the modal dance.
|
||||
const [showCommand, setShowCommand] = useState(false)
|
||||
const busy = submitting !== null
|
||||
// false when the backend won't honor a permanent allow (tirith warning) → hide "Always allow".
|
||||
const allowPermanent = request.allowPermanent !== false
|
||||
const hasCommand = request.command.trim().length > 0
|
||||
|
||||
const respond = useCallback(
|
||||
async (choice: ApprovalChoice) => {
|
||||
@@ -119,70 +126,89 @@ const ApprovalBar: FC<{ request: ApprovalRequest }> = ({ request }) => {
|
||||
}, [confirmAlways, respond])
|
||||
|
||||
return (
|
||||
<div className="mt-1 flex items-center gap-2.5 ps-5" data-slot="tool-approval-inline">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<div className="mt-1 ps-5" data-slot="tool-approval-inline">
|
||||
<div className="flex items-center gap-2.5">
|
||||
<div className="inline-flex h-6 items-stretch overflow-hidden rounded-md border border-primary/25 bg-primary/10 text-primary">
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('once')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
</Button>
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={copy.moreOptions}
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
{allowPermanent && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="h-full gap-1 rounded-none px-2 text-xs font-medium text-primary hover:bg-primary/15 hover:text-primary"
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('once')}
|
||||
onClick={() => void respond('deny')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'once' ? <Loader2 className="size-3 animate-spin" /> : copy.run}
|
||||
{submitting !== 'once' && <span className="text-[0.625rem] text-primary/60">{isMac ? '⌘⏎' : 'Ctrl⏎'}</span>}
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||
</Button>
|
||||
<span aria-hidden className="w-px self-stretch bg-primary/20" />
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
aria-label={copy.moreOptions}
|
||||
className="h-full w-5 rounded-none px-0 text-primary hover:bg-primary/15 hover:text-primary"
|
||||
disabled={busy}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
<ChevronDown className="size-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="min-w-44">
|
||||
<DropdownMenuItem onSelect={() => void respond('session')}>{copy.allowSession}</DropdownMenuItem>
|
||||
{allowPermanent && (
|
||||
<DropdownMenuItem
|
||||
onSelect={() => {
|
||||
// Defer one tick so the menu fully unmounts before the dialog
|
||||
// mounts — otherwise Radix's focus-return races the dialog and
|
||||
// dismisses it via onInteractOutside.
|
||||
setTimeout(() => setConfirmAlways(true), 0)
|
||||
}}
|
||||
>
|
||||
{copy.alwaysAllowMenu}
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onSelect={() => void respond('deny')} variant="destructive">
|
||||
{copy.reject}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
|
||||
{hasCommand && (
|
||||
<Button
|
||||
aria-expanded={showCommand}
|
||||
className="h-6 gap-1 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
onClick={() => setShowCommand(value => !value)}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{copy.command}
|
||||
<ChevronDown className={cn('size-3 transition-transform', showCommand && 'rotate-180')} />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
className="h-6 gap-1.5 rounded-md px-1.5 text-xs font-normal text-(--ui-text-tertiary) hover:text-foreground"
|
||||
disabled={busy}
|
||||
onClick={() => void respond('deny')}
|
||||
size="xs"
|
||||
variant="ghost"
|
||||
>
|
||||
{submitting === 'deny' ? <Loader2 className="size-3 animate-spin" /> : copy.reject}
|
||||
{submitting !== 'deny' && <span className="text-[0.625rem] opacity-55">Esc</span>}
|
||||
</Button>
|
||||
{showCommand && hasCommand && (
|
||||
<pre className="mt-1.5 max-h-40 overflow-auto whitespace-pre-wrap break-words rounded-md border border-(--ui-stroke-tertiary) bg-(--ui-chat-surface-background) px-2.5 py-1.5 font-mono text-xs leading-snug text-foreground">
|
||||
{request.command.trim()}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
<Dialog onOpenChange={setConfirmAlways} open={confirmAlways}>
|
||||
<DialogContent className="max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{copy.alwaysTitle}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{copy.alwaysDescription(request.description)}
|
||||
</DialogDescription>
|
||||
<DialogDescription>{copy.alwaysDescription(request.description)}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{request.command.trim() && (
|
||||
|
||||
@@ -903,6 +903,9 @@ export const en: Translations = {
|
||||
deleting: 'Deleting...',
|
||||
createDesc: 'Profiles are independent Hermes environments: separate config, skills, and SOUL.md.',
|
||||
nameLabel: 'Name',
|
||||
cloneFrom: 'Clone from',
|
||||
cloneFromNone: 'None (blank)',
|
||||
cloneFromDesc: 'Copies config, skills, and SOUL.md from the selected source profile.',
|
||||
cloneFromDefault: 'Clone from default',
|
||||
cloneFromDefaultDesc: 'Copy config, skills, and SOUL.md from your default profile.',
|
||||
invalidName: hint => `Invalid name. ${hint}`,
|
||||
@@ -1687,6 +1690,7 @@ export const en: Translations = {
|
||||
gatewayDisconnected: 'Hermes gateway is not connected',
|
||||
sendFailed: 'Could not send approval response',
|
||||
run: 'Run',
|
||||
command: 'Command',
|
||||
moreOptions: 'More approval options',
|
||||
allowSession: 'Allow this session',
|
||||
alwaysAllowMenu: 'Always allow…',
|
||||
|
||||
@@ -1041,6 +1041,9 @@ export const ja = defineLocale({
|
||||
deleting: '削除中...',
|
||||
createDesc: 'プロファイルは独立した Hermes 環境です:設定、スキル、SOUL.md が別々になります。',
|
||||
nameLabel: '名前',
|
||||
cloneFrom: '複製元',
|
||||
cloneFromNone: 'なし(空)',
|
||||
cloneFromDesc: '選択したプロファイルから設定、スキル、SOUL.md をコピーします。',
|
||||
cloneFromDefault: 'デフォルトプロファイルから設定を複製',
|
||||
cloneFromDefaultDesc: 'デフォルトプロファイルから設定、スキル、SOUL.md をコピーします。',
|
||||
invalidName: hint => `無効なプロファイル名。${hint}`,
|
||||
@@ -1827,6 +1830,7 @@ export const ja = defineLocale({
|
||||
gatewayDisconnected: 'Hermes ゲートウェイが接続されていません',
|
||||
sendFailed: '承認応答を送信できませんでした',
|
||||
run: '実行',
|
||||
command: 'コマンド',
|
||||
moreOptions: 'その他の承認オプション',
|
||||
allowSession: 'このセッションで許可',
|
||||
alwaysAllowMenu: '常に許可…',
|
||||
|
||||
@@ -695,6 +695,9 @@ export interface Translations {
|
||||
deleting: string
|
||||
createDesc: string
|
||||
nameLabel: string
|
||||
cloneFrom: string
|
||||
cloneFromNone: string
|
||||
cloneFromDesc: string
|
||||
cloneFromDefault: string
|
||||
cloneFromDefaultDesc: string
|
||||
invalidName: (hint: string) => string
|
||||
@@ -1346,6 +1349,7 @@ export interface Translations {
|
||||
gatewayDisconnected: string
|
||||
sendFailed: string
|
||||
run: string
|
||||
command: string
|
||||
moreOptions: string
|
||||
allowSession: string
|
||||
alwaysAllowMenu: string
|
||||
|
||||
@@ -999,6 +999,9 @@ export const zhHant = defineLocale({
|
||||
deleting: '刪除中…',
|
||||
createDesc: '設定檔是獨立的 Hermes 環境:各自擁有獨立的設定、技能和 SOUL.md。',
|
||||
nameLabel: '名稱',
|
||||
cloneFrom: '複製來源',
|
||||
cloneFromNone: '無(空白)',
|
||||
cloneFromDesc: '從選取的來源設定檔複製設定、技能和 SOUL.md。',
|
||||
cloneFromDefault: '從預設設定檔複製設定',
|
||||
cloneFromDefaultDesc: '從您的預設設定檔複製設定、技能和 SOUL.md。',
|
||||
invalidName: hint => `設定檔名稱無效。${hint}`,
|
||||
@@ -1771,6 +1774,7 @@ export const zhHant = defineLocale({
|
||||
gatewayDisconnected: 'Hermes 閘道未連線',
|
||||
sendFailed: '無法傳送核准回應',
|
||||
run: '執行',
|
||||
command: '指令',
|
||||
moreOptions: '更多核准選項',
|
||||
allowSession: '允許本工作階段',
|
||||
alwaysAllowMenu: '一律允許…',
|
||||
|
||||
@@ -1092,6 +1092,9 @@ export const zh: Translations = {
|
||||
deleting: '删除中…',
|
||||
createDesc: '配置档案是相互独立的 Hermes 环境:各自拥有独立的配置、技能和 SOUL.md。',
|
||||
nameLabel: '名称',
|
||||
cloneFrom: '克隆来源',
|
||||
cloneFromNone: '无(空白)',
|
||||
cloneFromDesc: '从选中的来源配置档案复制配置、技能和 SOUL.md。',
|
||||
cloneFromDefault: '从默认档案克隆',
|
||||
cloneFromDefaultDesc: '从你的默认配置档案复制配置、技能和 SOUL.md。',
|
||||
invalidName: hint => `名称无效。${hint}`,
|
||||
@@ -1867,6 +1870,7 @@ export const zh: Translations = {
|
||||
gatewayDisconnected: 'Hermes 网关未连接',
|
||||
sendFailed: '无法发送审批响应',
|
||||
run: '运行',
|
||||
command: '命令',
|
||||
moreOptions: '更多审批选项',
|
||||
allowSession: '允许本会话',
|
||||
alwaysAllowMenu: '始终允许…',
|
||||
|
||||
@@ -470,7 +470,7 @@ export interface CronJobUpdates {
|
||||
|
||||
export interface ProfileCreatePayload {
|
||||
clone_all?: boolean
|
||||
clone_from?: string
|
||||
clone_from?: null | string
|
||||
clone_from_default?: boolean
|
||||
name: string
|
||||
no_skills?: boolean
|
||||
|
||||
@@ -719,11 +719,6 @@ platform_toolsets:
|
||||
# # allowed_chats: ["-1001234567890"]
|
||||
# extra:
|
||||
# disable_link_previews: false # Set true to suppress Telegram URL previews in bot messages
|
||||
# # Bot API 10.1 Rich Messages: final replies send raw markdown via
|
||||
# # sendRichMessage so tables, task lists, collapsible details, math, etc.
|
||||
# # render natively (with automatic MarkdownV2 fallback). Opt-in while
|
||||
# # the new endpoint is validated; default false.
|
||||
# rich_messages: false # Set true to enable native rich rendering
|
||||
#
|
||||
# Discord-specific settings (config.yaml top-level, not under platforms:):
|
||||
#
|
||||
|
||||
@@ -2827,6 +2827,53 @@ def _strip_leaked_terminal_responses(text: str) -> str:
|
||||
return cleaned
|
||||
|
||||
|
||||
def _estimate_tui_input_height(
|
||||
lines: list[str] | tuple[str, ...],
|
||||
prompt_text: str,
|
||||
terminal_columns: int,
|
||||
*,
|
||||
max_height: int = 8,
|
||||
) -> int:
|
||||
"""Estimate classic prompt_toolkit input rows using live terminal cells.
|
||||
|
||||
The TextArea prompt is injected with prompt_toolkit's BeforeInput
|
||||
processor, which means it consumes cells only on logical line 0. After a
|
||||
narrow resize, that first row can leave only one input cell beside an icon
|
||||
prompt such as ``⚔ ``, while continuation rows use the full terminal width.
|
||||
Never substitute a fake wide fallback here: under- or over-allocating the
|
||||
TextArea height leaves stale prompt/input cells visible at the bottom of the
|
||||
terminal.
|
||||
"""
|
||||
try:
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
except Exception:
|
||||
get_cwidth = lambda value: len(value or "") # type: ignore[assignment]
|
||||
|
||||
try:
|
||||
columns = int(terminal_columns or 0)
|
||||
except (TypeError, ValueError):
|
||||
columns = 0
|
||||
|
||||
columns = max(1, columns)
|
||||
prompt_width = max(0, get_cwidth(prompt_text or ""))
|
||||
|
||||
visual_lines = 0
|
||||
for index, line in enumerate(lines or [""]):
|
||||
# prompt_toolkit's TextArea injects ``prompt`` via BeforeInput, which
|
||||
# applies only to logical line 0. Wrapped continuation rows, and later
|
||||
# logical lines, use the full terminal width. Count the display cells
|
||||
# after that same transformation rather than subtracting the prompt from
|
||||
# every wrapped row.
|
||||
line_width = get_cwidth(line or "")
|
||||
display_width = line_width + (prompt_width if index == 0 else 0)
|
||||
if display_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-display_width // columns))
|
||||
|
||||
return min(max(visual_lines, 1), max(1, int(max_height or 1)))
|
||||
|
||||
|
||||
def _collect_query_images(query: str | None, image_arg: str | None = None) -> tuple[str, list[Path]]:
|
||||
"""Collect local image attachments for single-query CLI flows."""
|
||||
message = query or ""
|
||||
@@ -3689,9 +3736,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
startup UI and ``_replay_output_history`` cannot reconstruct it
|
||||
(the banner was never added to ``_OUTPUT_HISTORY``).
|
||||
|
||||
Instead we just reset prompt_toolkit's renderer cache so the next
|
||||
incremental redraw starts from a clean slate, then let
|
||||
``original_on_resize`` recalculate layout for the new size.
|
||||
Let prompt_toolkit's own resize path run with its renderer cursor
|
||||
cache intact. Its Application._on_resize() starts with
|
||||
renderer.erase(leave_alternate_screen=False), which needs the cached
|
||||
cursor position to move back to the live prompt origin before
|
||||
erase_down(). Resetting the renderer before that erase loses the
|
||||
origin and can leave stale prompt glyphs after a narrow resize.
|
||||
|
||||
We also flag ``_status_bar_suppressed_after_resize`` so the dynamic
|
||||
status bar and input separator rules stay hidden until the next user
|
||||
@@ -3702,14 +3752,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
next prompt restores the bar cleanly.
|
||||
"""
|
||||
self._status_bar_suppressed_after_resize = True
|
||||
try:
|
||||
app.renderer.reset(leave_alternate_screen=False)
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
app.invalidate()
|
||||
except Exception:
|
||||
pass
|
||||
original_on_resize()
|
||||
|
||||
def _schedule_resize_recovery(self, app, original_on_resize, delay: float = 0.12) -> None:
|
||||
@@ -12004,26 +12046,17 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(self._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
terminal_columns = get_app().output.get_size().columns
|
||||
except Exception:
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
# Each logical line takes at least 1 visual row; long lines wrap.
|
||||
# Use prompt_toolkit's cell width so CJK wide characters count as 2.
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width)) # ceil division
|
||||
return min(max(visual_lines, 1), 8)
|
||||
terminal_columns = shutil.get_terminal_size((80, 24)).columns
|
||||
return _estimate_tui_input_height(
|
||||
doc.lines,
|
||||
self._get_tui_prompt_text(),
|
||||
terminal_columns,
|
||||
)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
@@ -12765,6 +12798,13 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
||||
style=style,
|
||||
full_screen=False,
|
||||
mouse_support=False,
|
||||
# The status bar contains wall-clock read-outs (live prompt elapsed
|
||||
# and idle-since-last-turn). Once a turn finishes there may be no
|
||||
# further events to invalidate the app, so prompt_toolkit would keep
|
||||
# rendering the first post-turn value (usually ``✓ 0s``) forever.
|
||||
# A low-rate refresh keeps the clock honest without reintroducing a
|
||||
# custom repaint thread or touching conversation state.
|
||||
refresh_interval=1.0,
|
||||
# Erase the live bottom chrome (status bar, input box, separator
|
||||
# rules) on exit instead of freezing a final copy into scrollback.
|
||||
# Without this, prompt_toolkit's render_as_done teardown repaints
|
||||
|
||||
@@ -85,6 +85,8 @@ Extend `ProfileCreate` and the create endpoint — no new endpoints, no rewrite:
|
||||
```python
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
clone_from: Optional[str] = None
|
||||
# Backward compatibility for older dashboard/desktop clients.
|
||||
clone_from_default: bool = False
|
||||
clone_all: bool = False
|
||||
no_skills: bool = False
|
||||
|
||||
+126
-27
@@ -37,10 +37,12 @@ class GatewayAuthorizationMixin:
|
||||
Mirrors ``BasePlatformAdapter.enforces_own_access_policy``. Adapters
|
||||
such as WeCom, Weixin, Yuanbao, QQBot, and WhatsApp evaluate their
|
||||
documented ``dm_policy`` / ``group_policy`` / ``allow_from`` config before a
|
||||
message is dispatched to the gateway, so a message that reaches
|
||||
``_is_user_authorized`` has already been authorized by the adapter.
|
||||
Defaults to ``False`` when the adapter is unknown or doesn't expose
|
||||
the flag.
|
||||
message is dispatched to the gateway. The flag alone is NOT "already
|
||||
authorized": these adapters default to ``open``, which forwards every
|
||||
sender, so ``_is_user_authorized`` only trusts the adapter when its
|
||||
effective policy for the chat type is an actual ``allowlist`` restriction
|
||||
(see that method). Defaults to ``False`` when the adapter is unknown or
|
||||
doesn't expose the flag.
|
||||
"""
|
||||
if not platform:
|
||||
return False
|
||||
@@ -65,10 +67,11 @@ class GatewayAuthorizationMixin:
|
||||
env var is not always bridged back into ``config.extra``) — and falls
|
||||
back to ``config.extra`` for bare runners built without a live adapter.
|
||||
|
||||
Used by ``_is_user_authorized`` to carve ``dm_policy: pairing`` out of
|
||||
the adapter-trust shortcut: in pairing mode the adapter forwards the DM
|
||||
so the gateway can run its pairing handshake, so "reached the gateway"
|
||||
must not be read as "authorized".
|
||||
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
|
||||
actually restricted DM senders to a configured allowlist (trustworthy)
|
||||
or merely forwarded everyone under ``dm_policy: open`` / for a pairing
|
||||
handshake (not authorization). "Reached the gateway" only carries an
|
||||
authorization signal in the ``allowlist`` case.
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
@@ -87,6 +90,89 @@ class GatewayAuthorizationMixin:
|
||||
policy = extra.get("dm_policy")
|
||||
return str(policy or "").strip().lower()
|
||||
|
||||
def _adapter_group_policy(self, platform: Optional[Platform]) -> str:
|
||||
"""Best-effort read of an own-policy adapter's effective group policy.
|
||||
|
||||
Mirror of ``_adapter_dm_policy`` for group / forum / channel traffic:
|
||||
returns the lowercased ``group_policy`` (``"open"`` / ``"allowlist"`` /
|
||||
``"disabled"``) for *platform*, or ``""`` when unknown. Prefers the live
|
||||
adapter's resolved ``_group_policy`` and falls back to ``config.extra``
|
||||
for bare runners built without a live adapter.
|
||||
|
||||
Used by ``_is_user_authorized`` to decide whether an own-policy adapter
|
||||
restricted group senders to a configured allowlist (trustworthy) or
|
||||
forwarded the whole channel under ``group_policy: open`` (not
|
||||
authorization).
|
||||
"""
|
||||
if not platform:
|
||||
return ""
|
||||
adapters = getattr(self, "adapters", None) or {}
|
||||
adapter = adapters.get(platform)
|
||||
policy = getattr(adapter, "_group_policy", None) if adapter is not None else None
|
||||
if policy is None:
|
||||
config = getattr(self, "config", None)
|
||||
platform_cfg = (
|
||||
config.platforms.get(platform)
|
||||
if config is not None and hasattr(config, "platforms")
|
||||
else None
|
||||
)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
policy = extra.get("group_policy")
|
||||
return str(policy or "").strip().lower()
|
||||
|
||||
def _adapter_group_has_sender_allowlist(
|
||||
self,
|
||||
platform: Optional[Platform],
|
||||
chat_id: Optional[str],
|
||||
) -> bool:
|
||||
"""Whether a per-group sender allowlist gated this group message.
|
||||
|
||||
WeCom supports ``groups.<group_id>.allow_from`` on top of the top-level
|
||||
``group_policy``. A group may be open at the chat level while still
|
||||
restricting which senders inside that group can invoke Hermes. If such a
|
||||
message reached the gateway, the adapter already checked that sender
|
||||
allowlist, so it is a trustworthy intake decision rather than the
|
||||
fail-open ``group_policy: open`` case.
|
||||
"""
|
||||
if not platform or not chat_id:
|
||||
return False
|
||||
adapters = getattr(self, "adapters", None) or {}
|
||||
adapter = adapters.get(platform)
|
||||
groups = getattr(adapter, "_groups", None) if adapter is not None else None
|
||||
if groups is None:
|
||||
config = getattr(self, "config", None)
|
||||
platform_cfg = (
|
||||
config.platforms.get(platform)
|
||||
if config is not None and hasattr(config, "platforms")
|
||||
else None
|
||||
)
|
||||
extra = getattr(platform_cfg, "extra", None) if platform_cfg else None
|
||||
if isinstance(extra, dict):
|
||||
groups = extra.get("groups")
|
||||
if not isinstance(groups, dict):
|
||||
return False
|
||||
|
||||
chat_id_str = str(chat_id)
|
||||
group_cfg = groups.get(chat_id_str)
|
||||
if not isinstance(group_cfg, dict):
|
||||
lowered = chat_id_str.lower()
|
||||
for key, value in groups.items():
|
||||
if isinstance(key, str) and key.lower() == lowered and isinstance(value, dict):
|
||||
group_cfg = value
|
||||
break
|
||||
if not isinstance(group_cfg, dict):
|
||||
group_cfg = groups.get("*")
|
||||
if not isinstance(group_cfg, dict):
|
||||
return False
|
||||
|
||||
sender_allow = group_cfg.get("allow_from") or group_cfg.get("allowFrom")
|
||||
if isinstance(sender_allow, str):
|
||||
return bool(sender_allow.strip())
|
||||
if isinstance(sender_allow, (list, tuple, set)):
|
||||
return any(str(item).strip() for item in sender_allow)
|
||||
return False
|
||||
|
||||
def _is_user_authorized(self, source: SessionSource) -> bool:
|
||||
"""
|
||||
Check if a user is authorized to use the bot.
|
||||
@@ -237,27 +323,40 @@ class GatewayAuthorizationMixin:
|
||||
global_allowlist = os.getenv("GATEWAY_ALLOWED_USERS", "").strip()
|
||||
|
||||
if not platform_allowlist and not group_user_allowlist and not group_chat_allowlist and not global_allowlist:
|
||||
# No env allowlists configured. Adapters that own their own
|
||||
# No env allowlist configured. Adapters that own their own
|
||||
# config-driven access policy (dm_policy / group_policy /
|
||||
# allow_from / group_allow_from) already gated this message at
|
||||
# intake — it would not have reached the gateway otherwise — so
|
||||
# honor that decision instead of falling through to the
|
||||
# env-only default-deny below, which would silently break
|
||||
# `dm_policy: open` and config-only allowlists. (#34515)
|
||||
# allow_from / group_allow_from) gate access at intake, so for those
|
||||
# platforms we can honor the adapter's decision instead of the
|
||||
# env-only default-deny below -- but ONLY when that decision was an
|
||||
# actual allowlist restriction.
|
||||
#
|
||||
# The adapters default dm_policy / group_policy to "open", which
|
||||
# forwards EVERY sender. Reading "reached the gateway" as
|
||||
# authorization in that case would admit the whole external network
|
||||
# with no operator-configured allowlist -- the fail-open SECURITY.md
|
||||
# §2.6 forbids ("an allowlist is required for every enabled
|
||||
# network-exposed adapter ... code paths that fail open when no
|
||||
# allowlist is configured are code bugs"). "disabled" never
|
||||
# forwards, and "pairing" forwards unpaired DMs only so the gateway
|
||||
# can run its pairing handshake (the pairing-store check above
|
||||
# already denied this sender). So trust the adapter only when its
|
||||
# effective policy for THIS chat type is "allowlist"; for "open" /
|
||||
# "pairing" / anything else, fall through to default-deny, where
|
||||
# GATEWAY_ALLOW_ALL_USERS, the per-platform {PLATFORM}_ALLOW_ALL_USERS
|
||||
# flag (checked above), and the pairing flow remain the explicit
|
||||
# opt-ins to broader access. (#34515 follow-up: trusting "open" was a
|
||||
# fail-open.)
|
||||
if self._adapter_enforces_own_access_policy(source.platform):
|
||||
# Exception: `dm_policy: pairing` does NOT authorize at intake.
|
||||
# The adapter forwards the DM precisely so the gateway can run
|
||||
# its pairing handshake (issue a code, consult the pairing
|
||||
# store). The pairing-store approval check above already ran and
|
||||
# returned False for this sender, so blanket-trusting the
|
||||
# adapter here would silently turn pairing mode into open
|
||||
# access. Fall through to default-deny so the unpaired sender is
|
||||
# offered a pairing code instead. (Pairing is DM-only; group
|
||||
# traffic keeps the adapter-trust path.)
|
||||
if not (
|
||||
source.chat_type == "dm"
|
||||
and self._adapter_dm_policy(source.platform) == "pairing"
|
||||
):
|
||||
if source.chat_type in {"group", "forum", "channel"}:
|
||||
effective_policy = self._adapter_group_policy(source.platform)
|
||||
if self._adapter_group_has_sender_allowlist(
|
||||
source.platform,
|
||||
source.chat_id,
|
||||
):
|
||||
return True
|
||||
else:
|
||||
effective_policy = self._adapter_dm_policy(source.platform)
|
||||
if effective_policy == "allowlist":
|
||||
return True
|
||||
# No allowlists configured -- check global allow-all flag
|
||||
return os.getenv("GATEWAY_ALLOW_ALL_USERS", "").lower() in {"true", "1", "yes"}
|
||||
|
||||
@@ -1128,8 +1128,11 @@ SUPPORTED_DOCUMENT_TYPES = {
|
||||
".ini": "text/plain",
|
||||
".cfg": "text/plain",
|
||||
".zip": "application/zip",
|
||||
".doc": "application/msword",
|
||||
".docx": "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
|
||||
".xls": "application/vnd.ms-excel",
|
||||
".xlsx": "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
||||
".ppt": "application/vnd.ms-powerpoint",
|
||||
".pptx": "application/vnd.openxmlformats-officedocument.presentationml.presentation",
|
||||
".ts": "text/plain",
|
||||
".py": "text/plain",
|
||||
@@ -1913,16 +1916,21 @@ class BasePlatformAdapter(ABC):
|
||||
enforce it at intake: a message is dropped inside the adapter and never
|
||||
reaches the gateway unless it already passed that policy.
|
||||
|
||||
The gateway's env-based allowlist check runs *after* the adapter, so for
|
||||
these platforms a message arriving at ``_is_user_authorized`` has, by
|
||||
definition, already been authorized by the adapter. Without this flag the
|
||||
gateway would then deny it again (no env allowlist → default deny),
|
||||
silently breaking ``dm_policy: open`` and config-only allowlists.
|
||||
The gateway's env-based allowlist check runs *after* the adapter. When
|
||||
no env allowlist is configured, the gateway consults this flag so it can
|
||||
honor a config-only ``dm_policy: allowlist`` / ``allow_from`` (which the
|
||||
adapter already enforced) instead of double-denying it. Crucially, the
|
||||
flag alone is NOT "already authorized": these adapters default
|
||||
``dm_policy`` / ``group_policy`` to ``"open"``, which forwards every
|
||||
sender, so the gateway trusts the adapter only when its effective policy
|
||||
for the chat type is an actual ``"allowlist"`` restriction — never for
|
||||
``"open"`` (that would be the network-exposed fail-open SECURITY.md §2.6
|
||||
forbids). Open access still requires an explicit
|
||||
``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
|
||||
|
||||
Adapters that own their access policy override this to return ``True``.
|
||||
The gateway treats that as "already authorized at intake" and skips the
|
||||
env-allowlist default-deny. Adapters that delegate access control to the
|
||||
gateway leave it ``False`` (the default).
|
||||
Adapters that delegate access control to the gateway leave it ``False``
|
||||
(the default).
|
||||
"""
|
||||
return False
|
||||
|
||||
@@ -1945,6 +1953,46 @@ class BasePlatformAdapter(ABC):
|
||||
"""
|
||||
return False
|
||||
|
||||
def prefers_fresh_final_streaming(
|
||||
self,
|
||||
content: str,
|
||||
metadata: Optional[Dict[str, Any]] = None,
|
||||
) -> bool:
|
||||
"""Whether the stream consumer should finalize a streamed reply by
|
||||
sending a *fresh* final message (and deleting the preview) instead of
|
||||
final-editing the preview.
|
||||
|
||||
Some adapters can send richer final messages than their current edit
|
||||
implementation supports. Telegram is the motivating case: Hermes sends
|
||||
final replies through ``sendRichMessage`` but still finalizes streamed
|
||||
previews through its existing MarkdownV2 edit path until Bot API 10.1's
|
||||
``rich_message`` edit parameter is wired directly. Such adapters
|
||||
override this to ask the consumer to re-deliver the completed answer as
|
||||
a new rich message and best-effort delete the stale preview, so the
|
||||
final rendering matches the rich send path.
|
||||
|
||||
Default implementation returns False — legacy platforms keep the
|
||||
edit-in-place finalization path.
|
||||
"""
|
||||
return False
|
||||
|
||||
def streaming_overflow_limit(self) -> Optional[int]:
|
||||
"""Max single-message length (in this adapter's ``message_len_fn``
|
||||
units) the stream consumer may accumulate before it splits, when the
|
||||
adapter can deliver a larger message than its legacy per-message limit.
|
||||
|
||||
Telegram Bot API 10.1 Rich Messages accept up to 32,768 chars in a
|
||||
single ``sendRichMessage`` / ``sendRichMessageDraft``, far above the
|
||||
4,096 MarkdownV2 limit. Adapters with such a richer send/draft path
|
||||
override this so the consumer doesn't fragment a reply that fits one
|
||||
rich message; the live edit preview is still bound by the platform's
|
||||
edit limit, but the finalized reply (and DM draft preview) is delivered
|
||||
whole.
|
||||
|
||||
Return ``None`` (default) to use ``MAX_MESSAGE_LENGTH``.
|
||||
"""
|
||||
return None
|
||||
|
||||
async def send_draft(
|
||||
self,
|
||||
chat_id: str,
|
||||
|
||||
@@ -209,7 +209,7 @@ class _MatrixHtmlSanitizer(HTMLParser):
|
||||
_ALLOWED_TAGS = {
|
||||
"a", "b", "blockquote", "br", "code", "del", "em", "h1", "h2", "h3",
|
||||
"h4", "h5", "h6", "hr", "i", "li", "ol", "p", "pre", "s", "strike",
|
||||
"strong", "ul",
|
||||
"strong", "table", "tbody", "td", "th", "thead", "tr", "ul",
|
||||
}
|
||||
_VOID_TAGS = {"br", "hr"}
|
||||
|
||||
|
||||
@@ -349,8 +349,11 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
MAX_MESSAGE_LENGTH = 4096
|
||||
supports_code_blocks = True # Telegram MarkdownV2 renders fenced code blocks
|
||||
# Bot API 10.1 Rich Messages cap the raw markdown/html text at 32,768
|
||||
# UTF-8 bytes. Content above this is sent via the legacy chunking path.
|
||||
RICH_MESSAGE_MAX_BYTES = 32768
|
||||
# UTF-8 characters. Content above this is sent via the legacy chunking path.
|
||||
RICH_MESSAGE_MAX_CHARS = 32768
|
||||
# Backwards-compatible alias for tests/external callers that referenced the
|
||||
# initial implementation name. The API limit is character-based, not bytes.
|
||||
RICH_MESSAGE_MAX_BYTES = RICH_MESSAGE_MAX_CHARS
|
||||
# Threshold for detecting Telegram client-side message splits.
|
||||
# When a chunk is near this limit, a continuation is almost certain.
|
||||
_SPLIT_THRESHOLD = 4000
|
||||
@@ -416,10 +419,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
self._mention_patterns = self._compile_mention_patterns()
|
||||
self._reply_to_mode: str = getattr(config, 'reply_to_mode', 'first') or 'first'
|
||||
self._disable_link_previews: bool = self._coerce_bool_extra("disable_link_previews", False)
|
||||
# Bot API 10.1 Rich Messages: opportunistically send final replies via
|
||||
# sendRichMessage with the raw agent markdown so tables/task lists/etc.
|
||||
# render natively. Opt-out via platforms.telegram.extra.rich_messages.
|
||||
self._rich_messages_enabled: bool = self._coerce_bool_extra("rich_messages", False)
|
||||
# Bot API 10.1 Rich Messages: send final replies via sendRichMessage
|
||||
# with the raw agent markdown so tables/task lists/etc. render natively.
|
||||
# Latched off after a capability failure on sendRichMessage /
|
||||
# sendRichMessageDraft (e.g. older python-telegram-bot without the
|
||||
# endpoint) so later sends skip the doomed rich attempt entirely.
|
||||
@@ -922,19 +923,20 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# the RAW agent markdown so richer constructs (tables, task lists,
|
||||
# collapsible details, math, ...) render natively. The legacy MarkdownV2
|
||||
# send() path stays as the fallback for unsupported/oversized content and
|
||||
# older PTB/clients. Streaming edits/drafts are intentionally untouched —
|
||||
# Telegram exposes no rich-edit method.
|
||||
# older PTB/clients. Streaming edits stay on Hermes' existing MarkdownV2
|
||||
# edit path for now; finalization can re-send as rich and delete the stale
|
||||
# preview until rich_message edit support is wired directly.
|
||||
# ------------------------------------------------------------------
|
||||
def _content_fits_rich_limits(self, content: str) -> bool:
|
||||
"""Cheap pre-check for the one hard rich limit we can count locally.
|
||||
|
||||
Only the 32,768 UTF-8 byte text cap is enforced here. Other Bot API
|
||||
Only the 32,768 UTF-8 character text cap is enforced here. Other Bot API
|
||||
rich limits (500 blocks, 16 nesting levels, 20 table columns, ...) are
|
||||
not pre-counted; if exceeded Telegram returns a BadRequest, which
|
||||
:meth:`_is_rich_fallback_error` classifies as permanent so the send
|
||||
degrades to the legacy chunking path.
|
||||
"""
|
||||
return len(content.encode("utf-8")) <= self.RICH_MESSAGE_MAX_BYTES
|
||||
return len(content) <= self.RICH_MESSAGE_MAX_CHARS
|
||||
|
||||
def _bot_supports_rich(self) -> bool:
|
||||
"""True when the bound bot can issue raw ``sendRichMessage`` calls.
|
||||
@@ -948,19 +950,57 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
return inspect.iscoroutinefunction(getattr(self._bot, "do_api_request", None))
|
||||
|
||||
def _should_attempt_rich(self, content: str) -> bool:
|
||||
# getattr defaults: tests build adapters via object.__new__() (no
|
||||
# __init__), so the flags may be unset — default rich OFF (the
|
||||
# feature is opt-in via platforms.telegram.extra.rich_messages).
|
||||
def _should_attempt_rich(
|
||||
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
return bool(
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
and not (metadata or {}).get("expect_edits")
|
||||
and content
|
||||
and content.strip()
|
||||
and self._content_fits_rich_limits(content)
|
||||
and self._bot_supports_rich()
|
||||
)
|
||||
|
||||
def prefers_fresh_final_streaming(
|
||||
self, content: str, metadata: Optional[Dict[str, Any]] = None
|
||||
) -> bool:
|
||||
"""Finalize rich-eligible streamed replies with a fresh sendRichMessage
|
||||
instead of Hermes' current MarkdownV2 edit path.
|
||||
|
||||
The final edit path has not yet been upgraded to Bot API 10.1's
|
||||
``rich_message`` edit parameter, so finalizing through edit would lose
|
||||
rich constructs such as tables/task lists. When the completed content
|
||||
is rich-eligible, re-send it via ``sendRichMessage`` and delete the
|
||||
preview (see ``gateway.stream_consumer._try_fresh_final``).
|
||||
|
||||
``metadata`` is intentionally ignored: the preview was sent with
|
||||
``expect_edits=True`` (to stay on the editable path mid-stream), but the
|
||||
FINAL answer is a brand-new message that should render rich. Gating
|
||||
otherwise matches :meth:`_should_attempt_rich`: rich not latched off,
|
||||
content present and within the rich character limit, and the bot exposes
|
||||
an async ``do_api_request``.
|
||||
"""
|
||||
return self._should_attempt_rich(content)
|
||||
|
||||
def streaming_overflow_limit(self) -> Optional[int]:
|
||||
"""Allow the stream consumer to accumulate up to the rich-message cap
|
||||
before splitting, so a reply that fits one ``sendRichMessage`` /
|
||||
``sendRichMessageDraft`` isn't fragmented at the 4,096 MarkdownV2 limit.
|
||||
|
||||
Gated on the same rich capability as the send path (minus the
|
||||
content-length check — raising that cap is the whole point): rich not
|
||||
latched off and the bot exposes an async ``do_api_request``. Returns
|
||||
``None`` (→ legacy 4,096 limit) when rich isn't available, so non-rich
|
||||
streams split exactly as before.
|
||||
"""
|
||||
if (
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
and self._bot_supports_rich()
|
||||
):
|
||||
return self.RICH_MESSAGE_MAX_CHARS
|
||||
return None
|
||||
|
||||
def _rich_message_payload(
|
||||
self, content: str, *, skip_entity_detection: bool = False
|
||||
) -> Dict[str, Any]:
|
||||
@@ -982,16 +1022,19 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
rejections (BadRequest from a parser/limit issue) are NOT capability
|
||||
errors: the next message may be fine.
|
||||
"""
|
||||
name = exc.__class__.__name__.lower()
|
||||
if name in {"endpointnotfound", "invalidtoken"}:
|
||||
return True
|
||||
if isinstance(exc, (AttributeError, TypeError, NotImplementedError)):
|
||||
return True
|
||||
if getattr(exc, "error_code", None) == 404:
|
||||
return True
|
||||
s = str(exc).lower()
|
||||
if ("method" in s and "not found" in s) or "no such method" in s:
|
||||
if ("method" in s or "endpoint" in s) and (
|
||||
"not found" in s or "does not exist" in s
|
||||
):
|
||||
return True
|
||||
if "unsupported" in s or "not implemented" in s:
|
||||
return True
|
||||
return False
|
||||
return "no such method" in s
|
||||
|
||||
def _is_rich_fallback_error(self, exc: Exception) -> bool:
|
||||
"""True ⇒ permanent/capability error ⇒ safe to fall back to legacy.
|
||||
@@ -1003,7 +1046,10 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
"""
|
||||
if self._is_bad_request_error(exc):
|
||||
return True
|
||||
return self._is_rich_capability_error(exc)
|
||||
if self._is_rich_capability_error(exc):
|
||||
return True
|
||||
s = str(exc).lower()
|
||||
return "unsupported" in s or "not implemented" in s
|
||||
|
||||
def _compute_single_send_routing(
|
||||
self,
|
||||
@@ -1076,6 +1122,8 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# which must not be sent as a stray field on the raw endpoint.
|
||||
payload.update({k: v for k, v in thread_kwargs.items() if v is not None})
|
||||
payload.update(self._notification_kwargs(metadata))
|
||||
if getattr(self, "_disable_link_previews", False):
|
||||
payload["link_preview_options"] = {"is_disabled": True}
|
||||
if reply_to_id is not None:
|
||||
# Spec: sendRichMessage takes reply_parameters (ReplyParameters
|
||||
# object), NOT the legacy reply_to_message_id scalar. Unknown
|
||||
@@ -1084,8 +1132,12 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
payload["reply_parameters"] = {"message_id": reply_to_id}
|
||||
|
||||
try:
|
||||
# Take the raw Bot API result (dict under real PTB). Passing
|
||||
# return_type=Message would make PTB deserialize a Bot API 10.1
|
||||
# response shape it does not fully model yet; a post-delivery parse
|
||||
# error must not be mistaken for a sendable failure.
|
||||
msg = await self._bot.do_api_request(
|
||||
"sendRichMessage", api_kwargs=payload, return_type=Message
|
||||
"sendRichMessage", api_kwargs=payload
|
||||
)
|
||||
except Exception as exc:
|
||||
if self._is_rich_fallback_error(exc):
|
||||
@@ -1122,7 +1174,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
if isinstance(msg, dict):
|
||||
message_id = msg.get("message_id")
|
||||
if message_id is None:
|
||||
message_id = msg.get("result", {}).get("message_id")
|
||||
message_id = (msg.get("result") or {}).get("message_id")
|
||||
else:
|
||||
message_id = getattr(msg, "message_id", None)
|
||||
return SendResult(
|
||||
@@ -1132,8 +1184,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
|
||||
def _should_attempt_rich_draft(self, content: str) -> bool:
|
||||
return bool(
|
||||
getattr(self, "_rich_messages_enabled", False)
|
||||
and not getattr(self, "_rich_send_disabled", False)
|
||||
not getattr(self, "_rich_send_disabled", False)
|
||||
and not getattr(self, "_rich_draft_disabled", False)
|
||||
and content
|
||||
and content.strip()
|
||||
@@ -2153,7 +2204,7 @@ class TelegramAdapter(BasePlatformAdapter):
|
||||
# through to the legacy MarkdownV2 path on permanent/capability
|
||||
# errors or DM-topic routing skips; returns directly on success or
|
||||
# on a transient failure (which must NOT be legacy-resent).
|
||||
if self._should_attempt_rich(content):
|
||||
if self._should_attempt_rich(content, metadata=metadata):
|
||||
rich_result = await self._try_send_rich(chat_id, content, reply_to, metadata)
|
||||
if rich_result is not None:
|
||||
if rich_result.success:
|
||||
|
||||
+110
-60
@@ -4556,6 +4556,14 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
)
|
||||
continue
|
||||
|
||||
# Claim the session slot *before* spawning the task so that an
|
||||
# inbound message arriving between task creation and the task's
|
||||
# first await (where _process_message_background sets the real
|
||||
# sentinel) sees the slot as occupied and queues behind it
|
||||
# instead of spinning up a duplicate AIAgent (#45456).
|
||||
self._running_agents[entry.session_key] = _AGENT_PENDING_SENTINEL
|
||||
self._running_agents_ts[entry.session_key] = time.time()
|
||||
|
||||
# Empty-text internal event — the _is_resume_pending branch in
|
||||
# _handle_message_with_agent prepends the proper reason-aware
|
||||
# system note before the turn runs.
|
||||
@@ -4565,7 +4573,33 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
source=source,
|
||||
internal=True,
|
||||
)
|
||||
task = asyncio.create_task(adapter.handle_message(event))
|
||||
|
||||
async def _guarded_handle_message(
|
||||
_adapter: Any, _event: MessageEvent, _key: str = entry.session_key,
|
||||
) -> None:
|
||||
"""Ensure the pre-claimed sentinel is always released.
|
||||
|
||||
In the normal flow the resume turn reaches
|
||||
``_handle_message``, which replaces our pre-claim with
|
||||
its own ``_AGENT_PENDING_SENTINEL`` (and releases it in
|
||||
its ``finally`` block) once the run begins. If
|
||||
``handle_message`` raises *before* the runner takes over
|
||||
the slot (e.g. during topic recovery or session-key
|
||||
resolution), nobody clears our pre-claim — so we do it
|
||||
here unconditionally. The ``is _AGENT_PENDING_SENTINEL``
|
||||
guard below only releases the slot we ourselves placed,
|
||||
never one a live run currently owns.
|
||||
"""
|
||||
try:
|
||||
await _adapter.handle_message(_event)
|
||||
finally:
|
||||
# Only release if the sentinel we set is still there
|
||||
# (i.e. _process_message_background hasn't replaced
|
||||
# and cleaned it already).
|
||||
if self._running_agents.get(_key) is _AGENT_PENDING_SENTINEL:
|
||||
self._release_running_agent_state(_key)
|
||||
|
||||
task = asyncio.create_task(_guarded_handle_message(adapter, event))
|
||||
self._background_tasks.add(task)
|
||||
task.add_done_callback(self._background_tasks.discard)
|
||||
scheduled += 1
|
||||
@@ -8815,12 +8849,29 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"Auto-resetting session %s after compression exhaustion.",
|
||||
session_entry.session_id,
|
||||
)
|
||||
self.session_store.reset_session(session_key)
|
||||
new_entry = self.session_store.reset_session(session_key)
|
||||
self._evict_cached_agent(session_key)
|
||||
self._session_model_overrides.pop(session_key, None)
|
||||
self._set_session_reasoning_override(session_key, None)
|
||||
if hasattr(self, "_pending_model_notes"):
|
||||
self._pending_model_notes.pop(session_key, None)
|
||||
if new_entry is not None:
|
||||
# Drop the stale reference to the bloated compressed child and
|
||||
# re-point the Telegram topic binding at the fresh session.
|
||||
# Compression rotated session_entry.session_id to the oversized
|
||||
# compressed child earlier this turn (the agent-result sync
|
||||
# above), and that _sync also rewrote the (chat_id, thread_id)
|
||||
# -> bloated-child binding. reset_session swaps in a clean,
|
||||
# parentless session, but without re-syncing the binding the
|
||||
# next inbound message in this topic gets switch_session'd back
|
||||
# onto the bloated child by the binding-heal walk, reloads the
|
||||
# oversized transcript, and re-triggers compression exhaustion
|
||||
# forever (#35809 — regression of the #9893/#10063 auto-reset).
|
||||
# No-op on non-topic lanes.
|
||||
session_entry = new_entry
|
||||
self._sync_telegram_topic_binding(
|
||||
source, session_entry, reason="compression-exhausted-reset",
|
||||
)
|
||||
response = (response or "") + (
|
||||
"\n\n🔄 Session auto-reset — the conversation exceeded the "
|
||||
"maximum context size and could not be compressed further. "
|
||||
@@ -14565,6 +14616,61 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
_context_length = getattr(_agent.context_compressor, "context_length", 0) or 0
|
||||
_resolved_model = getattr(_agent, "model", None) if _agent else None
|
||||
|
||||
# Sync session_id immediately after run_conversation(). Compression
|
||||
# can rotate before a follow-up model call fails; the failure return
|
||||
# below must still point the gateway at the compressed child.
|
||||
agent = agent_holder[0]
|
||||
_session_was_split = False
|
||||
agent_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
|
||||
if agent and session_key and agent_session_id != session_id:
|
||||
_session_was_split = True
|
||||
logger.info(
|
||||
"Session split detected: %s → %s (compression)",
|
||||
session_id, agent_session_id,
|
||||
)
|
||||
entry = self.session_store._entries.get(session_key)
|
||||
if entry:
|
||||
entry.session_id = agent_session_id
|
||||
self.session_store._save()
|
||||
|
||||
# If this is a Telegram DM and source.thread_id was lost during
|
||||
# the session split (synthetic / recovered event), restore it
|
||||
# from the binding so _thread_metadata_for_source produces the
|
||||
# correct message_thread_id instead of routing to the General
|
||||
# thread. Failure here is non-fatal — we log and continue;
|
||||
# worst case the message lands in General, which is the
|
||||
# pre-fix behaviour.
|
||||
if (
|
||||
getattr(source, "platform", None) == Platform.TELEGRAM
|
||||
and getattr(source, "chat_type", None) == "dm"
|
||||
and getattr(source, "thread_id", None) is None
|
||||
and self._session_db is not None
|
||||
):
|
||||
try:
|
||||
_binding = self._session_db.get_telegram_topic_binding_by_session(
|
||||
session_id=agent_session_id,
|
||||
)
|
||||
if _binding and _binding.get("thread_id"):
|
||||
source.thread_id = str(_binding["thread_id"])
|
||||
logger.debug(
|
||||
"Restored source.thread_id=%s from binding after session split %s → %s",
|
||||
source.thread_id,
|
||||
session_id,
|
||||
agent_session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to restore thread_id from binding after session split",
|
||||
exc_info=True,
|
||||
)
|
||||
if entry:
|
||||
self._sync_telegram_topic_binding(
|
||||
source, entry, reason="agent-run-compression",
|
||||
)
|
||||
|
||||
effective_session_id = agent_session_id
|
||||
_effective_history_offset = 0 if _session_was_split else len(agent_history)
|
||||
|
||||
if not final_response:
|
||||
error_msg = f"⚠️ {result['error']}" if result.get("error") else ""
|
||||
return {
|
||||
@@ -14579,7 +14685,8 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
"error": result.get("error"),
|
||||
"compression_exhausted": result.get("compression_exhausted", False),
|
||||
"tools": tools_holder[0] or [],
|
||||
"history_offset": len(agent_history),
|
||||
"history_offset": _effective_history_offset,
|
||||
"session_id": effective_session_id,
|
||||
"last_prompt_tokens": _last_prompt_toks,
|
||||
"input_tokens": _input_toks,
|
||||
"output_tokens": _output_toks,
|
||||
@@ -14625,63 +14732,6 @@ class GatewayRunner(GatewayAuthorizationMixin, GatewayKanbanWatchersMixin, Gatew
|
||||
unique_tags.insert(0, "[[audio_as_voice]]")
|
||||
final_response = final_response + "\n" + "\n".join(unique_tags)
|
||||
|
||||
# Sync session_id: the agent may have created a new session during
|
||||
# mid-run context compression (_compress_context splits sessions).
|
||||
# If so, update the session store entry so the NEXT message loads
|
||||
# the compressed transcript, not the stale pre-compression one.
|
||||
agent = agent_holder[0]
|
||||
_session_was_split = False
|
||||
if agent and session_key and hasattr(agent, 'session_id') and agent.session_id != session_id:
|
||||
_session_was_split = True
|
||||
logger.info(
|
||||
"Session split detected: %s → %s (compression)",
|
||||
session_id, agent.session_id,
|
||||
)
|
||||
entry = self.session_store._entries.get(session_key)
|
||||
if entry:
|
||||
entry.session_id = agent.session_id
|
||||
self.session_store._save()
|
||||
|
||||
# If this is a Telegram DM and source.thread_id was lost during
|
||||
# the session split (synthetic / recovered event), restore it
|
||||
# from the binding so _thread_metadata_for_source produces the
|
||||
# correct message_thread_id instead of routing to the General
|
||||
# thread. Failure here is non-fatal — we log and continue;
|
||||
# worst case the message lands in General, which is the
|
||||
# pre-fix behaviour.
|
||||
if (
|
||||
getattr(source, "platform", None) == Platform.TELEGRAM
|
||||
and getattr(source, "chat_type", None) == "dm"
|
||||
and getattr(source, "thread_id", None) is None
|
||||
and self._session_db is not None
|
||||
):
|
||||
try:
|
||||
_binding = self._session_db.get_telegram_topic_binding_by_session(
|
||||
session_id=agent.session_id,
|
||||
)
|
||||
if _binding and _binding.get("thread_id"):
|
||||
source.thread_id = str(_binding["thread_id"])
|
||||
logger.debug(
|
||||
"Restored source.thread_id=%s from binding after session split %s → %s",
|
||||
source.thread_id,
|
||||
session_id,
|
||||
agent.session_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.debug(
|
||||
"Failed to restore thread_id from binding after session split",
|
||||
exc_info=True,
|
||||
)
|
||||
|
||||
effective_session_id = getattr(agent, 'session_id', session_id) if agent else session_id
|
||||
|
||||
# When compression created a new session, the messages list was
|
||||
# shortened. Using the original history offset would produce an
|
||||
# empty new_messages slice, causing the gateway to write only a
|
||||
# user/assistant pair — losing the compressed summary and tail.
|
||||
# Reset to 0 so the gateway writes ALL compressed messages.
|
||||
_effective_history_offset = 0 if _session_was_split else len(agent_history)
|
||||
|
||||
# Auto-generate session title after first exchange (non-blocking)
|
||||
if final_response and self._session_db:
|
||||
try:
|
||||
|
||||
+143
-22
@@ -143,6 +143,13 @@ class GatewayStreamConsumer:
|
||||
# timestamps would be stale by completion time. Ported from
|
||||
# openclaw/openclaw#72038.
|
||||
self._message_created_ts: Optional[float] = None
|
||||
# Every real preview message id the consumer has put on screen during
|
||||
# this response (first send + any continuation messages from oversized
|
||||
# edits/sends). The fresh-final path deletes all of them when it
|
||||
# re-delivers the completed answer as a single (rich) message, so a
|
||||
# reply that was split across the platform's edit limit while streaming
|
||||
# doesn't leave stale fragments above the final message.
|
||||
self._preview_message_ids: "set[str]" = set()
|
||||
self._already_sent = False
|
||||
self._edit_supported = True # Disabled when progressive edits are no longer usable
|
||||
self._last_edit_time = 0.0
|
||||
@@ -420,7 +427,10 @@ class GatewayStreamConsumer:
|
||||
if isinstance(self.adapter, _BasePlatformAdapter)
|
||||
else len
|
||||
)
|
||||
_raw_limit = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
|
||||
# Rich-capable adapters (Telegram rich messages) raise this above the
|
||||
# legacy per-message limit so a reply that fits one rich send/draft
|
||||
# isn't fragmented at 4096 while streaming. See _raw_message_limit.
|
||||
_raw_limit = self._raw_message_limit()
|
||||
_safe_limit = max(500, _raw_limit - _len_fn(self.cfg.cursor) - 100)
|
||||
|
||||
# Resolve native draft streaming once per run. When enabled the
|
||||
@@ -589,9 +599,20 @@ class GatewayStreamConsumer:
|
||||
if self._accumulated:
|
||||
if self._fallback_final_send:
|
||||
await self._send_fallback_final(self._accumulated)
|
||||
elif current_update_visible and (
|
||||
not self._adapter_requires_finalize
|
||||
or self._last_edit_overflowed
|
||||
elif self._final_response_sent:
|
||||
# A finalize=True tick above already delivered the
|
||||
# final answer via the adapter's fresh-final path
|
||||
# (_try_fresh_final sent a fresh rich message and
|
||||
# deleted the preview). Running a second finalize
|
||||
# edit here would duplicate the message / re-delete,
|
||||
# so just record delivery and stop.
|
||||
self._final_content_delivered = True
|
||||
elif (
|
||||
current_update_visible
|
||||
and (
|
||||
not self._adapter_requires_finalize
|
||||
or self._last_edit_overflowed
|
||||
)
|
||||
):
|
||||
# Mid-stream edit above already delivered the
|
||||
# final accumulated content. Skip the redundant
|
||||
@@ -729,6 +750,9 @@ class GatewayStreamConsumer:
|
||||
return reply_to_id
|
||||
try:
|
||||
meta = dict(self.metadata) if self.metadata else {}
|
||||
# This chunk becomes the next edit target — adapters that support
|
||||
# rich final sends (Telegram) must keep it on the editable path.
|
||||
meta["expect_edits"] = True
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
@@ -737,6 +761,7 @@ class GatewayStreamConsumer:
|
||||
)
|
||||
if result.success and result.message_id:
|
||||
self._message_id = str(result.message_id)
|
||||
self._track_preview_ids_from_result(result)
|
||||
self._already_sent = True
|
||||
self._last_sent_text = text
|
||||
# Fresh content bubble — close off any stale tool bubble
|
||||
@@ -1114,6 +1139,76 @@ class GatewayStreamConsumer:
|
||||
age = time.monotonic() - self._message_created_ts
|
||||
return age >= threshold
|
||||
|
||||
def _raw_message_limit(self) -> int:
|
||||
"""Per-message length budget (in the adapter's ``message_len_fn`` units)
|
||||
before the consumer splits an overflowing reply.
|
||||
|
||||
Adapters with a richer send/draft path (e.g. Telegram rich messages)
|
||||
can raise this above ``MAX_MESSAGE_LENGTH`` via
|
||||
``streaming_overflow_limit`` so a reply that fits one rich message isn't
|
||||
fragmented at the legacy edit limit. Falls back to
|
||||
``MAX_MESSAGE_LENGTH`` (4096 default) for everyone else.
|
||||
"""
|
||||
base = getattr(self.adapter, "MAX_MESSAGE_LENGTH", 4096)
|
||||
# isinstance gate: MagicMock adapters return mock objects (truthy, not
|
||||
# ints) for arbitrary attribute access — keep them on the base limit.
|
||||
if isinstance(self.adapter, _BasePlatformAdapter):
|
||||
try:
|
||||
cap = self.adapter.streaming_overflow_limit()
|
||||
except Exception as e:
|
||||
logger.debug("streaming_overflow_limit check failed: %s", e)
|
||||
cap = None
|
||||
if isinstance(cap, int) and cap > base:
|
||||
return cap
|
||||
return base
|
||||
|
||||
def _track_preview_id(self, message_id: Optional[str]) -> None:
|
||||
"""Record a real preview message id for fresh-final cleanup."""
|
||||
if message_id and message_id != "__no_edit__":
|
||||
self._preview_message_ids.add(str(message_id))
|
||||
|
||||
def _track_preview_ids_from_result(self, result: Any) -> None:
|
||||
"""Record every message id a send/edit result exposes: the primary id
|
||||
plus any continuation ids from an oversized split
|
||||
(``continuation_message_ids`` or ``raw_response['message_ids']``)."""
|
||||
self._track_preview_id(getattr(result, "message_id", None))
|
||||
for mid in (getattr(result, "continuation_message_ids", None) or ()):
|
||||
self._track_preview_id(mid)
|
||||
raw = getattr(result, "raw_response", None) or {}
|
||||
if isinstance(raw, dict):
|
||||
for mid in (raw.get("message_ids") or ()):
|
||||
self._track_preview_id(mid)
|
||||
|
||||
def _adapter_prefers_fresh_final(self, text: str) -> bool:
|
||||
"""Return True when the adapter would rather finalize a streamed reply
|
||||
by sending a fresh message and deleting the preview than by editing the
|
||||
preview in place — e.g. Telegram, whose ``sendRichMessage`` send path
|
||||
currently renders richer markdown than Hermes' MarkdownV2 edit path.
|
||||
|
||||
Returns False when there is no real preview to replace (no message id,
|
||||
or the ``__no_edit__`` sentinel), when the adapter doesn't expose the
|
||||
hook, or on any error (the consumer then keeps the edit-in-place path).
|
||||
"""
|
||||
if not self._message_id or self._message_id == "__no_edit__":
|
||||
return False
|
||||
fn = getattr(self.adapter, "prefers_fresh_final_streaming", None)
|
||||
if fn is None:
|
||||
return False
|
||||
try:
|
||||
try:
|
||||
result = fn(text, metadata=self.metadata)
|
||||
except TypeError:
|
||||
# Adapter / test double whose hook doesn't accept the metadata
|
||||
# keyword — fall back to the positional-only form.
|
||||
result = fn(text)
|
||||
except Exception as e:
|
||||
logger.debug("prefers_fresh_final_streaming check failed: %s", e)
|
||||
return False
|
||||
# ``is True`` (not ``bool(...)``) so a MagicMock adapter's auto-child
|
||||
# method — truthy by default in tests — does not wrongly enable the
|
||||
# fresh-final path. Mirrors the REQUIRES_EDIT_FINALIZE gate in __init__.
|
||||
return result is True
|
||||
|
||||
async def _try_fresh_final(self, text: str, *, is_turn_final: bool = True) -> bool:
|
||||
"""Send ``text`` as a brand-new message (best-effort delete the old
|
||||
preview) so the platform's visible timestamp reflects completion
|
||||
@@ -1127,7 +1222,13 @@ class GatewayStreamConsumer:
|
||||
|
||||
Ported from openclaw/openclaw#72038.
|
||||
"""
|
||||
old_message_id = self._message_id
|
||||
# Every preview message the user has seen for this response: the
|
||||
# current one plus any continuation fragments tracked while streaming
|
||||
# (an oversized reply split across the platform's edit limit). All of
|
||||
# them are replaced by the single fresh message below.
|
||||
stale_ids = set(self._preview_message_ids)
|
||||
if self._message_id and self._message_id != "__no_edit__":
|
||||
stale_ids.add(self._message_id)
|
||||
try:
|
||||
result = await self.adapter.send(
|
||||
chat_id=self.chat_id,
|
||||
@@ -1139,25 +1240,29 @@ class GatewayStreamConsumer:
|
||||
return False
|
||||
if not getattr(result, "success", False):
|
||||
return False
|
||||
# Successful fresh send — try to delete the stale preview so the
|
||||
# user doesn't see the old edit-stuck message underneath. Cleanup
|
||||
# is best-effort; platforms that don't implement ``delete_message``
|
||||
# just leave the preview behind (still an acceptable outcome —
|
||||
# the visible final timestamp is the important part).
|
||||
if old_message_id and old_message_id != "__no_edit__":
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
try:
|
||||
await delete_fn(self.chat_id, old_message_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Fresh-final preview cleanup failed (%s): %s",
|
||||
old_message_id, e,
|
||||
)
|
||||
# Adopt the new message id as the current message so subsequent
|
||||
# callers (e.g. overflow split loops, finalize retries) see a
|
||||
# consistent state.
|
||||
new_message_id = getattr(result, "message_id", None)
|
||||
# Successful fresh send — try to delete the stale preview(s) so the
|
||||
# user doesn't see the old edit-stuck message(s) underneath. Cleanup
|
||||
# is best-effort; platforms that don't implement ``delete_message``
|
||||
# just leave the preview behind (still an acceptable outcome — the
|
||||
# visible final timestamp is the important part). Never delete the
|
||||
# message we just sent.
|
||||
delete_fn = getattr(self.adapter, "delete_message", None)
|
||||
if delete_fn is not None:
|
||||
for stale_id in stale_ids:
|
||||
if not stale_id or stale_id == "__no_edit__" or stale_id == new_message_id:
|
||||
continue
|
||||
try:
|
||||
await delete_fn(self.chat_id, stale_id)
|
||||
except Exception as e:
|
||||
logger.debug(
|
||||
"Fresh-final preview cleanup failed (%s): %s",
|
||||
stale_id, e,
|
||||
)
|
||||
self._preview_message_ids = set()
|
||||
if new_message_id:
|
||||
self._message_id = new_message_id
|
||||
self._message_created_ts = time.monotonic()
|
||||
@@ -1267,9 +1372,19 @@ class GatewayStreamConsumer:
|
||||
# old preview follows. Ported from
|
||||
# openclaw/openclaw#72038. Gated by config so the
|
||||
# legacy edit-in-place path stays the default.
|
||||
#
|
||||
# Adapters can also opt in regardless of the time threshold
|
||||
# via prefers_fresh_final_streaming (e.g. Telegram, whose
|
||||
# send path renders richer markdown than its edit path):
|
||||
# finalizing through edit would visibly downgrade a rich
|
||||
# preview, so re-deliver as a fresh message + delete the
|
||||
# preview instead.
|
||||
if (
|
||||
finalize
|
||||
and self._should_send_fresh_final()
|
||||
and (
|
||||
self._should_send_fresh_final()
|
||||
or self._adapter_prefers_fresh_final(text)
|
||||
)
|
||||
and await self._try_fresh_final(
|
||||
text, is_turn_final=is_turn_final,
|
||||
)
|
||||
@@ -1283,6 +1398,9 @@ class GatewayStreamConsumer:
|
||||
)
|
||||
if result.success:
|
||||
self._already_sent = True
|
||||
# Record any continuation fragments an oversized edit
|
||||
# split off, so fresh-final can clean them all up.
|
||||
self._track_preview_ids_from_result(result)
|
||||
# Adapter may have split-and-delivered an oversized
|
||||
# edit across the original message + N continuations.
|
||||
# When that happens, ``message_id`` is the LAST visible
|
||||
@@ -1405,7 +1523,7 @@ class GatewayStreamConsumer:
|
||||
chat_id=self.chat_id,
|
||||
content=text,
|
||||
reply_to=self._initial_reply_to_id,
|
||||
metadata=self.metadata,
|
||||
metadata={**(self.metadata or {}), "expect_edits": True},
|
||||
)
|
||||
if result.success:
|
||||
if result.message_id:
|
||||
@@ -1414,6 +1532,9 @@ class GatewayStreamConsumer:
|
||||
# the user so fresh-final logic can detect stale
|
||||
# preview timestamps on long-running responses.
|
||||
self._message_created_ts = time.monotonic()
|
||||
# Record this (and any continuation fragments from an
|
||||
# oversized first send) for fresh-final cleanup.
|
||||
self._track_preview_ids_from_result(result)
|
||||
else:
|
||||
self._edit_supported = False
|
||||
self._already_sent = True
|
||||
|
||||
+69
-7
@@ -3524,6 +3524,22 @@ def _save_codex_tokens(tokens: Dict[str, str], last_refresh: str = None, label:
|
||||
_save_auth_store(auth_store)
|
||||
|
||||
|
||||
def _recover_codex_tokens_from_cli(reason: str) -> Optional[Dict[str, str]]:
|
||||
"""Adopt a valid Codex CLI token pair into Hermes auth, if available."""
|
||||
imported = _import_codex_cli_tokens()
|
||||
# Require BOTH tokens before adopting: persisting a payload without a
|
||||
# usable refresh_token would only break the next refresh cycle.
|
||||
if not (
|
||||
imported
|
||||
and str(imported.get("access_token", "") or "").strip()
|
||||
and str(imported.get("refresh_token", "") or "").strip()
|
||||
):
|
||||
return None
|
||||
logger.info("Codex auth recovered from Codex CLI auth.json (%s).", reason)
|
||||
_save_codex_tokens(imported)
|
||||
return dict(imported)
|
||||
|
||||
|
||||
def refresh_codex_oauth_pure(
|
||||
access_token: str,
|
||||
refresh_token: str,
|
||||
@@ -3660,11 +3676,34 @@ def _refresh_codex_auth_tokens(
|
||||
|
||||
Saves the new tokens to Hermes auth store automatically.
|
||||
"""
|
||||
refreshed = refresh_codex_oauth_pure(
|
||||
str(tokens.get("access_token", "") or ""),
|
||||
str(tokens.get("refresh_token", "") or ""),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
try:
|
||||
refreshed = refresh_codex_oauth_pure(
|
||||
str(tokens.get("access_token", "") or ""),
|
||||
str(tokens.get("refresh_token", "") or ""),
|
||||
timeout_seconds=timeout_seconds,
|
||||
)
|
||||
except AuthError as exc:
|
||||
# Self-heal cross-store refresh_token rotation. Hermes keeps its OWN
|
||||
# Codex OAuth token (per profile + top-level), separate from the Codex
|
||||
# CLI's ~/.codex/auth.json. OAuth refresh_tokens are single-use, so when
|
||||
# the Codex CLI (or another Hermes process) rotates the shared token,
|
||||
# this frozen copy's refresh_token goes stale and the refresh fails with
|
||||
# a relogin-required error (invalid_grant / refresh_token_reused / 401).
|
||||
# Before surfacing that as a hard 401 to the turn, adopt the canonical
|
||||
# fresh token from ~/.codex/auth.json (the Codex CLI keeps it current) so
|
||||
# idle profiles / desktop sessions recover automatically instead of
|
||||
# 401'ing until a manual re-auth. Transient failures (e.g. 429 quota)
|
||||
# keep relogin_required=False — the stored token is still valid there, so
|
||||
# we never self-heal those and re-raise unchanged.
|
||||
if not getattr(exc, "relogin_required", False):
|
||||
raise
|
||||
imported = _recover_codex_tokens_from_cli(
|
||||
f"refresh_token rejected: {getattr(exc, 'code', None) or 'auth_error'}"
|
||||
)
|
||||
if not imported:
|
||||
raise
|
||||
return imported
|
||||
|
||||
updated_tokens = dict(tokens)
|
||||
updated_tokens["access_token"] = refreshed["access_token"]
|
||||
updated_tokens["refresh_token"] = refreshed["refresh_token"]
|
||||
@@ -3724,9 +3763,25 @@ def resolve_codex_runtime_credentials(
|
||||
HTTP 401 ``Missing Authentication header`` from the wire instead of a usable
|
||||
credential. See issue #32992.
|
||||
"""
|
||||
read_error: Optional[AuthError] = None
|
||||
try:
|
||||
data = _read_codex_tokens()
|
||||
except AuthError:
|
||||
except AuthError as exc:
|
||||
read_error = exc
|
||||
if getattr(exc, "relogin_required", False) and getattr(exc, "code", None) in {
|
||||
"codex_auth_missing_access_token",
|
||||
"codex_auth_missing_refresh_token",
|
||||
"codex_auth_invalid_shape",
|
||||
}:
|
||||
imported = _recover_codex_tokens_from_cli(str(getattr(exc, "code", None) or "auth_error"))
|
||||
if imported:
|
||||
data = {"tokens": imported, "last_refresh": imported.get("last_refresh")}
|
||||
else:
|
||||
data = None
|
||||
else:
|
||||
data = None
|
||||
|
||||
if data is None:
|
||||
pool_token = _pool_codex_access_token()
|
||||
if pool_token:
|
||||
base_url = (
|
||||
@@ -3741,7 +3796,14 @@ def resolve_codex_runtime_credentials(
|
||||
"last_refresh": None,
|
||||
"auth_mode": "chatgpt",
|
||||
}
|
||||
raise
|
||||
if read_error is not None:
|
||||
raise read_error
|
||||
raise AuthError(
|
||||
"No Codex credentials stored. Run `hermes auth` to authenticate.",
|
||||
provider="openai-codex",
|
||||
code="codex_auth_missing",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
tokens = dict(data["tokens"])
|
||||
access_token = str(tokens.get("access_token", "") or "").strip()
|
||||
|
||||
+29
-3
@@ -1543,8 +1543,14 @@ def run_doctor(args):
|
||||
total = critical + high + moderate
|
||||
# Determine a scoped fix command for the remediation hint.
|
||||
if audit_extra and audit_extra[0] == "--workspace":
|
||||
fix_scope = " ".join(audit_extra)
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix {fix_scope}"
|
||||
# Detection (`npm audit --workspace <name>`) is read-only and
|
||||
# safe, but `npm audit fix --workspace <name>` crashes on
|
||||
# current npm with "Cannot read properties of null (reading
|
||||
# 'edgesOut')" — an arborist bug with workspace-filtered
|
||||
# audit fix. The root-level `npm audit fix` can crash on the
|
||||
# same tree with "isDescendantOf", so do not hand the user a
|
||||
# manual fix command for these build-tool advisories.
|
||||
fix_cmd = None
|
||||
elif audit_extra == ["--workspaces=false"]:
|
||||
fix_cmd = f"cd {npm_dir} && npm audit fix --workspaces=false"
|
||||
else:
|
||||
@@ -1552,10 +1558,30 @@ def run_doctor(args):
|
||||
if total == 0:
|
||||
check_ok(f"{label} deps", "(no known vulnerabilities)")
|
||||
elif critical > 0 or high > 0:
|
||||
if fix_cmd:
|
||||
vuln_detail = (
|
||||
f"{critical} critical, {high} high, {moderate} moderate — run: {fix_cmd}"
|
||||
)
|
||||
else:
|
||||
vuln_detail = (
|
||||
f"{critical} critical, {high} high, {moderate} moderate — "
|
||||
"build-tool advisory; clears via lockfile bump"
|
||||
)
|
||||
check_warn(
|
||||
f"{label} deps",
|
||||
f"({critical} critical, {high} high, {moderate} moderate — run: {fix_cmd})"
|
||||
f"({vuln_detail})"
|
||||
)
|
||||
if audit_extra and audit_extra[0] == "--workspace":
|
||||
# The web/ui-tui workspace advisories are in build-time
|
||||
# tooling (esbuild/vite, etc.), not runtime code that ships
|
||||
# to users. Manual npm remediation may error with a known
|
||||
# arborist crash (edgesOut / isDescendantOf) on this monorepo
|
||||
# tree — in that case it is an npm bug, not a Hermes one.
|
||||
check_info(
|
||||
" ^ build-time tooling (not runtime); if manual npm remediation "
|
||||
"errors with an arborist crash it's a known npm bug — clears "
|
||||
"via a lockfile bump"
|
||||
)
|
||||
issues.append(
|
||||
f"{label} has {total} npm "
|
||||
f"{'vulnerability' if total == 1 else 'vulnerabilities'}"
|
||||
|
||||
@@ -6675,6 +6675,40 @@ def _worker_terminal_timeout_env(
|
||||
return str(desired)
|
||||
|
||||
|
||||
def _resolve_worker_cli_toolsets(hermes_home: Optional[str]) -> Optional[list[str]]:
|
||||
"""Return the assigned profile's effective CLI toolsets for a worker.
|
||||
|
||||
Dispatcher-spawned workers are launched from a long-lived gateway process,
|
||||
then the child re-enters the CLI with ``-p <assignee>``. Resolve the
|
||||
assignee profile's CLI tool surface at dispatch time and pass it as an
|
||||
explicit ``--toolsets`` pin so worker startup cannot fall back to a stale
|
||||
root/active-profile config or a profile whose top-level ``toolsets`` entry
|
||||
is only the kanban orchestrator surface. ``model_tools`` still appends the
|
||||
task-scoped kanban lifecycle tools when ``HERMES_KANBAN_TASK`` is set.
|
||||
"""
|
||||
if not hermes_home:
|
||||
return None
|
||||
try:
|
||||
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
|
||||
from hermes_cli.config import load_config
|
||||
from hermes_cli.tools_config import _get_platform_tools
|
||||
|
||||
token = set_hermes_home_override(hermes_home)
|
||||
try:
|
||||
cfg = load_config()
|
||||
toolsets = sorted(_get_platform_tools(cfg, "cli"))
|
||||
finally:
|
||||
reset_hermes_home_override(token)
|
||||
return toolsets or None
|
||||
except Exception as exc:
|
||||
_log.debug(
|
||||
"kanban worker: could not resolve CLI toolsets for HERMES_HOME=%r (%s)",
|
||||
hermes_home,
|
||||
exc,
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
def _default_spawn(
|
||||
task: Task,
|
||||
workspace: str,
|
||||
@@ -6808,6 +6842,9 @@ def _default_spawn(
|
||||
cmd.extend(["--skills", sk])
|
||||
if task.model_override:
|
||||
cmd.extend(["-m", task.model_override])
|
||||
worker_toolsets = _resolve_worker_cli_toolsets(env.get("HERMES_HOME"))
|
||||
if worker_toolsets:
|
||||
cmd.extend(["--toolsets", ",".join(worker_toolsets)])
|
||||
cmd.extend([
|
||||
"chat",
|
||||
"-q", prompt,
|
||||
|
||||
+201
-9
@@ -8030,6 +8030,182 @@ def _run_pre_update_backup(args) -> None:
|
||||
print()
|
||||
|
||||
|
||||
def _write_update_planned_stop_marker(profile_path: Path, pid: int) -> bool:
|
||||
"""Write a planned-stop marker into a specific profile home."""
|
||||
try:
|
||||
from datetime import timezone
|
||||
|
||||
from gateway.status import _get_process_start_time
|
||||
from utils import atomic_json_write
|
||||
|
||||
record = {
|
||||
"target_pid": pid,
|
||||
"target_start_time": _get_process_start_time(pid),
|
||||
"stopper_pid": os.getpid(),
|
||||
"written_at": datetime.now(timezone.utc).isoformat(),
|
||||
}
|
||||
atomic_json_write(
|
||||
Path(profile_path) / ".gateway-planned-stop.json",
|
||||
record,
|
||||
indent=None,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
return True
|
||||
except (OSError, PermissionError):
|
||||
return False
|
||||
|
||||
|
||||
def _wait_for_windows_update_gateway_exit(
|
||||
pids: list[int], *, timeout: float
|
||||
) -> set[int]:
|
||||
"""Wait for the given gateway PIDs to exit, returning survivors."""
|
||||
if not pids:
|
||||
return set()
|
||||
|
||||
from gateway.status import _pid_exists
|
||||
|
||||
remaining = set(pids)
|
||||
deadline = _time.monotonic() + max(timeout, 0.0)
|
||||
while remaining and _time.monotonic() < deadline:
|
||||
for pid in list(remaining):
|
||||
try:
|
||||
if not _pid_exists(pid):
|
||||
remaining.discard(pid)
|
||||
except Exception:
|
||||
remaining.discard(pid)
|
||||
if remaining:
|
||||
_time.sleep(0.25)
|
||||
|
||||
survivors: set[int] = set()
|
||||
for pid in remaining:
|
||||
try:
|
||||
if _pid_exists(pid):
|
||||
survivors.add(pid)
|
||||
except Exception:
|
||||
pass
|
||||
return survivors
|
||||
|
||||
|
||||
def _pause_windows_gateways_for_update() -> dict | None:
|
||||
"""Stop running Windows gateways before mutating the checkout or venv.
|
||||
|
||||
Windows scheduled/startup gateways run through pythonw.exe, so the generic
|
||||
hermes.exe concurrent-instance guard does not see them. They still import
|
||||
from the checkout and can keep files locked while ``git`` or ``uv`` updates
|
||||
the install. Stop only PIDs that the gateway discovery code identifies.
|
||||
"""
|
||||
if not _is_windows():
|
||||
return None
|
||||
|
||||
try:
|
||||
from gateway.status import terminate_pid
|
||||
from hermes_cli.gateway import (
|
||||
_get_restart_drain_timeout,
|
||||
find_gateway_pids,
|
||||
find_profile_gateway_processes,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.debug("Could not prepare Windows gateway pause for update: %s", exc)
|
||||
return None
|
||||
|
||||
try:
|
||||
running_pids = list(dict.fromkeys(find_gateway_pids(all_profiles=True)))
|
||||
except Exception as exc:
|
||||
logger.debug("Could not discover Windows gateway PIDs before update: %s", exc)
|
||||
return None
|
||||
if not running_pids:
|
||||
return None
|
||||
|
||||
profile_processes = {}
|
||||
try:
|
||||
profile_processes = {
|
||||
proc.pid: proc for proc in find_profile_gateway_processes()
|
||||
}
|
||||
except Exception as exc:
|
||||
logger.debug("Could not map Windows gateway PIDs to profiles: %s", exc)
|
||||
|
||||
profiles: dict[str, int] = {}
|
||||
mapped_pids = []
|
||||
for pid in running_pids:
|
||||
proc = profile_processes.get(pid)
|
||||
if proc is None:
|
||||
continue
|
||||
profiles[str(proc.profile)] = int(pid)
|
||||
mapped_pids.append(int(pid))
|
||||
_write_update_planned_stop_marker(Path(proc.path), int(pid))
|
||||
|
||||
print("→ Stopping Windows gateway process(es) before updating Hermes...")
|
||||
try:
|
||||
drain_timeout = max(float(_get_restart_drain_timeout()), 1.0)
|
||||
except Exception:
|
||||
drain_timeout = 10.0
|
||||
survivors = _wait_for_windows_update_gateway_exit(
|
||||
mapped_pids,
|
||||
timeout=drain_timeout,
|
||||
)
|
||||
unmapped_pids = [pid for pid in running_pids if pid not in profile_processes]
|
||||
|
||||
force_killed = []
|
||||
for pid in sorted(set(survivors).union(unmapped_pids)):
|
||||
try:
|
||||
terminate_pid(int(pid), force=True)
|
||||
force_killed.append(int(pid))
|
||||
except (ProcessLookupError, PermissionError, OSError):
|
||||
pass
|
||||
|
||||
if profiles:
|
||||
print(f" ✓ Paused gateway profile(s): {', '.join(sorted(profiles))}")
|
||||
if force_killed:
|
||||
print(f" → Force-stopped {len(force_killed)} gateway process(es)")
|
||||
|
||||
if unmapped_pids:
|
||||
print(
|
||||
f" → Stopped {len(unmapped_pids)} gateway process(es) without profile mapping"
|
||||
)
|
||||
print(" Restart manually after update: hermes gateway run")
|
||||
|
||||
return {
|
||||
"resume_needed": True,
|
||||
"profiles": profiles,
|
||||
"unmapped_pids": unmapped_pids,
|
||||
}
|
||||
|
||||
|
||||
def _resume_windows_gateways_after_update(token: dict | None) -> None:
|
||||
"""Restart Windows profile gateways previously paused for update."""
|
||||
if not token or not token.get("resume_needed"):
|
||||
return
|
||||
token["resume_needed"] = False
|
||||
if not _is_windows():
|
||||
return
|
||||
|
||||
profiles = token.get("profiles") or {}
|
||||
if not profiles:
|
||||
return
|
||||
|
||||
try:
|
||||
from hermes_cli.gateway import launch_detached_profile_gateway_restart
|
||||
except Exception as exc:
|
||||
logger.debug("Could not load Windows gateway restart helper: %s", exc)
|
||||
return
|
||||
|
||||
relaunched = []
|
||||
for profile, old_pid in sorted(profiles.items()):
|
||||
try:
|
||||
if launch_detached_profile_gateway_restart(str(profile), int(old_pid)):
|
||||
relaunched.append(str(profile))
|
||||
except Exception as exc:
|
||||
logger.debug(
|
||||
"Could not restart Windows gateway profile %s after update: %s",
|
||||
profile,
|
||||
exc,
|
||||
)
|
||||
|
||||
if relaunched:
|
||||
print()
|
||||
print(f" ✓ Restarting Windows gateway profile(s): {', '.join(relaunched)}")
|
||||
|
||||
|
||||
def _discard_lockfile_churn(git_cmd, repo_root):
|
||||
"""Restore tracked ``package-lock.json`` files that npm dirtied locally.
|
||||
|
||||
@@ -8232,6 +8408,15 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
# always roll back to the exact state they had before this update.
|
||||
_run_pre_update_backup(args)
|
||||
|
||||
_windows_gateway_resume = _pause_windows_gateways_for_update()
|
||||
if _windows_gateway_resume:
|
||||
import atexit as _atexit
|
||||
|
||||
_atexit.register(
|
||||
_resume_windows_gateways_after_update,
|
||||
_windows_gateway_resume,
|
||||
)
|
||||
|
||||
# Try git-based update first, fall back to ZIP download on Windows
|
||||
# when git file I/O is broken (antivirus, NTFS filter drivers, etc.)
|
||||
use_zip_update = False
|
||||
@@ -8294,7 +8479,10 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
|
||||
if use_zip_update:
|
||||
# ZIP-based update for Windows when git is broken
|
||||
_update_via_zip(args)
|
||||
try:
|
||||
_update_via_zip(args)
|
||||
finally:
|
||||
_resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
return
|
||||
|
||||
# Fetch and pull
|
||||
@@ -8431,6 +8619,7 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
check=False,
|
||||
)
|
||||
print("✓ Already up to date!")
|
||||
_resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
return
|
||||
|
||||
print(f"→ Found {commit_count} new commit(s)")
|
||||
@@ -9627,6 +9816,8 @@ def _cmd_update_impl(args, gateway_mode: bool):
|
||||
except Exception as e:
|
||||
logger.debug("Gateway restart during update failed: %s", e)
|
||||
|
||||
_resume_windows_gateways_after_update(_windows_gateway_resume)
|
||||
|
||||
# Warn if legacy Hermes gateway unit files are still installed.
|
||||
# When both hermes.service (from a pre-rename install) and the
|
||||
# current hermes-gateway.service are enabled, they SIGTERM-fight
|
||||
@@ -9860,19 +10051,20 @@ def cmd_profile(args):
|
||||
|
||||
try:
|
||||
clone_from = getattr(args, "clone_from", None)
|
||||
clone_config = clone or clone_from is not None
|
||||
|
||||
profile_dir = create_profile(
|
||||
name=name,
|
||||
clone_from=clone_from,
|
||||
clone_all=clone_all,
|
||||
clone_config=clone,
|
||||
clone_config=clone_config,
|
||||
no_alias=no_alias,
|
||||
no_skills=no_skills,
|
||||
description=getattr(args, "description", None),
|
||||
)
|
||||
print(f"\nProfile '{name}' created at {profile_dir}")
|
||||
|
||||
if clone or clone_all:
|
||||
if clone_config or clone_all:
|
||||
source_label = (
|
||||
getattr(args, "clone_from", None) or get_active_profile_name()
|
||||
)
|
||||
@@ -9886,8 +10078,8 @@ def cmd_profile(args):
|
||||
f"Cloned config, .env, SOUL.md, and skills from {source_label}."
|
||||
)
|
||||
|
||||
# Auto-clone Honcho config for the new profile (only with --clone/--clone-all)
|
||||
if clone or clone_all:
|
||||
# Auto-clone Honcho config for the new profile (only with clone operations)
|
||||
if clone_config or clone_all:
|
||||
try:
|
||||
from plugins.memory.honcho.cli import clone_honcho_for_profile
|
||||
|
||||
@@ -9896,10 +10088,10 @@ def cmd_profile(args):
|
||||
except Exception:
|
||||
pass # Honcho plugin not installed or not configured
|
||||
|
||||
# Seed bundled skills (skip if --clone-all already copied them, or
|
||||
# if --no-skills was passed — in which case seed_profile_skills()
|
||||
# honors the marker file and returns skipped_opt_out=True).
|
||||
if not clone_all:
|
||||
# Seed bundled skills for fresh profiles only. Clone operations
|
||||
# already copied the source profile's skills, including any
|
||||
# user-installed or intentionally removed skills.
|
||||
if not (clone_config or clone_all):
|
||||
result = seed_profile_skills(profile_dir)
|
||||
if result and result.get("skipped_opt_out"):
|
||||
print(
|
||||
|
||||
@@ -84,7 +84,6 @@ _STRIP_VENDOR_ONLY_PROVIDERS: frozenset[str] = frozenset({
|
||||
|
||||
# Providers whose native naming is authoritative -- pass through unchanged.
|
||||
_AUTHORITATIVE_NATIVE_PROVIDERS: frozenset[str] = frozenset({
|
||||
"gemini",
|
||||
"huggingface",
|
||||
})
|
||||
|
||||
@@ -103,6 +102,8 @@ _MATCHING_PREFIX_STRIP_PROVIDERS: frozenset[str] = frozenset({
|
||||
"arcee",
|
||||
"ollama-cloud",
|
||||
"custom",
|
||||
"gemini",
|
||||
"xai",
|
||||
})
|
||||
|
||||
# Providers whose APIs require lowercase model IDs. Xiaomi's
|
||||
|
||||
@@ -26,6 +26,7 @@ from dataclasses import dataclass
|
||||
from typing import List, NamedTuple, Optional
|
||||
|
||||
from hermes_cli.providers import (
|
||||
ProviderDef,
|
||||
custom_provider_slug,
|
||||
determine_api_mode,
|
||||
get_label,
|
||||
@@ -46,6 +47,23 @@ from agent.models_dev import (
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _bare_custom_provider_def(current_base_url: str) -> Optional[ProviderDef]:
|
||||
"""ProviderDef for a direct ``model.provider: custom`` endpoint."""
|
||||
base_url = str(current_base_url or "").strip()
|
||||
if not base_url:
|
||||
return None
|
||||
return ProviderDef(
|
||||
id="custom",
|
||||
name="Custom endpoint",
|
||||
transport="openai_chat",
|
||||
api_key_env_vars=(),
|
||||
base_url=base_url,
|
||||
is_aggregator=False,
|
||||
auth_type="api_key",
|
||||
source="model-config",
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Non-agentic model warning
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -676,6 +694,8 @@ def switch_model(
|
||||
user_providers,
|
||||
custom_providers,
|
||||
)
|
||||
if pdef is None and explicit_provider.strip().lower() == "custom":
|
||||
pdef = _bare_custom_provider_def(current_base_url)
|
||||
if pdef is None:
|
||||
_switch_err = (
|
||||
f"Unknown provider '{explicit_provider}'. "
|
||||
@@ -881,6 +901,8 @@ def switch_model(
|
||||
|
||||
provider_changed = target_provider != current_provider
|
||||
provider_label = get_label(target_provider)
|
||||
if target_provider == "custom" and current_base_url:
|
||||
provider_label = "Custom endpoint"
|
||||
if target_provider.startswith("custom:"):
|
||||
custom_pdef = resolve_provider_full(
|
||||
target_provider,
|
||||
@@ -932,6 +954,10 @@ def switch_model(
|
||||
api_key = _ukey
|
||||
base_url = _user_pdef.base_url
|
||||
api_mode = ""
|
||||
elif target_provider == "custom" and current_base_url:
|
||||
api_key = current_api_key
|
||||
base_url = current_base_url
|
||||
api_mode = determine_api_mode(target_provider, base_url)
|
||||
else:
|
||||
try:
|
||||
runtime = resolve_runtime_provider(
|
||||
@@ -1748,6 +1774,43 @@ def list_authenticated_providers(
|
||||
if _pair[0] and _pair[1]:
|
||||
_section3_emitted_pairs.add(_pair)
|
||||
|
||||
# --- 3b. Active bare custom endpoint from model config ---
|
||||
# A config can still use the direct one-off form:
|
||||
# model.provider: custom
|
||||
# model.base_url: https://some-openai-compatible/v1
|
||||
# In that shape there is no named providers:/custom_providers row for the
|
||||
# picker to render, but the gateway only passes this current model slice to
|
||||
# list_authenticated_providers(). Surface the active endpoint explicitly so
|
||||
# /model does not look like it ignored config.yaml.
|
||||
_current_provider_norm = str(current_provider or "").strip().lower()
|
||||
if (
|
||||
_current_provider_norm == "custom"
|
||||
and current_base_url
|
||||
and "custom" not in seen_slugs
|
||||
and not any(
|
||||
isinstance(_cp, dict)
|
||||
and str(
|
||||
_cp.get("base_url", "")
|
||||
or _cp.get("url", "")
|
||||
or _cp.get("api", "")
|
||||
).strip().rstrip("/").lower()
|
||||
== str(current_base_url).strip().rstrip("/").lower()
|
||||
for _cp in (custom_providers or [])
|
||||
)
|
||||
):
|
||||
_models = [current_model] if current_model else []
|
||||
results.append({
|
||||
"slug": "custom",
|
||||
"name": "Custom endpoint",
|
||||
"is_current": True,
|
||||
"is_user_defined": True,
|
||||
"models": _models[:max_models] if max_models else _models,
|
||||
"total_models": len(_models),
|
||||
"source": "model-config",
|
||||
"api_url": str(current_base_url).strip().rstrip("/"),
|
||||
})
|
||||
seen_slugs.add("custom")
|
||||
|
||||
# --- 4. Saved custom providers from config ---
|
||||
# Each ``custom_providers`` entry represents one model under a named
|
||||
# provider. Entries sharing the same endpoint, credential identity, and
|
||||
|
||||
@@ -135,6 +135,20 @@ def _sanitize_plugin_name(
|
||||
return target
|
||||
|
||||
|
||||
_GITHUB_BROWSER_SEGMENTS = {
|
||||
"actions",
|
||||
"blob",
|
||||
"commit",
|
||||
"commits",
|
||||
"issues",
|
||||
"pull",
|
||||
"pulls",
|
||||
"releases",
|
||||
"tree",
|
||||
"wiki",
|
||||
}
|
||||
|
||||
|
||||
def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
|
||||
"""Turn an identifier into a cloneable Git URL and optional subdirectory.
|
||||
|
||||
@@ -146,6 +160,8 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
|
||||
- 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
|
||||
- Browser URL: https://github.com/owner/repo/tree/main/path
|
||||
→ (https://github.com/owner/repo.git, "path")
|
||||
- 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")
|
||||
@@ -161,6 +177,17 @@ def _resolve_git_url(identifier: str) -> tuple[str, Optional[str]]:
|
||||
"""
|
||||
# Already a URL.
|
||||
if identifier.startswith(("https://", "http://", "git@", "ssh://", "file://")):
|
||||
if identifier.startswith("https://github.com/"):
|
||||
path = identifier[len("https://github.com/") :]
|
||||
path = path.split("?", 1)[0].split("#", 1)[0].strip("/")
|
||||
parts = path.split("/")
|
||||
if len(parts) >= 3 and all(parts[:2]) and parts[2] in _GITHUB_BROWSER_SEGMENTS:
|
||||
repo = parts[1].removesuffix(".git")
|
||||
subdir = None
|
||||
if parts[2] == "tree" and len(parts) >= 5:
|
||||
subdir = "/".join(p for p in parts[4:] if p).strip("/") or None
|
||||
return f"https://github.com/{parts[0]}/{repo}.git", subdir
|
||||
|
||||
# Explicit ``#subdir`` fragment — unambiguous for any scheme.
|
||||
if "#" in identifier:
|
||||
git_url, _, frag = identifier.partition("#")
|
||||
|
||||
+12
-6
@@ -784,9 +784,9 @@ def create_profile(
|
||||
Path
|
||||
The newly created profile directory.
|
||||
"""
|
||||
if no_skills and (clone_config or clone_all):
|
||||
if no_skills and (clone_from is not None or clone_config or clone_all):
|
||||
raise ValueError(
|
||||
"--no-skills is mutually exclusive with --clone / --clone-all "
|
||||
"--no-skills is mutually exclusive with --clone / --clone-from / --clone-all "
|
||||
"(cloning explicitly copies skills from the source profile)."
|
||||
)
|
||||
canon = normalize_profile_name(name)
|
||||
@@ -1190,10 +1190,16 @@ def _maybe_register_gateway_service(profile_name: str) -> None:
|
||||
can re-register manually later via the gateway start command,
|
||||
which goes through the same dispatch path.
|
||||
|
||||
Port selection is governed by the profile's ``config.yaml``
|
||||
(``[gateway] port = …``) — there is no Python-side allocator
|
||||
(PR #30136 review item I5 retired the SHA-256-derived range
|
||||
[9200, 9800) because it was dead code through the entire stack).
|
||||
Port selection: each supervised profile gateway loads its own
|
||||
``HERMES_HOME`` and binds the port resolved by ``gateway/config.py``
|
||||
from that profile's environment — ``API_SERVER_PORT`` (or
|
||||
``platforms.api_server.extra.port`` in the profile's
|
||||
``config.yaml``), defaulting to 8642. There is no ``[gateway] port``
|
||||
key and no Python-side allocator (PR #30136 review item I5 retired
|
||||
the SHA-256-derived range [9200, 9800) as dead code), so two
|
||||
profiles that both leave the port at its default will both try to
|
||||
bind 8642 — give each profile a distinct ``API_SERVER_PORT`` in its
|
||||
``.env``.
|
||||
|
||||
Host short-circuit: check ``detect_service_manager()`` first and
|
||||
return immediately if it isn't ``"s6"``. This keeps host
|
||||
|
||||
@@ -660,6 +660,61 @@ def has_named_custom_provider(requested_provider: str) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def find_custom_provider_identity(base_url: str) -> Optional[str]:
|
||||
"""Map an endpoint URL back to its canonical ``custom:<name>`` menu key.
|
||||
|
||||
Returns the ``custom:<normalized-name>`` slug of the first ``providers:``
|
||||
/ ``custom_providers:`` entry whose base_url matches, or ``None`` when no
|
||||
entry owns the URL.
|
||||
|
||||
Session persistence stores the agent's *resolved* provider, and for every
|
||||
named custom endpoint that is the literal string ``"custom"`` — the entry
|
||||
name is lost, and the api_key is deliberately never persisted. The
|
||||
endpoint URL is the one durable fact that survives the round-trip, so
|
||||
this reverse lookup lets persist/rebuild paths recover the entry identity
|
||||
(and with it key_env/api_key/api_mode resolution via
|
||||
:func:`_get_named_custom_provider`) instead of failing with
|
||||
``auth_unavailable`` or silently rebuilding with placeholder credentials.
|
||||
"""
|
||||
target = _normalize_base_url_for_match(base_url)
|
||||
if not target:
|
||||
return None
|
||||
try:
|
||||
config = load_config()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
providers = config.get("providers")
|
||||
if isinstance(providers, dict):
|
||||
for ep_name, entry in providers.items():
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
entry_url = (
|
||||
entry.get("api") or entry.get("url") or entry.get("base_url") or ""
|
||||
)
|
||||
if _normalize_base_url_for_match(entry_url) == target:
|
||||
return f"custom:{_normalize_custom_provider_name(str(ep_name))}"
|
||||
|
||||
try:
|
||||
custom_providers = get_compatible_custom_providers(config)
|
||||
except Exception:
|
||||
custom_providers = None
|
||||
for entry in custom_providers or []:
|
||||
if not isinstance(entry, dict):
|
||||
continue
|
||||
name = entry.get("name")
|
||||
if not isinstance(name, str) or not name.strip():
|
||||
continue
|
||||
if _normalize_base_url_for_match(entry.get("base_url")) == target:
|
||||
return f"custom:{_normalize_custom_provider_name(name)}"
|
||||
|
||||
return None
|
||||
|
||||
|
||||
def _normalize_base_url_for_match(value) -> str:
|
||||
return str(value or "").strip().rstrip("/").lower()
|
||||
|
||||
|
||||
def _custom_provider_request_overrides(custom_provider: Dict[str, Any]) -> Dict[str, Any]:
|
||||
extra_body = custom_provider.get("extra_body")
|
||||
if not isinstance(extra_body, dict) or not extra_body:
|
||||
|
||||
@@ -585,15 +585,20 @@ class S6ServiceManager:
|
||||
would instead look up ``$HERMES_HOME/profiles/default/`` — a
|
||||
completely different (and almost always nonexistent) profile.
|
||||
|
||||
Port selection: the gateway picks its bind port from the
|
||||
profile's ``config.yaml`` (``[gateway] port = ...``) — that
|
||||
is the single source of truth. Previously this method took a
|
||||
``port`` parameter that was passed in but never substituted
|
||||
into the rendered script (it was carried in for "API parity"
|
||||
with a deterministic SHA-256 allocator in
|
||||
``hermes_cli.profiles._allocate_gateway_port``). PR #30136
|
||||
review item I5 retired both the allocator and the parameter
|
||||
because they were dead code through the entire stack.
|
||||
Port selection: the gateway binds the port resolved by
|
||||
``gateway/config.py`` from the profile's own environment —
|
||||
``API_SERVER_PORT`` (or ``platforms.api_server.extra.port`` in
|
||||
that profile's ``config.yaml``), defaulting to 8642. There is
|
||||
no ``[gateway] port`` key and no Python-side allocator: because
|
||||
each supervised profile gateway loads its own ``HERMES_HOME``,
|
||||
two profiles that both leave the port unset will both try to
|
||||
bind 8642 — give each profile a distinct ``API_SERVER_PORT`` in
|
||||
its ``.env``. Previously this method took a ``port`` parameter
|
||||
that was passed in but never substituted into the rendered
|
||||
script (carried for "API parity" with a deterministic SHA-256
|
||||
allocator in ``hermes_cli.profiles._allocate_gateway_port``).
|
||||
PR #30136 review item I5 retired both the allocator and the
|
||||
parameter because they were dead code through the entire stack.
|
||||
"""
|
||||
import shlex
|
||||
lines = [
|
||||
|
||||
@@ -14,6 +14,21 @@ from typing import Callable
|
||||
from hermes_cli.subcommands._shared import add_accept_hooks_flag
|
||||
|
||||
|
||||
def _add_compat_platform_flag(parser: argparse.ArgumentParser) -> None:
|
||||
"""Accept stale `gateway <verb> --platform X` docs without advertising it.
|
||||
|
||||
Gateway service lifecycle commands operate on the gateway process, not a
|
||||
single messaging adapter. Photon briefly printed a per-platform start
|
||||
command during setup; keep that command parseable so users following the
|
||||
old hint don't get blocked by argparse before the gateway can start.
|
||||
"""
|
||||
parser.add_argument(
|
||||
"--platform",
|
||||
dest="platform",
|
||||
help=argparse.SUPPRESS,
|
||||
)
|
||||
|
||||
|
||||
def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callable) -> None:
|
||||
"""Attach the ``gateway`` and ``proxy`` subcommands to ``subparsers``."""
|
||||
# =========================================================================
|
||||
@@ -75,6 +90,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
action="store_true",
|
||||
help="Kill ALL stale gateway processes across all profiles before starting",
|
||||
)
|
||||
_add_compat_platform_flag(gateway_start)
|
||||
|
||||
# gateway stop
|
||||
gateway_stop = gateway_subparsers.add_parser("stop", help="Stop gateway service")
|
||||
@@ -103,6 +119,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
action="store_true",
|
||||
help="Kill ALL gateway processes across all profiles before restarting",
|
||||
)
|
||||
_add_compat_platform_flag(gateway_restart)
|
||||
|
||||
# gateway status
|
||||
gateway_status = gateway_subparsers.add_parser("status", help="Show gateway status")
|
||||
@@ -118,6 +135,7 @@ def build_gateway_parser(subparsers, *, cmd_gateway: Callable, cmd_proxy: Callab
|
||||
action="store_true",
|
||||
help="Target the Linux system-level gateway service",
|
||||
)
|
||||
_add_compat_platform_flag(gateway_status)
|
||||
|
||||
# gateway install
|
||||
gateway_install = gateway_subparsers.add_parser(
|
||||
|
||||
@@ -35,17 +35,17 @@ def build_profile_parser(subparsers, *, cmd_profile: Callable) -> None:
|
||||
profile_create.add_argument(
|
||||
"--clone",
|
||||
action="store_true",
|
||||
help="Copy config.yaml, .env, SOUL.md from active profile",
|
||||
help="Copy config.yaml, .env, SOUL.md, and skills from active profile",
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--clone-all",
|
||||
action="store_true",
|
||||
help="Full copy of active profile (all state)",
|
||||
help="Full copy of active profile (all state, excluding per-profile history)",
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--clone-from",
|
||||
metavar="SOURCE",
|
||||
help="Source profile to clone from (default: active)",
|
||||
help="Source profile to clone from; implies --clone unless --clone-all is set",
|
||||
)
|
||||
profile_create.add_argument(
|
||||
"--no-alias", action="store_true", help="Skip wrapper script creation"
|
||||
|
||||
+140
-47
@@ -1230,7 +1230,12 @@ def _managed_files_policy(request: Request, *, create_root: bool = True) -> Mana
|
||||
root = _ensure_managed_root(raw_forced_root) if create_root else _canonical_path(Path(raw_forced_root))
|
||||
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
|
||||
|
||||
if not _local_dashboard_request(request) or _default_hermes_root_is_opt_data():
|
||||
# Remote/OAuth access does not imply a hosted container. Users can expose a
|
||||
# local dashboard through the auth gate (for example a macOS launchd install)
|
||||
# and still expect the Files page to browse their local home directory. Lock
|
||||
# to /opt/data only when the installation's Hermes root is actually /opt/data
|
||||
# (the container/hosted layout) or when HERMES_DASHBOARD_FILES_ROOT is set.
|
||||
if _default_hermes_root_is_opt_data():
|
||||
root = _ensure_managed_root(_HOSTED_MANAGED_FILES_ROOT) if create_root else _HOSTED_MANAGED_FILES_ROOT
|
||||
return ManagedFilesPolicy(default_path=root, locked_root=root, can_change_path=False)
|
||||
|
||||
@@ -1640,17 +1645,16 @@ async def get_status():
|
||||
# Module not importable yet (early startup) — leave as [].
|
||||
pass
|
||||
|
||||
return {
|
||||
# Always-public liveness + auth-gate shape. Safe for external uptime
|
||||
# probes (NAS's wildcard-subdomain liveness probe), the SPA's pre-login
|
||||
# bootstrap, and anyone who can curl the host — i.e. exactly the audience
|
||||
# ``PUBLIC_API_PATHS`` documents this endpoint as serving.
|
||||
status = {
|
||||
"version": __version__,
|
||||
"release_date": __release_date__,
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"config_path": str(get_config_path()),
|
||||
"env_path": str(get_env_path()),
|
||||
"config_version": current_ver,
|
||||
"latest_config_version": latest_ver,
|
||||
"gateway_running": gateway_running,
|
||||
"gateway_pid": gateway_pid,
|
||||
"gateway_health_url": _GATEWAY_HEALTH_URL,
|
||||
"gateway_state": gateway_state,
|
||||
"gateway_platforms": gateway_platforms,
|
||||
"gateway_exit_reason": gateway_exit_reason,
|
||||
@@ -1660,6 +1664,27 @@ async def get_status():
|
||||
"auth_providers": auth_providers,
|
||||
}
|
||||
|
||||
# Absolute host paths, the gateway PID, and the internal gateway health
|
||||
# URL are deployment recon a liveness probe never needs. ``/api/status``
|
||||
# is in ``PUBLIC_API_PATHS`` so it bypasses dashboard auth; on a
|
||||
# network-exposed (gated) bind that means *any* unauthenticated caller
|
||||
# reaches it, and leaking host metadata there contradicts the allowlist's
|
||||
# own contract ("version, gateway state, active session count, and the
|
||||
# dashboard auth-gate shape. No bodies, no session content, no secrets").
|
||||
# Surface this detail only on a loopback / ``--insecure`` bind, where the
|
||||
# dashboard is local-only and the caller is already inside the trust
|
||||
# envelope — the same loopback/gated split ``should_require_auth`` draws.
|
||||
if not auth_required:
|
||||
status.update({
|
||||
"hermes_home": str(get_hermes_home()),
|
||||
"config_path": str(get_config_path()),
|
||||
"env_path": str(get_env_path()),
|
||||
"gateway_pid": gateway_pid,
|
||||
"gateway_health_url": _GATEWAY_HEALTH_URL,
|
||||
})
|
||||
|
||||
return status
|
||||
|
||||
|
||||
_WINDOWS_11_MIN_BUILD = 22000
|
||||
|
||||
@@ -2535,6 +2560,7 @@ async def get_sessions(
|
||||
order: str = "created",
|
||||
source: str = None,
|
||||
exclude_sources: str = None,
|
||||
profile: Optional[str] = None,
|
||||
):
|
||||
"""List sessions.
|
||||
|
||||
@@ -2558,9 +2584,11 @@ async def get_sessions(
|
||||
status_code=400,
|
||||
detail="order must be one of: created, recent",
|
||||
)
|
||||
profile_name: Optional[str] = None
|
||||
if profile:
|
||||
profile_name, _ = _cron_profile_home(profile)
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
min_message_count = max(0, min_messages)
|
||||
archived_only = archived == "only"
|
||||
@@ -2594,11 +2622,16 @@ async def get_sessions(
|
||||
s.get("ended_at") is None
|
||||
and (now - s.get("last_active", s.get("started_at", 0))) < 300
|
||||
)
|
||||
if profile_name:
|
||||
s["profile"] = profile_name
|
||||
s["is_default_profile"] = profile_name == "default"
|
||||
# SQLite stores the flag as 0/1; expose a real JSON boolean.
|
||||
s["archived"] = bool(s.get("archived"))
|
||||
return {"sessions": sessions, "total": total, "limit": limit, "offset": offset}
|
||||
finally:
|
||||
db.close()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("GET /api/sessions failed")
|
||||
raise HTTPException(status_code=500, detail="Internal server error")
|
||||
@@ -2724,7 +2757,7 @@ async def get_profiles_sessions(
|
||||
|
||||
|
||||
@app.get("/api/sessions/search")
|
||||
async def search_sessions(q: str = "", limit: int = 20):
|
||||
async def search_sessions(q: str = "", limit: int = 20, profile: Optional[str] = None):
|
||||
"""Search sessions by ID plus full-text message content using FTS5.
|
||||
|
||||
Direct session-id matches are surfaced first, then FTS message-content
|
||||
@@ -2738,8 +2771,7 @@ async def search_sessions(q: str = "", limit: int = 20):
|
||||
if not q or not q.strip():
|
||||
return {"results": []}
|
||||
try:
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
safe_limit = max(1, min(int(limit or 20), 100))
|
||||
|
||||
@@ -2881,6 +2913,8 @@ async def search_sessions(q: str = "", limit: int = 20):
|
||||
return {"results": list(seen.values())}
|
||||
finally:
|
||||
db.close()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
_log.exception("GET /api/sessions/search failed")
|
||||
raise HTTPException(status_code=500, detail="Search failed")
|
||||
@@ -6290,6 +6324,7 @@ def _session_latest_descendant(session_id: str):
|
||||
# reorder this block, move every route in it together.
|
||||
class BulkDeleteSessions(BaseModel):
|
||||
ids: List[str]
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/sessions/bulk-delete")
|
||||
@@ -6334,8 +6369,7 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
||||
status_code=400,
|
||||
detail="ids must contain at most 500 entries",
|
||||
)
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
try:
|
||||
deleted = db.delete_sessions(body.ids)
|
||||
return {"ok": True, "deleted": deleted}
|
||||
@@ -6344,15 +6378,14 @@ async def bulk_delete_sessions_endpoint(body: BulkDeleteSessions):
|
||||
|
||||
|
||||
@app.get("/api/sessions/empty/count")
|
||||
async def count_empty_sessions_endpoint():
|
||||
async def count_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
"""Return the number of empty, ended, non-archived sessions.
|
||||
|
||||
Drives the dashboard's "Delete empty (N)" button — when N is 0 the
|
||||
UI hides the affordance so users aren't presented with a button
|
||||
that does nothing. Cheap, single-COUNT query.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
return {"count": db.count_empty_sessions()}
|
||||
finally:
|
||||
@@ -6360,7 +6393,7 @@ async def count_empty_sessions_endpoint():
|
||||
|
||||
|
||||
@app.delete("/api/sessions/empty")
|
||||
async def delete_empty_sessions_endpoint():
|
||||
async def delete_empty_sessions_endpoint(profile: Optional[str] = None):
|
||||
"""Delete every empty (``message_count == 0``), ended,
|
||||
non-archived session in a single transaction.
|
||||
|
||||
@@ -6379,8 +6412,7 @@ async def delete_empty_sessions_endpoint():
|
||||
prune-on-startup pass. Matching that pre-existing trade-off keeps
|
||||
the two delete endpoints' DB-vs-disk behaviour consistent.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
deleted = db.delete_empty_sessions()
|
||||
return {"ok": True, "deleted": deleted}
|
||||
@@ -6389,15 +6421,13 @@ async def delete_empty_sessions_endpoint():
|
||||
|
||||
|
||||
@app.get("/api/sessions/stats")
|
||||
async def get_session_stats():
|
||||
async def get_session_stats(profile: Optional[str] = None):
|
||||
"""Session-store statistics for the Sessions page (mirrors `hermes sessions stats`).
|
||||
|
||||
Registered before ``/api/sessions/{session_id}`` so the literal ``stats``
|
||||
path isn't captured as a session id by the parameterized route.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
total = db.session_count(include_archived=True)
|
||||
active_store = db.session_count(include_archived=False)
|
||||
@@ -6535,11 +6565,9 @@ async def rename_session_endpoint(session_id: str, body: SessionRename):
|
||||
|
||||
|
||||
@app.get("/api/sessions/{session_id}/export")
|
||||
async def export_session_endpoint(session_id: str):
|
||||
async def export_session_endpoint(session_id: str, profile: Optional[str] = None):
|
||||
"""Export a single session (metadata + messages) as JSON."""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
sid = db.resolve_session_id(session_id)
|
||||
if not sid:
|
||||
@@ -6555,6 +6583,7 @@ async def export_session_endpoint(session_id: str):
|
||||
class SessionPrune(BaseModel):
|
||||
older_than_days: int = 90
|
||||
source: Optional[str] = None
|
||||
profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.post("/api/sessions/prune")
|
||||
@@ -6562,11 +6591,10 @@ async def prune_sessions_endpoint(body: SessionPrune):
|
||||
"""Delete ended sessions older than N days (mirrors `hermes sessions prune`)."""
|
||||
if body.older_than_days < 1:
|
||||
raise HTTPException(status_code=400, detail="older_than_days must be >= 1")
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
profile_home = _cron_profile_home(body.profile)[1] if body.profile else get_hermes_home()
|
||||
db = _open_session_db_for_profile(body.profile)
|
||||
try:
|
||||
sessions_dir = get_hermes_home() / "sessions"
|
||||
sessions_dir = profile_home / "sessions"
|
||||
removed = db.prune_sessions(
|
||||
older_than_days=body.older_than_days,
|
||||
source=(body.source or None),
|
||||
@@ -8490,15 +8518,13 @@ async def scan_skill_hub(identifier: str = ""):
|
||||
|
||||
class ProfileCreate(BaseModel):
|
||||
name: str
|
||||
clone_from: Optional[str] = None
|
||||
# Backward compatibility for older dashboard/desktop clients. New clients
|
||||
# send clone_from="default" (or another profile name) explicitly.
|
||||
clone_from_default: bool = False
|
||||
clone_all: bool = False
|
||||
no_skills: bool = False
|
||||
description: Optional[str] = None
|
||||
# Explicit source profile to clone from (e.g. duplicating an existing
|
||||
# profile). When set, it takes precedence over ``clone_from_default``,
|
||||
# which always sources from "default". ``clone_all`` still selects a full
|
||||
# state copytree vs. a config/skills/SOUL copy.
|
||||
clone_from: Optional[str] = None
|
||||
provider: Optional[str] = None
|
||||
model: Optional[str] = None
|
||||
# Profile-builder additions — all optional, all applied best-effort AFTER
|
||||
@@ -8770,10 +8796,16 @@ async def create_profile_endpoint(body: ProfileCreate):
|
||||
clone = True
|
||||
clone_from = explicit_source
|
||||
clone_config = not body.clone_all
|
||||
elif body.clone_all:
|
||||
# Preserve the dashboard's historical clone-all behavior: a full-copy
|
||||
# request with no explicit dropdown source copies from default.
|
||||
clone = True
|
||||
clone_from = "default"
|
||||
clone_config = False
|
||||
else:
|
||||
clone = body.clone_from_default or body.clone_all
|
||||
clone = body.clone_from_default
|
||||
clone_from = "default" if clone else None
|
||||
clone_config = body.clone_from_default and not body.clone_all
|
||||
clone_config = clone
|
||||
try:
|
||||
path = profiles_mod.create_profile(
|
||||
name=body.name,
|
||||
@@ -9612,11 +9644,10 @@ async def update_config_raw(body: RawConfigUpdate, profile: Optional[str] = None
|
||||
|
||||
|
||||
@app.get("/api/analytics/usage")
|
||||
async def get_usage_analytics(days: int = 30):
|
||||
from hermes_state import SessionDB
|
||||
async def get_usage_analytics(days: int = 30, profile: Optional[str] = None):
|
||||
from agent.insights import InsightsEngine
|
||||
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
cutoff = time.time() - (days * 86400)
|
||||
cur = db._conn.execute("""
|
||||
@@ -9681,15 +9712,13 @@ async def get_usage_analytics(days: int = 30):
|
||||
|
||||
|
||||
@app.get("/api/analytics/models")
|
||||
async def get_models_analytics(days: int = 30):
|
||||
async def get_models_analytics(days: int = 30, profile: Optional[str] = None):
|
||||
"""Rich per-model analytics for the Models dashboard page.
|
||||
|
||||
Returns token/cost/session breakdown per model plus capability metadata
|
||||
from models.dev (context window, vision, tools, reasoning, etc.).
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
db = _open_session_db_for_profile(profile)
|
||||
try:
|
||||
cutoff = time.time() - (days * 86400)
|
||||
|
||||
@@ -9711,7 +9740,71 @@ async def get_models_analytics(days: int = 30):
|
||||
GROUP BY model, billing_provider
|
||||
ORDER BY SUM(input_tokens) + SUM(output_tokens) DESC
|
||||
""", (cutoff,))
|
||||
rows = [dict(r) for r in cur.fetchall()]
|
||||
raw_rows = [dict(r) for r in cur.fetchall()]
|
||||
|
||||
# Session rows can be created before the first billable provider call
|
||||
# finishes. If that early row records only the model name, and a later
|
||||
# row for the same model has real accounting + billing_provider, the
|
||||
# Models page used to show a duplicate "0 tokens / — API calls" card
|
||||
# next to the real provider card. Fold those session-only rows into
|
||||
# the single accounted provider row when the ownership is unambiguous.
|
||||
rows_by_model: Dict[str, List[Dict[str, Any]]] = {}
|
||||
for row in raw_rows:
|
||||
rows_by_model.setdefault(row.get("model") or "", []).append(row)
|
||||
|
||||
rows: List[Dict[str, Any]] = []
|
||||
for model_rows in rows_by_model.values():
|
||||
provider_rows = [r for r in model_rows if r.get("billing_provider")]
|
||||
if len(provider_rows) == 1:
|
||||
target = provider_rows[0]
|
||||
for row in model_rows:
|
||||
if row is target or row.get("billing_provider"):
|
||||
continue
|
||||
has_usage = any(
|
||||
(row.get(key) or 0) != 0
|
||||
for key in (
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"reasoning_tokens",
|
||||
"estimated_cost",
|
||||
"actual_cost",
|
||||
"api_calls",
|
||||
"tool_calls",
|
||||
)
|
||||
)
|
||||
if has_usage:
|
||||
continue
|
||||
target["sessions"] = (target.get("sessions") or 0) + (row.get("sessions") or 0)
|
||||
target["last_used_at"] = max(target.get("last_used_at") or 0, row.get("last_used_at") or 0)
|
||||
total_tokens = (target.get("input_tokens") or 0) + (target.get("output_tokens") or 0)
|
||||
sessions = target.get("sessions") or 0
|
||||
target["avg_tokens_per_session"] = total_tokens / sessions if sessions else 0
|
||||
rows.append(target)
|
||||
rows.extend(
|
||||
r for r in model_rows
|
||||
if r is not target
|
||||
and (r.get("billing_provider") or any(
|
||||
(r.get(key) or 0) != 0
|
||||
for key in (
|
||||
"input_tokens",
|
||||
"output_tokens",
|
||||
"cache_read_tokens",
|
||||
"reasoning_tokens",
|
||||
"estimated_cost",
|
||||
"actual_cost",
|
||||
"api_calls",
|
||||
"tool_calls",
|
||||
)
|
||||
))
|
||||
)
|
||||
else:
|
||||
rows.extend(model_rows)
|
||||
|
||||
rows.sort(
|
||||
key=lambda r: (r.get("input_tokens") or 0) + (r.get("output_tokens") or 0),
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
models = []
|
||||
for row in rows:
|
||||
|
||||
@@ -46,7 +46,7 @@ talks to it over loopback.
|
||||
hermes photon setup --phone +15551234567
|
||||
|
||||
# Start the gateway
|
||||
hermes gateway start --platform photon
|
||||
hermes gateway start
|
||||
```
|
||||
|
||||
`hermes photon setup` does, in order:
|
||||
|
||||
@@ -274,7 +274,7 @@ def _cmd_setup(args: argparse.Namespace) -> int:
|
||||
|
||||
print()
|
||||
print("✓ Photon setup complete.")
|
||||
print(" Start the gateway: hermes gateway start --platform photon")
|
||||
print(" Start the gateway: hermes gateway start")
|
||||
return 0
|
||||
|
||||
|
||||
|
||||
+21
-2
@@ -3246,7 +3246,11 @@ class AIAgent:
|
||||
return sanitize_api_messages(messages)
|
||||
|
||||
@staticmethod
|
||||
def _is_thinking_only_assistant(msg: Dict[str, Any]) -> bool:
|
||||
def _is_thinking_only_assistant(
|
||||
msg: Dict[str, Any],
|
||||
*,
|
||||
drop_codex_reasoning_items: bool = True,
|
||||
) -> bool:
|
||||
"""Return True if ``msg`` is an assistant turn whose only payload is reasoning.
|
||||
|
||||
"Thinking-only" means the model emitted reasoning (``reasoning`` or
|
||||
@@ -3297,15 +3301,30 @@ class AIAgent:
|
||||
rd = msg.get("reasoning_details")
|
||||
if isinstance(rd, list) and rd:
|
||||
return True
|
||||
# Codex Responses stores encrypted reasoning state under a separate
|
||||
# assistant-message key. Treat only real reasoning items as
|
||||
# thinking-only; empty/junk lists should fall through to the generic
|
||||
# empty-turn handling instead of being dropped here.
|
||||
codex_items = msg.get("codex_reasoning_items")
|
||||
if drop_codex_reasoning_items and isinstance(codex_items, list):
|
||||
return any(
|
||||
isinstance(item, dict) and item.get("type") == "reasoning"
|
||||
for item in codex_items
|
||||
)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _drop_thinking_only_and_merge_users(
|
||||
messages: List[Dict[str, Any]],
|
||||
*,
|
||||
drop_codex_reasoning_items: bool = True,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Forwarder — see ``agent.agent_runtime_helpers.drop_thinking_only_and_merge_users``."""
|
||||
from agent.agent_runtime_helpers import drop_thinking_only_and_merge_users
|
||||
return drop_thinking_only_and_merge_users(messages)
|
||||
return drop_thinking_only_and_merge_users(
|
||||
messages,
|
||||
drop_codex_reasoning_items=drop_codex_reasoning_items,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _cap_delegate_task_calls(tool_calls: list) -> list:
|
||||
|
||||
@@ -1171,6 +1171,20 @@ function Install-Repository {
|
||||
# agent-created dirs (e.g. tinker-atropos/) survive too.
|
||||
$statusOut = git -c windows.appendAtomically=false status --porcelain 2>$null
|
||||
if (-not [string]::IsNullOrWhiteSpace(($statusOut -join "`n"))) {
|
||||
# A previously interrupted update can leave the index with
|
||||
# unmerged entries. In that state `git stash` aborts with
|
||||
# "could not write index" and the following `git checkout`
|
||||
# aborts with "you need to resolve your current index first"
|
||||
# -- the GUI "git checkout main failed (exit 1)" install
|
||||
# failure. Clear the conflict markers with `git reset` first:
|
||||
# working-tree changes are kept (and stashed just below); only
|
||||
# the index conflict state is dropped. Mirrors the `hermes
|
||||
# update` path (#4735).
|
||||
$unmergedOut = git -c windows.appendAtomically=false ls-files --unmerged 2>$null
|
||||
if (-not [string]::IsNullOrWhiteSpace(($unmergedOut -join "`n"))) {
|
||||
Write-Info "Clearing unmerged index entries from a previous conflict..."
|
||||
git -c windows.appendAtomically=false reset -q 2>$null
|
||||
}
|
||||
$stashName = "hermes-install-autostash-" + (Get-Date -Format "yyyyMMdd-HHmmss")
|
||||
Write-Info "Local changes detected, stashing before update..."
|
||||
git -c windows.appendAtomically=false stash push --include-untracked -m "$stashName"
|
||||
|
||||
@@ -1111,6 +1111,19 @@ clone_repo() {
|
||||
|
||||
local autostash_ref=""
|
||||
if [ -n "$(git status --porcelain)" ]; then
|
||||
# A previously interrupted update can leave the index with
|
||||
# unmerged entries. In that state `git stash` aborts with
|
||||
# "could not write index" and the later `git checkout` aborts
|
||||
# with "you need to resolve your current index first", failing
|
||||
# the whole install at the repository stage. Clear the conflict
|
||||
# markers with `git reset` first -- this keeps working-tree
|
||||
# changes (they're still stashed just below) and only drops the
|
||||
# index-level conflict state. Mirrors the `hermes update` path
|
||||
# (#4735).
|
||||
if [ -n "$(git ls-files --unmerged)" ]; then
|
||||
log_info "Clearing unmerged index entries from a previous conflict..."
|
||||
git reset -q
|
||||
fi
|
||||
local stash_name
|
||||
stash_name="hermes-install-autostash-$(date -u +%Y%m%d-%H%M%S)"
|
||||
log_info "Local changes detected, stashing before update..."
|
||||
|
||||
@@ -45,8 +45,11 @@ ACP_REGISTRY_MANIFEST = REPO_ROOT / "acp_registry" / "agent.json"
|
||||
|
||||
# Auto-extracted from noreply emails + manual overrides
|
||||
AUTHOR_MAP = {
|
||||
"kenmege@yahoo.com": "Kenmege",
|
||||
"peterhao@Peters-MacBook-Air.local": "pinguarmy",
|
||||
"adalsteinnhelgason@Aalsteinns-MacBook-Pro-3.local": "AIalliAI",
|
||||
"adalsteinnhelgason@users.noreply.github.com": "AIalliAI",
|
||||
"zhang.hz6666@gmail.com": "HaozheZhang6",
|
||||
"barronlroth@gmail.com": "barronlroth",
|
||||
"ondrej.drapalik@gmail.com": "OndrejDrapalik",
|
||||
"tomasz.panek@gmail.com": "tomekpanek",
|
||||
@@ -78,6 +81,10 @@ AUTHOR_MAP = {
|
||||
"290859878+synapsesx@users.noreply.github.com": "synapsesx",
|
||||
"157689911+itsflownium@users.noreply.github.com": "itsflownium",
|
||||
"dirtyren@users.noreply.github.com": "dirtyren",
|
||||
"achaljhawar03@gmail.com": "achaljhawar",
|
||||
"claytonchew@ClaytonMacMiniM4.local": "claytonchew",
|
||||
"hbentel@gmail.com": "hbentel",
|
||||
"JustinBao@outlook.com": "justinbao19",
|
||||
"kdunn926@gmail.com": "kdunn926",
|
||||
"mvanhorn@MacBook-Pro.local": "mvanhorn",
|
||||
"470766206@qq.com": "youjunxiaji",
|
||||
@@ -489,6 +496,7 @@ AUTHOR_MAP = {
|
||||
"20nik.nosov21@gmail.com": "nik1t7n",
|
||||
"90299797+nik1t7n@users.noreply.github.com": "nik1t7n",
|
||||
"suncokret@protonmail.com": "suncokret12",
|
||||
"WompaJango@protonmail.com": "WompaJango",
|
||||
"mio.imoto.ai@gmail.com": "mioimotoai-lgtm",
|
||||
"aamirjawaid@microsoft.com": "heyitsaamir",
|
||||
"johnnncenaaa77@gmail.com": "johnncenae",
|
||||
@@ -984,6 +992,7 @@ AUTHOR_MAP = {
|
||||
"tuancanhnguyen706@gmail.com": "xxxigm",
|
||||
"larcombe.n@gmail.com": "NickLarcombe",
|
||||
"54813621+xxxigm@users.noreply.github.com": "xxxigm",
|
||||
"xxxigm@users.noreply.github.com": "xxxigm",
|
||||
"asurla@nvidia.com": "anniesurla",
|
||||
"kchantharuan@nvidia.com": "nv-kasikritc",
|
||||
"bbednarski@nvidia.com": "bbednarski9",
|
||||
@@ -1522,6 +1531,7 @@ AUTHOR_MAP = {
|
||||
"chanhokyim@gmail.com": "joel611", # PR #33958 salvage (DISCORD_ALLOWED_ROLES role_authorized gateway flag)
|
||||
"desg38@gmail.com": "dschnurbusch", # PR #42373 salvage (archive compressed conversation lineages)
|
||||
"bsmith@bramarstrategicservices.com": "bcsmith528", # PR #20589 salvage (register_slack_action_handler plugin API)
|
||||
"sunsky.lau@gmail.com": "liuhao1024", # PR #45494 salvage (claim session slot before auto-resume task; #45456)
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -1797,6 +1797,11 @@ class TestRegisterSessionMcpServers:
|
||||
state.agent.tools = []
|
||||
state.agent.valid_tool_names = set()
|
||||
state.agent._cached_system_prompt = "old prompt"
|
||||
state.agent._memory_manager = SimpleNamespace(
|
||||
get_all_tool_schemas=lambda: [
|
||||
{"name": "hindsight_recall", "description": "Recall", "parameters": {}}
|
||||
]
|
||||
)
|
||||
|
||||
server = McpServerStdio(
|
||||
name="srv",
|
||||
@@ -1807,6 +1812,7 @@ class TestRegisterSessionMcpServers:
|
||||
|
||||
fake_tools = [
|
||||
{"function": {"name": "mcp_srv_search"}},
|
||||
{"function": {"name": "memory"}},
|
||||
{"function": {"name": "terminal"}},
|
||||
]
|
||||
|
||||
@@ -1820,8 +1826,21 @@ class TestRegisterSessionMcpServers:
|
||||
quiet_mode=True,
|
||||
)
|
||||
assert state.agent.enabled_toolsets == ["hermes-acp", "mcp-srv"]
|
||||
assert state.agent.tools == fake_tools
|
||||
assert state.agent.valid_tool_names == {"mcp_srv_search", "terminal"}
|
||||
assert state.agent.tools is fake_tools
|
||||
assert state.agent.tools[-1] == {
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": "hindsight_recall",
|
||||
"description": "Recall",
|
||||
"parameters": {},
|
||||
},
|
||||
}
|
||||
assert state.agent.valid_tool_names == {
|
||||
"hindsight_recall",
|
||||
"memory",
|
||||
"mcp_srv_search",
|
||||
"terminal",
|
||||
}
|
||||
# _invalidate_system_prompt should have been called
|
||||
state.agent._invalidate_system_prompt.assert_called_once()
|
||||
|
||||
|
||||
@@ -146,6 +146,47 @@ class TestBuildCallKwargsMaxTokens:
|
||||
assert "max_completion_tokens" not in kwargs
|
||||
|
||||
|
||||
class TestNousTagsScoping:
|
||||
def test_tags_injected_when_provider_is_nous(self, monkeypatch):
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
monkeypatch.setattr(aux, "auxiliary_is_nous", False)
|
||||
|
||||
kwargs = aux._build_call_kwargs(
|
||||
provider="nous",
|
||||
model="hermes-4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert kwargs["extra_body"]["tags"] == aux._nous_portal_tags()
|
||||
|
||||
def test_tags_not_injected_for_gemini_when_main_is_nous(self, monkeypatch):
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
monkeypatch.setattr(aux, "auxiliary_is_nous", True)
|
||||
|
||||
kwargs = aux._build_call_kwargs(
|
||||
provider="gemini",
|
||||
model="gemini-2.5-flash",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert "extra_body" not in kwargs
|
||||
|
||||
def test_tags_not_injected_for_openrouter_when_main_is_nous(self, monkeypatch):
|
||||
import agent.auxiliary_client as aux
|
||||
|
||||
monkeypatch.setattr(aux, "auxiliary_is_nous", True)
|
||||
|
||||
kwargs = aux._build_call_kwargs(
|
||||
provider="openrouter",
|
||||
model="openai/gpt-5.4",
|
||||
messages=[{"role": "user", "content": "hi"}],
|
||||
)
|
||||
|
||||
assert "extra_body" not in kwargs
|
||||
|
||||
|
||||
class TestNormalizeAuxProvider:
|
||||
def test_maps_github_copilot_aliases(self):
|
||||
assert _normalize_aux_provider("github") == "copilot"
|
||||
|
||||
@@ -161,6 +161,35 @@ class TestResolveAutoMainFirst:
|
||||
assert mock_resolve.call_args.args[0] == "anthropic"
|
||||
assert mock_resolve.call_args.args[1] == "runtime-model"
|
||||
|
||||
def test_runtime_base_url_passed_for_named_api_key_provider(self):
|
||||
"""Named API-key providers inherit the live session endpoint for aux work."""
|
||||
token_plan_url = "https://token-plan-sgp.xiaomimimo.com/v1"
|
||||
with patch(
|
||||
"agent.auxiliary_client._read_main_provider",
|
||||
return_value="openrouter",
|
||||
), patch(
|
||||
"agent.auxiliary_client._read_main_model", return_value="config-model",
|
||||
), patch(
|
||||
"agent.auxiliary_client.resolve_provider_client"
|
||||
) as mock_resolve:
|
||||
mock_resolve.return_value = (MagicMock(), "mimo-v2.5-pro")
|
||||
|
||||
from agent.auxiliary_client import _resolve_auto
|
||||
|
||||
_resolve_auto(main_runtime={
|
||||
"provider": "xiaomi",
|
||||
"model": "mimo-v2.5-pro",
|
||||
"base_url": token_plan_url,
|
||||
"api_key": "tp-test-key",
|
||||
"api_mode": "chat_completions",
|
||||
})
|
||||
|
||||
assert mock_resolve.call_args.args[0] == "xiaomi"
|
||||
assert mock_resolve.call_args.args[1] == "mimo-v2.5-pro"
|
||||
assert mock_resolve.call_args.kwargs["explicit_base_url"] == token_plan_url
|
||||
assert mock_resolve.call_args.kwargs["explicit_api_key"] == "tp-test-key"
|
||||
assert mock_resolve.call_args.kwargs["api_mode"] == "chat_completions"
|
||||
|
||||
|
||||
# ── Vision — resolve_vision_provider_client ─────────────────────────────────
|
||||
|
||||
|
||||
@@ -605,6 +605,74 @@ class TestBuildConverseKwargs:
|
||||
assert kwargs["inferenceConfig"]["temperature"] == 0.7
|
||||
assert kwargs["inferenceConfig"]["topP"] == 0.9
|
||||
|
||||
def test_omits_sampling_params_for_bedrock_opus_4_7(self):
|
||||
from agent.bedrock_adapter import build_converse_kwargs
|
||||
|
||||
for model_id in (
|
||||
"anthropic.claude-opus-4-7-20260101-v1:0",
|
||||
"us.anthropic.claude-opus-4-7",
|
||||
):
|
||||
kwargs = build_converse_kwargs(
|
||||
model=model_id,
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
)
|
||||
|
||||
assert "temperature" not in kwargs["inferenceConfig"]
|
||||
assert "topP" not in kwargs["inferenceConfig"]
|
||||
|
||||
def test_omits_sampling_params_for_bedrock_opus_4_8_variants(self):
|
||||
from agent.bedrock_adapter import build_converse_kwargs
|
||||
|
||||
for model_id in (
|
||||
"anthropic.claude-opus-4-8-20270101-v1:0",
|
||||
"us.anthropic.claude-opus-4-8",
|
||||
"anthropic.claude-opus-4.8",
|
||||
):
|
||||
kwargs = build_converse_kwargs(
|
||||
model=model_id,
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
temperature=0.5,
|
||||
top_p=0.95,
|
||||
)
|
||||
|
||||
assert "temperature" not in kwargs["inferenceConfig"]
|
||||
assert "topP" not in kwargs["inferenceConfig"]
|
||||
|
||||
def test_keeps_sampling_params_for_bedrock_non_restricted_models(self):
|
||||
from agent.bedrock_adapter import build_converse_kwargs
|
||||
|
||||
for model_id in (
|
||||
"anthropic.claude-sonnet-4-6-20250514-v1:0",
|
||||
"anthropic.claude-haiku-4-5",
|
||||
"test-model",
|
||||
):
|
||||
kwargs = build_converse_kwargs(
|
||||
model=model_id,
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
)
|
||||
|
||||
assert kwargs["inferenceConfig"].get("temperature") == 0.7
|
||||
assert kwargs["inferenceConfig"].get("topP") == 0.9
|
||||
|
||||
def test_bedrock_opus_strips_sampling_params_but_keeps_stop_sequences(self):
|
||||
from agent.bedrock_adapter import build_converse_kwargs
|
||||
|
||||
kwargs = build_converse_kwargs(
|
||||
model="us.anthropic.claude-opus-4-8",
|
||||
messages=[{"role": "user", "content": "Hi"}],
|
||||
temperature=0.7,
|
||||
top_p=0.9,
|
||||
stop_sequences=["END"],
|
||||
)
|
||||
|
||||
assert "temperature" not in kwargs["inferenceConfig"]
|
||||
assert "topP" not in kwargs["inferenceConfig"]
|
||||
assert kwargs["inferenceConfig"]["stopSequences"] == ["END"]
|
||||
|
||||
def test_includes_guardrail_config(self):
|
||||
from agent.bedrock_adapter import build_converse_kwargs
|
||||
guardrail = {
|
||||
|
||||
@@ -198,6 +198,45 @@ def test_native_client_uses_x_goog_api_key_and_native_models_endpoint(monkeypatc
|
||||
assert response.choices[0].message.content == "hello"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("model, expected", [
|
||||
("google/gemini-2.0-flash", "gemini-2.0-flash"),
|
||||
("gemini/gemini-3-pro-preview", "gemini-3-pro-preview"),
|
||||
("Google/Gemini-2.5-Pro", "Gemini-2.5-Pro"),
|
||||
("models/gemini-x", "models/gemini-x"),
|
||||
("tunedModels/my-tune", "tunedModels/my-tune"),
|
||||
])
|
||||
def test_bare_gemini_model_id_strips_only_self_prefix(model, expected):
|
||||
from agent.gemini_native_adapter import bare_gemini_model_id
|
||||
|
||||
assert bare_gemini_model_id(model) == expected
|
||||
|
||||
|
||||
def test_native_client_strips_self_prefix_from_model_url(monkeypatch):
|
||||
from agent.gemini_native_adapter import GeminiNativeClient
|
||||
|
||||
recorded = {}
|
||||
|
||||
class DummyHTTP:
|
||||
def post(self, url, json=None, headers=None, timeout=None):
|
||||
recorded["url"] = url
|
||||
return DummyResponse(payload={
|
||||
"candidates": [{"content": {"parts": [{"text": "ok"}]}, "finishReason": "STOP"}],
|
||||
"usageMetadata": {"promptTokenCount": 1, "candidatesTokenCount": 1, "totalTokenCount": 2},
|
||||
})
|
||||
|
||||
def close(self):
|
||||
return None
|
||||
|
||||
monkeypatch.setattr("agent.gemini_native_adapter.httpx.Client", lambda *a, **k: DummyHTTP())
|
||||
client = GeminiNativeClient(api_key="AIza-test", base_url="https://generativelanguage.googleapis.com/v1beta")
|
||||
client.chat.completions.create(
|
||||
model="google/gemini-2.0-flash",
|
||||
messages=[{"role": "user", "content": "Hello"}],
|
||||
)
|
||||
|
||||
assert recorded["url"] == "https://generativelanguage.googleapis.com/v1beta/models/gemini-2.0-flash:generateContent"
|
||||
|
||||
|
||||
def test_native_http_error_keeps_status_and_retry_after():
|
||||
from agent.gemini_native_adapter import gemini_http_error
|
||||
|
||||
@@ -328,6 +367,25 @@ def test_stream_event_translation_keeps_identical_calls_in_distinct_parts():
|
||||
assert tool_chunks[0].choices[0].delta.tool_calls[0].id != tool_chunks[1].choices[0].delta.tool_calls[0].id
|
||||
|
||||
|
||||
def test_system_instruction_includes_role_field_and_stays_out_of_contents():
|
||||
from agent.gemini_native_adapter import build_gemini_request
|
||||
|
||||
request = build_gemini_request(
|
||||
messages=[
|
||||
{"role": "system", "content": "You are a helpful assistant."},
|
||||
{"role": "user", "content": "Hello"},
|
||||
],
|
||||
tools=[],
|
||||
tool_choice=None,
|
||||
)
|
||||
|
||||
assert request["systemInstruction"] == {
|
||||
"role": "system",
|
||||
"parts": [{"text": "You are a helpful assistant."}],
|
||||
}
|
||||
assert all(content.get("role") != "system" for content in request["contents"])
|
||||
|
||||
|
||||
def test_max_tokens_none_defaults_to_gemini_output_ceiling():
|
||||
"""max_tokens=None must send the model's full output ceiling, not omit it.
|
||||
|
||||
|
||||
@@ -2,10 +2,11 @@
|
||||
|
||||
import json
|
||||
import pytest
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from agent.memory_provider import MemoryProvider
|
||||
from agent.memory_manager import MemoryManager
|
||||
from agent.memory_manager import MemoryManager, inject_memory_provider_tools
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Concrete test provider
|
||||
@@ -1320,38 +1321,25 @@ class TestMemoryToolToolsetGate:
|
||||
causing 10x latency on local models (Qwen3-30B: 1.7s → 42s) and
|
||||
tool-call loops on small models.
|
||||
|
||||
These tests mirror the gate logic in agent/agent_init.py around the
|
||||
memory provider tool injection block. The gate condition is:
|
||||
These tests exercise the shared gate used by agent init and ACP refreshes.
|
||||
The gate condition is:
|
||||
|
||||
enabled_toolsets is None → no filter, inject (backward compat)
|
||||
"memory" in enabled_toolsets → user opted in, inject
|
||||
selected toolsets include memory → user opted in, inject
|
||||
otherwise (incl. []) → skip injection
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _run_memory_injection(enabled_toolsets, memory_manager):
|
||||
"""Simulate the gated memory-tool injection block from agent_init.py."""
|
||||
tools = []
|
||||
valid_tool_names = set()
|
||||
|
||||
if memory_manager and tools is not None and (
|
||||
enabled_toolsets is None or "memory" in enabled_toolsets
|
||||
):
|
||||
_existing = {
|
||||
t.get("function", {}).get("name")
|
||||
for t in tools
|
||||
if isinstance(t, dict)
|
||||
}
|
||||
for _schema in memory_manager.get_all_tool_schemas():
|
||||
_tname = _schema.get("name", "")
|
||||
if _tname and _tname in _existing:
|
||||
continue
|
||||
tools.append({"type": "function", "function": _schema})
|
||||
if _tname:
|
||||
valid_tool_names.add(_tname)
|
||||
_existing.add(_tname)
|
||||
|
||||
return tools, valid_tool_names
|
||||
"""Run the shared memory-tool injection helper against a fake agent."""
|
||||
fake_agent = SimpleNamespace(
|
||||
_memory_manager=memory_manager,
|
||||
enabled_toolsets=enabled_toolsets,
|
||||
tools=[],
|
||||
valid_tool_names=set(),
|
||||
)
|
||||
inject_memory_provider_tools(fake_agent)
|
||||
return fake_agent.tools, fake_agent.valid_tool_names
|
||||
|
||||
def _mgr_with_tools(self, *tool_names):
|
||||
"""Build a MemoryManager whose providers expose the named tool schemas."""
|
||||
@@ -1376,6 +1364,13 @@ class TestMemoryToolToolsetGate:
|
||||
tools, names = self._run_memory_injection(["terminal", "memory", "web"], mgr)
|
||||
assert "fact_store" in names
|
||||
|
||||
def test_composite_toolset_with_memory_injects(self):
|
||||
"""Composite toolsets that include memory should inject provider tools."""
|
||||
mgr = self._mgr_with_tools("hindsight_recall")
|
||||
tools, names = self._run_memory_injection(["hermes-acp"], mgr)
|
||||
assert "hindsight_recall" in names
|
||||
assert any(t["function"]["name"] == "hindsight_recall" for t in tools)
|
||||
|
||||
def test_empty_toolsets_blocks_injection(self):
|
||||
"""`platform_toolsets: telegram: []` must suppress memory tools. (#5544)"""
|
||||
mgr = self._mgr_with_tools("fact_store")
|
||||
@@ -1384,7 +1379,7 @@ class TestMemoryToolToolsetGate:
|
||||
assert names == set()
|
||||
|
||||
def test_toolsets_without_memory_blocks_injection(self):
|
||||
"""Toolset list that doesn't name 'memory' must suppress injection."""
|
||||
"""Toolsets that don't include memory must suppress injection."""
|
||||
mgr = self._mgr_with_tools("fact_store")
|
||||
tools, names = self._run_memory_injection(["terminal", "web"], mgr)
|
||||
assert tools == []
|
||||
|
||||
@@ -397,3 +397,52 @@ class TestEnumNullStripping:
|
||||
assert db_type["type"] == "string"
|
||||
assert db_type["enum"] == ["mysql", "postgresql"], \
|
||||
"null/empty enum values must be stripped after anyOf collapse"
|
||||
|
||||
|
||||
class TestUnionTypeList:
|
||||
"""Moonshot sanitizer accepts JSON Schema union type arrays."""
|
||||
|
||||
def test_union_type_list_normalizes_to_first_concrete_type(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"limit": {
|
||||
"type": ["number", "string"],
|
||||
"description": "Max results",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
assert out["properties"]["limit"]["type"] == "number"
|
||||
|
||||
def test_union_type_list_skips_null_type(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"name": {"type": ["null", "string"]},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
assert out["properties"]["name"]["type"] == "string"
|
||||
|
||||
def test_union_type_list_with_enum_does_not_crash_or_mutate_input(self):
|
||||
params = {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"sort": {
|
||||
"type": ["string", "null"],
|
||||
"enum": ["asc", "desc", None, ""],
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
out = sanitize_moonshot_tool_parameters(params)
|
||||
|
||||
sort = out["properties"]["sort"]
|
||||
assert sort["type"] == "string"
|
||||
assert sort["enum"] == ["asc", "desc"]
|
||||
assert params["properties"]["sort"]["type"] == ["string", "null"]
|
||||
|
||||
@@ -877,6 +877,23 @@ class TestPromptBuilderConstants:
|
||||
# check that this test is calibrated correctly).
|
||||
assert "include MEDIA:" in PLATFORM_HINTS["telegram"]
|
||||
|
||||
def test_telegram_hint_encourages_rich_markdown(self):
|
||||
# Telegram Bot API 10.1 rich messages are default-on, so the hint must
|
||||
# encourage native structured markdown instead of forbidding tables.
|
||||
hint = PLATFORM_HINTS["telegram"]
|
||||
lowered = hint.lower()
|
||||
assert "Telegram has NO table syntax" not in hint
|
||||
assert "rich markdown" in lowered
|
||||
assert "table" in lowered
|
||||
assert "task list" in lowered
|
||||
assert "math" in lowered
|
||||
# Hint should proactively steer toward structured formatting, not just
|
||||
# permit it: bullet + numbered lists for scannable, structured output.
|
||||
assert "bullet" in lowered
|
||||
assert "numbered" in lowered
|
||||
# Local media delivery guidance must remain intact.
|
||||
assert "include MEDIA:" in hint
|
||||
|
||||
def test_platform_hints_mattermost(self):
|
||||
hint = PLATFORM_HINTS["mattermost"]
|
||||
assert "Mattermost" in hint
|
||||
|
||||
@@ -155,6 +155,46 @@ class TestCodexBuildKwargs:
|
||||
)
|
||||
assert "max_output_tokens" not in kw
|
||||
|
||||
def test_codex_backend_does_not_set_extra_headers(self, transport):
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
|
||||
kw = transport.build_kwargs(
|
||||
model="gpt-5.4",
|
||||
messages=messages,
|
||||
tools=[],
|
||||
session_id="conv-codex-1",
|
||||
is_codex_backend=True,
|
||||
)
|
||||
|
||||
assert "extra_headers" not in kw
|
||||
|
||||
def test_codex_backend_strips_caller_extra_headers(self, transport):
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
|
||||
kw = transport.build_kwargs(
|
||||
model="gpt-5.4",
|
||||
messages=messages,
|
||||
tools=[],
|
||||
session_id="conv-codex-1",
|
||||
is_codex_backend=True,
|
||||
request_overrides={"extra_headers": {"x-test": "1"}},
|
||||
)
|
||||
|
||||
assert "extra_headers" not in kw
|
||||
|
||||
def test_non_codex_responses_preserves_caller_extra_headers(self, transport):
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
|
||||
kw = transport.build_kwargs(
|
||||
model="gpt-5.4",
|
||||
messages=messages,
|
||||
tools=[],
|
||||
is_codex_backend=False,
|
||||
request_overrides={"extra_headers": {"x-test": "1"}},
|
||||
)
|
||||
|
||||
assert kw["extra_headers"] == {"x-test": "1"}
|
||||
|
||||
def test_xai_headers(self, transport):
|
||||
messages = [{"role": "user", "content": "Hi"}]
|
||||
kw = transport.build_kwargs(
|
||||
|
||||
@@ -71,18 +71,14 @@ class TestForceFullRedraw:
|
||||
"invalidate",
|
||||
]
|
||||
|
||||
def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
|
||||
"""Resize recovery must NOT erase screen or scrollback.
|
||||
def test_resize_recovery_uses_prompt_toolkit_original_resize_before_reset(self, bare_cli, monkeypatch):
|
||||
"""Resize recovery must preserve prompt_toolkit's tracked cursor state.
|
||||
|
||||
The startup banner lives in normal terminal scrollback (printed
|
||||
before prompt_toolkit owns the chrome). Clearing scrollback on
|
||||
SIGWINCH removes it and ``_replay_output_history`` cannot
|
||||
reconstruct it. The fix is to only reset the renderer cache and
|
||||
let ``original_on_resize`` recalculate layout.
|
||||
|
||||
Additionally, ``_status_bar_suppressed_after_resize`` must be set
|
||||
so the input rules and status bar hide until the next user input,
|
||||
preventing duplicated-bar artifacts on column shrink (#19280).
|
||||
prompt_toolkit's built-in Application._on_resize() starts with
|
||||
renderer.erase(leave_alternate_screen=False), which uses the renderer's
|
||||
cached cursor position to move back to the live prompt origin before
|
||||
erase_down(). If Hermes resets the renderer first, that cursor position
|
||||
is lost and stale prompt glyphs can remain after a narrow resize.
|
||||
"""
|
||||
app = MagicMock()
|
||||
events = []
|
||||
@@ -94,11 +90,9 @@ class TestForceFullRedraw:
|
||||
bare_cli._status_bar_suppressed_after_resize = False
|
||||
bare_cli._recover_after_resize(app, original_on_resize)
|
||||
|
||||
assert events == [
|
||||
"renderer_reset",
|
||||
"invalidate",
|
||||
"original_resize",
|
||||
]
|
||||
assert events == ["original_resize"]
|
||||
app.renderer.reset.assert_not_called()
|
||||
app.invalidate.assert_not_called()
|
||||
# Must NOT clear the screen or scrollback — those destroy the banner.
|
||||
app.renderer.output.erase_screen.assert_not_called()
|
||||
app.renderer.output.write_raw.assert_not_called()
|
||||
|
||||
@@ -3,6 +3,7 @@ from datetime import datetime, timedelta
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import cli as cli_mod
|
||||
from cli import HermesCLI
|
||||
|
||||
|
||||
@@ -104,91 +105,24 @@ class TestCLIStatusBar:
|
||||
assert "-1" not in text
|
||||
assert "0/200K" in text
|
||||
|
||||
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
|
||||
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
|
||||
# is inserted by BeforeInput only on logical line 0. At three terminal
|
||||
# cells, "⚔ " leaves one cell for the first input character, but
|
||||
# wrapped continuation rows use the full three cells. Estimating every
|
||||
# wrapped row as one-cell wide over-allocates the TextArea and can leave
|
||||
# stale prompt/input cells visible after resize.
|
||||
assert cli_mod._estimate_tui_input_height(["abcdef"], "⚔ ", 3) == 3
|
||||
|
||||
def test_input_height_counts_wide_characters_using_cell_width(self):
|
||||
cli_obj = _make_cli()
|
||||
# Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
|
||||
# which wraps to two rows at 14 terminal columns.
|
||||
assert cli_mod._estimate_tui_input_height(["你" * 10], "❯ ", 14) == 2
|
||||
|
||||
class _Doc:
|
||||
lines = ["你" * 10]
|
||||
|
||||
class _Buffer:
|
||||
document = _Doc()
|
||||
|
||||
input_area = SimpleNamespace(buffer=_Buffer())
|
||||
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
import shutil
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width))
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
||||
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
||||
patch("prompt_toolkit.application.get_app", return_value=mock_app):
|
||||
assert _input_height() == 2
|
||||
|
||||
def test_input_height_uses_prompt_toolkit_width_over_shutil(self):
|
||||
cli_obj = _make_cli()
|
||||
|
||||
class _Doc:
|
||||
lines = ["你" * 10]
|
||||
|
||||
class _Buffer:
|
||||
document = _Doc()
|
||||
|
||||
input_area = SimpleNamespace(buffer=_Buffer())
|
||||
|
||||
def _input_height():
|
||||
try:
|
||||
from prompt_toolkit.application import get_app
|
||||
from prompt_toolkit.utils import get_cwidth
|
||||
|
||||
doc = input_area.buffer.document
|
||||
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
|
||||
try:
|
||||
available_width = get_app().output.get_size().columns - prompt_width
|
||||
except Exception:
|
||||
import shutil
|
||||
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
|
||||
if available_width < 10:
|
||||
available_width = 40
|
||||
visual_lines = 0
|
||||
for line in doc.lines:
|
||||
line_width = get_cwidth(line)
|
||||
if line_width <= 0:
|
||||
visual_lines += 1
|
||||
else:
|
||||
visual_lines += max(1, -(-line_width // available_width))
|
||||
return min(max(visual_lines, 1), 8)
|
||||
except Exception:
|
||||
return 1
|
||||
|
||||
mock_app = MagicMock()
|
||||
mock_app.output.get_size.return_value = MagicMock(columns=14)
|
||||
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
|
||||
patch("prompt_toolkit.application.get_app", return_value=mock_app), \
|
||||
patch("shutil.get_terminal_size") as mock_shutil:
|
||||
assert _input_height() == 2
|
||||
mock_shutil.assert_not_called()
|
||||
def test_input_height_clamps_zero_width_to_one_cell(self):
|
||||
# Some terminals briefly report zero columns during resize. Treat that
|
||||
# as a one-cell terminal rather than falling back to a fake wide width.
|
||||
assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4
|
||||
|
||||
def test_build_status_bar_text_no_cost_in_status_bar(self):
|
||||
cli_obj = _attach_agent(
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
"""Regression tests for #35809 — compression-exhaustion auto-reset loop.
|
||||
|
||||
After compression is exhausted the gateway auto-resets the session so the
|
||||
next message starts on a fresh, empty conversation (#9893 / #10063). That
|
||||
guarantee regressed once the Telegram topic-binding heal landed
|
||||
(#20470 / #29712 / #33414):
|
||||
|
||||
1. Compression rotates ``session_entry.session_id`` to an oversized
|
||||
compressed *child* session mid-turn and the agent-result sync rewrites
|
||||
the ``(chat_id, thread_id) -> child`` topic binding.
|
||||
2. ``reset_session`` swaps in a clean, parentless session — but its return
|
||||
value was discarded and the topic binding was left pointing at the
|
||||
bloated child.
|
||||
3. On the next inbound message in that topic, the binding-heal walk
|
||||
``switch_session``'d the freshly-reset lane *back* onto the bloated
|
||||
child, ``load_transcript`` reloaded the oversized transcript, and
|
||||
compression exhaustion re-fired — a new session id every loop.
|
||||
|
||||
The fix captures the fresh entry from ``reset_session`` and re-syncs the
|
||||
topic binding to it (a no-op on non-topic lanes).
|
||||
|
||||
Two tests:
|
||||
|
||||
* ``TestAutoResetBlockReSyncsBinding`` — an AST invariant on
|
||||
``gateway/run.py`` (mirrors ``test_compression_session_id_persistence.py``):
|
||||
the compression-exhausted auto-reset block must capture
|
||||
``reset_session(...)`` and call ``_sync_telegram_topic_binding`` afterward.
|
||||
This is the load-bearing regression pin.
|
||||
* ``TestAutoResetLoadsCleanContext`` — a behavioral contract on the real
|
||||
``SessionStore``: after ``reset_session`` the next turn loads an EMPTY
|
||||
transcript for the new session_id, never the bloated child's transcript.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import ast
|
||||
import inspect
|
||||
|
||||
from gateway import run as gateway_run
|
||||
from gateway.config import GatewayConfig, Platform
|
||||
from gateway.session import SessionSource, SessionStore
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# AST invariant: the auto-reset block re-syncs the topic binding
|
||||
# ---------------------------------------------------------------------------
|
||||
def _find_compression_exhausted_reset_block() -> ast.If:
|
||||
"""Return the ``if agent_result.get('compression_exhausted') ...`` block."""
|
||||
tree = ast.parse(inspect.getsource(gateway_run))
|
||||
|
||||
for node in ast.walk(tree):
|
||||
if not isinstance(node, ast.If):
|
||||
continue
|
||||
consts = [
|
||||
n.value
|
||||
for n in ast.walk(node.test)
|
||||
if isinstance(n, ast.Constant) and isinstance(n.value, str)
|
||||
]
|
||||
# Identify the auto-reset branch by the literal passed to .get(...).
|
||||
if "compression_exhausted" in consts:
|
||||
# Only the branch that actually performs the reset, not the
|
||||
# earlier classifier that merely reads the flag into a bool.
|
||||
calls = {
|
||||
sub.func.attr
|
||||
for sub in ast.walk(node)
|
||||
if isinstance(sub, ast.Call) and isinstance(sub.func, ast.Attribute)
|
||||
}
|
||||
if "reset_session" in calls:
|
||||
return node
|
||||
raise AssertionError(
|
||||
"Could not locate the compression-exhausted auto-reset block "
|
||||
"(if agent_result.get('compression_exhausted') ... reset_session) "
|
||||
"in gateway/run.py — the structure changed or the AST walker is stale."
|
||||
)
|
||||
|
||||
|
||||
class TestAutoResetBlockReSyncsBinding:
|
||||
def test_reset_session_return_is_captured(self):
|
||||
"""``reset_session`` must be assigned, not called-and-discarded —
|
||||
the fresh entry is needed to re-point the binding and drop the stale
|
||||
reference to the bloated compressed child (#35809)."""
|
||||
block = _find_compression_exhausted_reset_block()
|
||||
captured = False
|
||||
for stmt in ast.walk(block):
|
||||
if isinstance(stmt, ast.Assign):
|
||||
val = stmt.value
|
||||
if (
|
||||
isinstance(val, ast.Call)
|
||||
and isinstance(val.func, ast.Attribute)
|
||||
and val.func.attr == "reset_session"
|
||||
):
|
||||
captured = True
|
||||
assert captured, (
|
||||
"gateway/run.py auto-reset block calls reset_session() but discards "
|
||||
"its return value. The fresh SessionEntry must be captured so the "
|
||||
"topic binding can be re-pointed at it; otherwise the next message "
|
||||
"resolves back to the bloated compressed child (#35809)."
|
||||
)
|
||||
|
||||
def test_topic_binding_is_resynced_after_reset(self):
|
||||
"""The block must re-sync the topic binding so the next inbound message
|
||||
cannot ``switch_session`` back onto the bloated compressed child."""
|
||||
block = _find_compression_exhausted_reset_block()
|
||||
sync_calls = [
|
||||
sub
|
||||
for sub in ast.walk(block)
|
||||
if isinstance(sub, ast.Call)
|
||||
and isinstance(sub.func, ast.Attribute)
|
||||
and sub.func.attr == "_sync_telegram_topic_binding"
|
||||
]
|
||||
assert sync_calls, (
|
||||
"gateway/run.py auto-reset block does not call "
|
||||
"_sync_telegram_topic_binding after reset_session. Without it the "
|
||||
"(chat_id, thread_id) -> bloated-child binding survives the reset "
|
||||
"and the binding-heal walk re-anchors the fresh lane onto the "
|
||||
"oversized compressed transcript, re-triggering the loop (#35809)."
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Behavioral contract: reset yields a clean next-turn transcript
|
||||
# ---------------------------------------------------------------------------
|
||||
def _make_store(tmp_path):
|
||||
store = SessionStore(sessions_dir=tmp_path, config=GatewayConfig())
|
||||
# Isolate the SQLite transcript store so we exercise per-session_id
|
||||
# transcripts without touching the developer's real state.db.
|
||||
store._db = SessionDB(db_path=tmp_path / "state.db")
|
||||
return store
|
||||
|
||||
|
||||
def _make_source():
|
||||
return SessionSource(platform=Platform.TELEGRAM, chat_id="123", user_id="u1")
|
||||
|
||||
|
||||
def _bloat(n):
|
||||
# Stand-in for the oversized, post-compression "child" transcript that
|
||||
# could not be compressed any further (#35809).
|
||||
return [{"role": "user", "content": "x" * 2000} for _ in range(n)]
|
||||
|
||||
|
||||
class TestAutoResetLoadsCleanContext:
|
||||
"""#35809: after the gateway auto-resets a session because compression
|
||||
was exhausted, the NEXT turn must load an EMPTY transcript for the new
|
||||
session_id — never the bloated compressed-child transcript."""
|
||||
|
||||
def test_next_turn_transcript_is_empty_after_auto_reset(self, tmp_path):
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
|
||||
entry = store.get_or_create_session(source)
|
||||
session_key = entry.session_key
|
||||
bloated_sid = entry.session_id
|
||||
store._db.create_session(
|
||||
session_id=bloated_sid, source="telegram", user_id="u1"
|
||||
)
|
||||
store._db.replace_messages(bloated_sid, _bloat(120))
|
||||
assert len(store.load_transcript(bloated_sid)) == 120 # precondition
|
||||
|
||||
new_entry = store.reset_session(session_key)
|
||||
assert new_entry is not None
|
||||
assert new_entry.session_id != bloated_sid
|
||||
|
||||
resolved = store.get_or_create_session(source)
|
||||
assert resolved.session_id == new_entry.session_id
|
||||
loaded = store.load_transcript(resolved.session_id)
|
||||
|
||||
assert loaded == [], (
|
||||
f"Auto-reset must yield an empty context, got {len(loaded)} "
|
||||
f"messages — the bloated compressed child leaked into the new session."
|
||||
)
|
||||
# The old transcript is still searchable, not destroyed.
|
||||
assert len(store.load_transcript(bloated_sid)) == 120
|
||||
|
||||
def test_clean_context_survives_gateway_restart(self, tmp_path):
|
||||
"""The fresh, empty session must still be the one loaded after a
|
||||
gateway restart (sessions.json + state.db round-trip)."""
|
||||
store = _make_store(tmp_path)
|
||||
source = _make_source()
|
||||
entry = store.get_or_create_session(source)
|
||||
bloated_sid = entry.session_id
|
||||
store._db.create_session(
|
||||
session_id=bloated_sid, source="telegram", user_id="u1"
|
||||
)
|
||||
store._db.replace_messages(bloated_sid, _bloat(120))
|
||||
|
||||
new_entry = store.reset_session(entry.session_key)
|
||||
new_sid = new_entry.session_id
|
||||
|
||||
# Simulate restart: drop in-memory index, reload from disk.
|
||||
store._loaded = False
|
||||
store._entries.clear()
|
||||
|
||||
reloaded = store.get_or_create_session(source)
|
||||
assert reloaded.session_id == new_sid
|
||||
assert store.load_transcript(reloaded.session_id) == []
|
||||
@@ -0,0 +1,160 @@
|
||||
import asyncio
|
||||
import sys
|
||||
import threading
|
||||
import types
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import AsyncMock, MagicMock
|
||||
|
||||
import gateway.run as gateway_run
|
||||
from gateway.config import Platform
|
||||
from gateway.session import SessionSource
|
||||
|
||||
|
||||
SESSION_KEY = "agent:main:telegram:dm:12345"
|
||||
|
||||
|
||||
class _SessionStore:
|
||||
def __init__(self):
|
||||
self.entry = SimpleNamespace(
|
||||
session_key=SESSION_KEY,
|
||||
session_id="session-before-compression",
|
||||
)
|
||||
self._entries = {SESSION_KEY: self.entry}
|
||||
self.save_calls = 0
|
||||
|
||||
def _save(self):
|
||||
self.save_calls += 1
|
||||
|
||||
|
||||
class _CompressionThenFailureAgent:
|
||||
def __init__(self, **kwargs):
|
||||
self.session_id = kwargs["session_id"]
|
||||
self.model = kwargs["model"]
|
||||
self.tools = []
|
||||
self.context_compressor = SimpleNamespace(
|
||||
last_prompt_tokens=4321,
|
||||
context_length=200000,
|
||||
)
|
||||
self.session_prompt_tokens = 4321
|
||||
self.session_completion_tokens = 0
|
||||
|
||||
def run_conversation(self, user_message, conversation_history=None, task_id=None, **_kwargs):
|
||||
self.session_id = "session-after-compression"
|
||||
return {
|
||||
"failed": True,
|
||||
"error": "APIConnectionError: Codex auxiliary Responses stream exceeded 120.0s total timeout",
|
||||
"messages": [
|
||||
{"role": "user", "content": "[compressed summary]"},
|
||||
{"role": "user", "content": user_message},
|
||||
],
|
||||
"api_calls": 1,
|
||||
}
|
||||
|
||||
def interrupt(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
|
||||
class _StreamConsumer:
|
||||
final_response_sent = False
|
||||
|
||||
def __init__(self, *_args, **_kwargs):
|
||||
pass
|
||||
|
||||
async def run(self):
|
||||
return None
|
||||
|
||||
def finish(self):
|
||||
pass
|
||||
|
||||
|
||||
class _Adapter:
|
||||
SUPPORTS_MESSAGE_EDITING = True
|
||||
_pending_messages = {}
|
||||
|
||||
def get_pending_message(self, _session_key):
|
||||
return None
|
||||
|
||||
async def send_typing(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
async def stop_typing(self, *_args, **_kwargs):
|
||||
return None
|
||||
|
||||
|
||||
def _runner(session_store):
|
||||
runner = object.__new__(gateway_run.GatewayRunner)
|
||||
runner.adapters = {Platform.TELEGRAM: _Adapter()}
|
||||
runner.config = SimpleNamespace(streaming=None, group_sessions_per_user=True, thread_sessions_per_user=False)
|
||||
runner.hooks = SimpleNamespace(loaded_hooks=False, emit=AsyncMock())
|
||||
runner.session_store = session_store
|
||||
runner._session_db = MagicMock()
|
||||
runner._session_db.get_telegram_topic_binding_by_session.return_value = None
|
||||
runner._agent_cache = {}
|
||||
runner._agent_cache_lock = threading.Lock()
|
||||
runner._running_agents = {}
|
||||
runner._running_agents_ts = {}
|
||||
runner._session_run_generation = {}
|
||||
runner._session_model_overrides = {}
|
||||
runner._pending_model_notes = {}
|
||||
runner._pending_skills_reload_notes = {}
|
||||
runner._prefill_messages = []
|
||||
runner._ephemeral_system_prompt = ""
|
||||
runner._reasoning_config = None
|
||||
runner._provider_routing = {}
|
||||
runner._fallback_model = None
|
||||
runner._draining = False
|
||||
runner._get_proxy_url = lambda: None
|
||||
runner._resolve_session_agent_runtime = lambda **_kwargs: (
|
||||
"gpt-5.4",
|
||||
{"provider": "openai-codex", "api_mode": "codex_responses", "base_url": "https://chatgpt.com/backend-api/codex", "api_key": "token"},
|
||||
)
|
||||
runner._resolve_session_reasoning_config = lambda **_kwargs: None
|
||||
runner._resolve_turn_agent_config = lambda message, model, runtime: {"model": model, "runtime": runtime}
|
||||
runner._load_service_tier = lambda: None
|
||||
runner._agent_config_signature = lambda *_args, **_kwargs: ("sig",)
|
||||
runner._extract_cache_busting_config = lambda _config: ()
|
||||
runner._thread_metadata_for_source = lambda *_args, **_kwargs: None
|
||||
runner._sync_telegram_topic_binding = MagicMock()
|
||||
runner._release_running_agent_state = MagicMock()
|
||||
return runner
|
||||
|
||||
|
||||
def test_failed_turn_still_syncs_compression_session_split(monkeypatch):
|
||||
fake_run_agent = types.ModuleType("run_agent")
|
||||
fake_run_agent.AIAgent = _CompressionThenFailureAgent
|
||||
monkeypatch.setitem(sys.modules, "run_agent", fake_run_agent)
|
||||
monkeypatch.setenv("HERMES_TOOL_PROGRESS_MODE", "off")
|
||||
monkeypatch.setenv("HERMES_AGENT_TIMEOUT", "0")
|
||||
monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {})
|
||||
monkeypatch.setattr("gateway.stream_consumer.GatewayStreamConsumer", _StreamConsumer)
|
||||
|
||||
import hermes_cli.tools_config as tools_config
|
||||
|
||||
monkeypatch.setattr(tools_config, "_get_platform_tools", lambda *_args, **_kwargs: {"core"})
|
||||
|
||||
session_store = _SessionStore()
|
||||
runner = _runner(session_store)
|
||||
source = SessionSource(platform=Platform.TELEGRAM, chat_id="12345", chat_type="dm", user_id="user-1")
|
||||
|
||||
result = asyncio.run(
|
||||
asyncio.wait_for(
|
||||
runner._run_agent(
|
||||
message="continue",
|
||||
context_prompt="",
|
||||
history=[{"role": "user", "content": "old question"}],
|
||||
source=source,
|
||||
session_id="session-before-compression",
|
||||
session_key=SESSION_KEY,
|
||||
),
|
||||
timeout=2,
|
||||
)
|
||||
)
|
||||
|
||||
assert result["failed"] is True
|
||||
assert result["session_id"] == "session-after-compression"
|
||||
assert result["history_offset"] == 0
|
||||
assert session_store.entry.session_id == "session-after-compression"
|
||||
assert session_store.save_calls == 1
|
||||
runner._sync_telegram_topic_binding.assert_called_once_with(
|
||||
source, session_store.entry, reason="agent-run-compression"
|
||||
)
|
||||
@@ -8,16 +8,21 @@ a message is dropped inside the adapter and never reaches the gateway unless it
|
||||
already passed that policy.
|
||||
|
||||
The gateway's env-based allowlist check (``_is_user_authorized``) runs *after*
|
||||
the adapter. Before the fix it fell through to an env-only default-deny when no
|
||||
``PLATFORM_ALLOWED_USERS`` env var was set, silently rejecting ``dm_policy:
|
||||
open`` and config-only allowlists even though the adapter had already
|
||||
authorized the sender.
|
||||
the adapter. Adapters that own their access policy declare
|
||||
``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property, default
|
||||
``False``) so the gateway can honor a config-only ``dm_policy: allowlist`` /
|
||||
``allow_from`` (which the adapter already enforced) instead of double-denying it
|
||||
when no ``PLATFORM_ALLOWED_USERS`` env var is set.
|
||||
|
||||
The fix is a single drift-proof contract: adapters that own their access policy
|
||||
declare ``enforces_own_access_policy`` (a ``BasePlatformAdapter`` property,
|
||||
default ``False``). The gateway trusts that flag and skips the env-only
|
||||
default-deny for those platforms, rather than re-implementing each adapter's
|
||||
policy logic a second time.
|
||||
Crucially, the flag is NOT a blanket "already authorized" pass. These adapters
|
||||
default ``dm_policy`` / ``group_policy`` to ``"open"``, which forwards *every*
|
||||
sender, so the gateway trusts the adapter only when its effective policy for the
|
||||
chat type is an actual ``"allowlist"`` restriction. Trusting ``"open"`` here
|
||||
admitted the whole external network with no operator-configured allowlist — the
|
||||
fail-open SECURITY.md §2.6 forbids for network-exposed adapters ("an allowlist
|
||||
is required for every enabled network-exposed adapter ... code paths that fail
|
||||
open when no allowlist is configured are code bugs"). Open access requires an
|
||||
explicit ``{PLATFORM}_ALLOW_ALL_USERS`` / ``GATEWAY_ALLOW_ALL_USERS`` opt-in.
|
||||
"""
|
||||
|
||||
from types import SimpleNamespace
|
||||
@@ -128,15 +133,16 @@ def test_own_policy_adapters_declare_the_flag(module_path, class_name):
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_platform_authorized_without_env_allowlist(monkeypatch, platform):
|
||||
"""A message reaching the gateway from an own-policy adapter is trusted.
|
||||
def test_own_policy_allowlist_authorized_without_env_allowlist(monkeypatch, platform):
|
||||
"""A config-only ``dm_policy: allowlist`` is trusted without an env allowlist.
|
||||
|
||||
With no env allowlist set, the gateway must NOT default-deny — the adapter
|
||||
already authorized the sender at intake (e.g. ``dm_policy: open``).
|
||||
The adapter only forwards an allowlisted sender under ``allowlist`` policy,
|
||||
so a message reaching the gateway *was* authorized for this specific sender.
|
||||
The gateway must honor that instead of double-denying (the #34515 case).
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "allowlist"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
@@ -144,15 +150,104 @@ def test_own_policy_platform_authorized_without_env_allowlist(monkeypatch, platf
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_platform_authorized_for_group_chat(monkeypatch, platform):
|
||||
"""Group traffic from an own-policy adapter is trusted the same way."""
|
||||
def test_own_policy_open_dm_not_authorized_without_allowlist(monkeypatch, platform):
|
||||
"""``dm_policy: open`` forwards everyone → NOT authorization (SECURITY.md §2.6).
|
||||
|
||||
With no env allowlist and no per-platform allow-all flag, an own-policy
|
||||
adapter running ``open`` (the default) must NOT fail open: the gateway falls
|
||||
through to default-deny so the whole external network can't reach the agent.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"dm_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform)) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_default_open_dm_is_fail_closed(monkeypatch, platform):
|
||||
"""The adapters' *default* ``open`` policy (no config at all) fails closed.
|
||||
|
||||
Operators who enable an own-policy adapter with only credentials get
|
||||
``dm_policy = "open"`` resolved on the live adapter. Simulate that resolved
|
||||
state (empty config.extra, adapter ``_dm_policy = "open"``) and confirm the
|
||||
gateway denies — the do-nothing default must not be open to the world.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(platforms={platform: PlatformConfig(enabled=True, extra={})})
|
||||
runner, adapter = _make_runner(platform, config, enforces=True)
|
||||
adapter._dm_policy = "open" # as the live adapter resolves the default
|
||||
|
||||
assert runner._is_user_authorized(_source(platform)) is False
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_allowlist_authorized_for_group_chat(monkeypatch, platform):
|
||||
"""A config-only ``group_policy: allowlist`` is trusted for group traffic."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "allowlist"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform, chat_type="group")) is True
|
||||
|
||||
|
||||
@pytest.mark.parametrize("platform", _OWN_POLICY_PLATFORMS)
|
||||
def test_own_policy_open_group_not_authorized_without_allowlist(monkeypatch, platform):
|
||||
"""``group_policy: open`` is the same fail-open class as DM open → deny."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={platform: PlatformConfig(enabled=True, extra={"group_policy": "open"})}
|
||||
)
|
||||
runner, _adapter = _make_runner(platform, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(platform, chat_type="group")) is True
|
||||
assert runner._is_user_authorized(_source(platform, chat_type="group")) is False
|
||||
|
||||
|
||||
def test_wecom_open_group_with_per_group_sender_allowlist_is_authorized(monkeypatch):
|
||||
"""WeCom ``groups.<id>.allow_from`` is an adapter-enforced restriction.
|
||||
|
||||
The top-level group policy is still ``open`` for the chat ID, but the
|
||||
adapter has already checked the sender allowlist before dispatching to the
|
||||
gateway. That is not the fail-open case and must not be double-denied.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.WECOM: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"group_policy": "open",
|
||||
"groups": {"some-chat": {"allow_from": ["some-user"]}},
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(Platform.WECOM, chat_type="group")) is True
|
||||
|
||||
|
||||
def test_wecom_open_group_with_wildcard_sender_allowlist_is_authorized(monkeypatch):
|
||||
"""Wildcard group config also gates senders before gateway auth runs."""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.WECOM: PlatformConfig(
|
||||
enabled=True,
|
||||
extra={
|
||||
"group_policy": "open",
|
||||
"groups": {"*": {"allow_from": ["user_admin"]}},
|
||||
},
|
||||
)
|
||||
}
|
||||
)
|
||||
runner, _adapter = _make_runner(Platform.WECOM, config, enforces=True)
|
||||
|
||||
assert runner._is_user_authorized(_source(Platform.WECOM, chat_type="group")) is True
|
||||
|
||||
|
||||
def test_non_owning_platform_still_default_denies(monkeypatch):
|
||||
@@ -259,12 +354,16 @@ def test_pairing_carveout_reads_adapter_when_env_set(monkeypatch):
|
||||
|
||||
|
||||
def test_pairing_dm_policy_group_chat_still_trusted(monkeypatch):
|
||||
"""Pairing is DM-only — group traffic keeps the adapter-trust path."""
|
||||
"""Pairing is DM-only — the DM pairing carve-out doesn't gate group traffic.
|
||||
|
||||
Group access is governed by ``group_policy``, so an allowlisted group is
|
||||
still trusted even while DMs are in ``pairing`` mode.
|
||||
"""
|
||||
_clear_auth_env(monkeypatch)
|
||||
config = GatewayConfig(
|
||||
platforms={
|
||||
Platform.WECOM: PlatformConfig(
|
||||
enabled=True, extra={"dm_policy": "pairing", "group_policy": "open"}
|
||||
enabled=True, extra={"dm_policy": "pairing", "group_policy": "allowlist"}
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -151,7 +151,18 @@ class TestSupportedDocumentTypes:
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ext",
|
||||
[".pdf", ".md", ".txt", ".zip", ".docx", ".xlsx", ".pptx"],
|
||||
[
|
||||
".pdf",
|
||||
".md",
|
||||
".txt",
|
||||
".zip",
|
||||
".doc",
|
||||
".docx",
|
||||
".xls",
|
||||
".xlsx",
|
||||
".ppt",
|
||||
".pptx",
|
||||
],
|
||||
)
|
||||
def test_expected_extensions_present(self, ext):
|
||||
assert ext in SUPPORTED_DOCUMENT_TYPES
|
||||
|
||||
@@ -1090,6 +1090,24 @@ class TestMatrixMarkdownToHtml:
|
||||
assert "<code" in result
|
||||
assert "print" in result
|
||||
|
||||
def test_matrix_markdown_preserves_table_structure(self):
|
||||
table = "\n".join(
|
||||
[
|
||||
"| Item | Quantity |",
|
||||
"| --- | --- |",
|
||||
"| Apples | 4 |",
|
||||
"| Bread | 1 |",
|
||||
]
|
||||
)
|
||||
|
||||
result = self.adapter._markdown_to_html(table)
|
||||
|
||||
assert "<table>" in result
|
||||
assert "<thead>" in result
|
||||
assert "<tbody>" in result
|
||||
assert "<th>Item</th>" in result
|
||||
assert "<td>Apples</td>" in result
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper: display name extraction
|
||||
|
||||
@@ -35,6 +35,7 @@ import pytest
|
||||
from gateway.config import GatewayConfig, HomeChannel, Platform
|
||||
from gateway.platforms.base import MessageEvent, MessageType, SendResult
|
||||
from gateway.run import (
|
||||
_AGENT_PENDING_SENTINEL,
|
||||
_auto_continue_freshness_window,
|
||||
_coerce_gateway_timestamp,
|
||||
_is_fresh_gateway_interruption,
|
||||
@@ -1420,3 +1421,194 @@ class TestStuckLoopEscalation:
|
||||
{"indent": None},
|
||||
)
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_resume_sets_sentinel_before_task_execution():
|
||||
"""Auto-resume must claim the session slot before the task starts.
|
||||
|
||||
Regression for #45456: between ``asyncio.create_task()`` and the task's
|
||||
first await (where ``_process_message_background`` sets the real
|
||||
sentinel), an inbound message could arrive and spin up a duplicate
|
||||
AIAgent. The fix pre-claims the slot so the inbound path sees it as
|
||||
occupied.
|
||||
"""
|
||||
runner, adapter = make_restart_runner()
|
||||
source = make_restart_source(chat_id="race-chat")
|
||||
pending_entry = SessionEntry(
|
||||
session_key="agent:main:telegram:dm:race-chat",
|
||||
session_id="sid",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
origin=source,
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
resume_pending=True,
|
||||
resume_reason="restart_interrupted",
|
||||
last_resume_marked_at=datetime.now(),
|
||||
)
|
||||
runner.session_store._entries = {pending_entry.session_key: pending_entry}
|
||||
|
||||
# Slow mock: hold the task open so we can inspect _running_agents
|
||||
# while it's in-flight.
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def _slow_handle(event):
|
||||
await gate.wait()
|
||||
|
||||
adapter.handle_message = _slow_handle
|
||||
|
||||
scheduled = runner._schedule_resume_pending_sessions()
|
||||
|
||||
assert scheduled == 1
|
||||
# The sentinel must be set immediately — before the task starts executing.
|
||||
assert pending_entry.session_key in runner._running_agents
|
||||
assert runner._running_agents[pending_entry.session_key] is _AGENT_PENDING_SENTINEL
|
||||
assert pending_entry.session_key in runner._running_agents_ts
|
||||
|
||||
# Release the task and let it complete.
|
||||
gate.set()
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# After the task completes, the sentinel should be cleaned up.
|
||||
assert pending_entry.session_key not in runner._running_agents
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_resume_sentinel_cleaned_on_task_failure():
|
||||
"""If handle_message raises before _process_message_background, the
|
||||
sentinel must still be released so the session is not locked forever.
|
||||
"""
|
||||
runner, adapter = make_restart_runner()
|
||||
source = make_restart_source(chat_id="fail-chat")
|
||||
pending_entry = SessionEntry(
|
||||
session_key="agent:main:telegram:dm:fail-chat",
|
||||
session_id="sid",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
origin=source,
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
resume_pending=True,
|
||||
resume_reason="restart_interrupted",
|
||||
last_resume_marked_at=datetime.now(),
|
||||
)
|
||||
runner.session_store._entries = {pending_entry.session_key: pending_entry}
|
||||
|
||||
async def _failing_handle(event):
|
||||
raise RuntimeError("adapter exploded")
|
||||
|
||||
adapter.handle_message = _failing_handle
|
||||
|
||||
scheduled = runner._schedule_resume_pending_sessions()
|
||||
assert scheduled == 1
|
||||
|
||||
# Sentinel is set immediately.
|
||||
assert pending_entry.session_key in runner._running_agents
|
||||
|
||||
# Let the task run and fail.
|
||||
await asyncio.sleep(0.05)
|
||||
|
||||
# The sentinel must be cleaned up despite the failure.
|
||||
assert pending_entry.session_key not in runner._running_agents
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_auto_resume_runs_agent_exactly_once_through_full_path():
|
||||
"""Full-path regression: the pre-claim must NOT make auto-resume a no-op.
|
||||
|
||||
The two tests above mock ``adapter.handle_message`` outright, so they
|
||||
only prove the sentinel is set/cleaned around a stub — they never
|
||||
exercise the real dispatch chain. This drives the production path
|
||||
end to end:
|
||||
|
||||
_schedule_resume_pending_sessions
|
||||
-> _guarded_handle_message
|
||||
-> adapter.handle_message (real)
|
||||
-> _process_message_background (real)
|
||||
-> _handle_message (real)
|
||||
|
||||
The risk the pre-claim introduces is a *self-bounce*: the resume
|
||||
turn's own ``_handle_message`` sees the sentinel it pre-claimed at
|
||||
the early running-agent guard, queues the event into
|
||||
``_pending_messages`` and returns ``None`` without running the
|
||||
agent. The adapter's late-arrival drain (in
|
||||
``_process_message_background``'s ``finally``) re-dispatches the
|
||||
queued event, and because the guard wrapper's ``finally`` releases
|
||||
the pre-claim before the spawned drain task starts, the agent runs
|
||||
exactly once. This test locks that invariant in: the resume agent
|
||||
must run once — never zero (regression) and never twice (the bug
|
||||
the fix targets).
|
||||
"""
|
||||
runner, adapter = make_restart_runner()
|
||||
source = make_restart_source(chat_id="full-path-chat")
|
||||
session_key = runner._session_key_for_source(source)
|
||||
pending_entry = SessionEntry(
|
||||
session_key=session_key,
|
||||
session_id="sid",
|
||||
created_at=datetime.now(),
|
||||
updated_at=datetime.now(),
|
||||
origin=source,
|
||||
platform=Platform.TELEGRAM,
|
||||
chat_type="dm",
|
||||
resume_pending=True,
|
||||
resume_reason="restart_interrupted",
|
||||
last_resume_marked_at=datetime.now(),
|
||||
)
|
||||
runner.session_store._entries = {session_key: pending_entry}
|
||||
|
||||
# Wire the REAL runner pipeline that _handle_message depends on.
|
||||
from gateway.run import GatewayRunner
|
||||
|
||||
runner._handle_message = GatewayRunner._handle_message.__get__(
|
||||
runner, GatewayRunner
|
||||
)
|
||||
runner._release_running_agent_state = (
|
||||
GatewayRunner._release_running_agent_state.__get__(runner, GatewayRunner)
|
||||
)
|
||||
runner._check_slash_access = lambda *a, **k: None
|
||||
runner._begin_session_run_generation = lambda session_key: 1
|
||||
runner._is_session_run_current = lambda session_key, generation: True
|
||||
runner._invalidate_session_run_generation = lambda *a, **k: 0
|
||||
runner._claim_active_session_slot = lambda session_key, source: (object(), None)
|
||||
runner._active_session_leases = {}
|
||||
runner._busy_ack_ts = {}
|
||||
runner._post_turn_goal_continuation = AsyncMock()
|
||||
runner.session_store.get_or_create_session.return_value = None
|
||||
|
||||
# Count how many times an actual agent run is started for this session.
|
||||
agent_runs: list[str] = []
|
||||
|
||||
async def _fake_run(event, source, _quick_key, run_generation):
|
||||
agent_runs.append(_quick_key)
|
||||
return "RESUMED OK"
|
||||
|
||||
runner._handle_message_with_agent = _fake_run
|
||||
|
||||
# Route the adapter's real background pipeline at the real handler,
|
||||
# and stub the leaf send/typing calls so delivery is a no-op.
|
||||
adapter.set_message_handler(runner._handle_message)
|
||||
adapter.send = AsyncMock()
|
||||
adapter._keep_typing = AsyncMock()
|
||||
adapter._stop_typing_refresh = AsyncMock()
|
||||
adapter._send_with_retry = AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="1")
|
||||
)
|
||||
adapter._run_processing_hook = AsyncMock()
|
||||
|
||||
scheduled = runner._schedule_resume_pending_sessions()
|
||||
assert scheduled == 1
|
||||
# Pre-claim must be visible immediately.
|
||||
assert runner._running_agents.get(session_key) is _AGENT_PENDING_SENTINEL
|
||||
|
||||
# Let the guarded task, the background task, and the late-arrival
|
||||
# drain task all settle.
|
||||
for _ in range(20):
|
||||
await asyncio.sleep(0.02)
|
||||
|
||||
# Exactly one agent run for the resumed session — not zero (the
|
||||
# pre-claim did not swallow the resume) and not two (no duplicate).
|
||||
assert agent_runs == [session_key]
|
||||
# No leaked sentinel and no orphaned queued event.
|
||||
assert session_key not in runner._running_agents
|
||||
assert session_key not in getattr(adapter, "_pending_messages", {})
|
||||
|
||||
@@ -321,3 +321,189 @@ class TestAlreadySentInDraftMode:
|
||||
|
||||
# After the regular sendMessage finalize, _already_sent is True.
|
||||
assert consumer._already_sent is True
|
||||
|
||||
|
||||
def _make_fresh_final_adapter():
|
||||
"""Build a non-draft adapter that prefers a fresh final send.
|
||||
|
||||
Mirrors Telegram's rich-message contract: REQUIRES_EDIT_FINALIZE so the
|
||||
final tick is routed through even when the text is unchanged, and
|
||||
prefers_fresh_final_streaming() True so the consumer delivers the final
|
||||
answer via a *fresh* send + preview delete instead of an edit.
|
||||
|
||||
``send`` returns two distinct ids so the test can tell the preview
|
||||
(first send) from the fresh final (second send) and assert the preview
|
||||
is the one deleted.
|
||||
"""
|
||||
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
||||
|
||||
FreshFinalAdapter = type(
|
||||
"FreshFinalAdapter",
|
||||
(BasePlatformAdapter,),
|
||||
{"MAX_MESSAGE_LENGTH": 4096, "REQUIRES_EDIT_FINALIZE": True},
|
||||
)
|
||||
FreshFinalAdapter.__abstractmethods__ = frozenset()
|
||||
adapter = FreshFinalAdapter.__new__(FreshFinalAdapter)
|
||||
adapter._typing_paused = set()
|
||||
adapter._fatal_error_message = None
|
||||
|
||||
# Edit-based path only — no native drafts.
|
||||
adapter.supports_draft_streaming = lambda chat_type=None, metadata=None: False
|
||||
# Accepts the metadata kwarg the consumer passes; ignores it (like Telegram).
|
||||
adapter.prefers_fresh_final_streaming = lambda content, metadata=None: True
|
||||
|
||||
adapter.send = AsyncMock(side_effect=[
|
||||
SendResult(success=True, message_id="preview1"),
|
||||
SendResult(success=True, message_id="final1"),
|
||||
])
|
||||
adapter.edit_message = AsyncMock(return_value=SendResult(success=True))
|
||||
adapter.delete_message = AsyncMock(return_value=True)
|
||||
return adapter
|
||||
|
||||
|
||||
class TestAdapterPrefersFreshFinal:
|
||||
"""An adapter whose send path is richer than its edit path (e.g. Telegram
|
||||
rich messages) finalizes a streamed reply by sending a fresh final message
|
||||
and deleting the preview, instead of final-editing the preview."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_edit_stream_finalizes_with_fresh_send_and_deletes_preview(self):
|
||||
adapter = _make_fresh_final_adapter()
|
||||
cfg = StreamConsumerConfig(
|
||||
transport="auto", chat_type="dm",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
fresh_final_after_seconds=0.0, # only the adapter hook drives fresh-final
|
||||
)
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
|
||||
consumer.on_delta("Full answer here")
|
||||
task = asyncio.create_task(consumer.run())
|
||||
# Let the first send land so a real preview message_id exists before
|
||||
# finalization — the fresh-final path only engages with a live preview.
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# Two sends: the streaming preview, then the fresh final.
|
||||
assert adapter.send.await_count == 2
|
||||
first_content = adapter.send.call_args_list[0].kwargs.get("content")
|
||||
second_content = adapter.send.call_args_list[1].kwargs.get("content")
|
||||
# First update delivered the preview via adapter.send.
|
||||
assert first_content == "Full answer here"
|
||||
# Finalization re-sent the same completed content as a fresh message.
|
||||
assert second_content == "Full answer here"
|
||||
|
||||
# The edit path must NOT be used to finalize a rich preview.
|
||||
adapter.edit_message.assert_not_called()
|
||||
|
||||
# The stale preview is best-effort deleted (by its id, not the final's).
|
||||
adapter.delete_message.assert_awaited_once_with("12345", "preview1")
|
||||
|
||||
assert consumer.final_response_sent is True
|
||||
|
||||
|
||||
def _make_rich_capable_adapter(*, overflow_limit=32768, send_results=None):
|
||||
"""Non-draft adapter that mimics Telegram rich messages: REQUIRES_EDIT_FINALIZE,
|
||||
prefers a fresh (rich) final send, and reports a 32,768 streaming overflow
|
||||
limit so the consumer doesn't pre-split a reply that fits one rich message.
|
||||
"""
|
||||
from gateway.platforms.base import BasePlatformAdapter, SendResult
|
||||
|
||||
RichAdapter = type(
|
||||
"RichCapableAdapter",
|
||||
(BasePlatformAdapter,),
|
||||
{"MAX_MESSAGE_LENGTH": 4096, "REQUIRES_EDIT_FINALIZE": True},
|
||||
)
|
||||
RichAdapter.__abstractmethods__ = frozenset()
|
||||
adapter = RichAdapter.__new__(RichAdapter)
|
||||
adapter._typing_paused = set()
|
||||
adapter._fatal_error_message = None
|
||||
adapter.supports_draft_streaming = lambda chat_type=None, metadata=None: False
|
||||
adapter.prefers_fresh_final_streaming = lambda content, metadata=None: True
|
||||
adapter.streaming_overflow_limit = lambda: overflow_limit
|
||||
adapter.send = AsyncMock(side_effect=send_results) if send_results else AsyncMock(
|
||||
return_value=SendResult(success=True, message_id="m1"),
|
||||
)
|
||||
adapter.edit_message = AsyncMock(return_value=SendResult(success=True))
|
||||
adapter.delete_message = AsyncMock(return_value=True)
|
||||
return adapter
|
||||
|
||||
|
||||
class TestRichAwareOverflow:
|
||||
"""Rich-capable adapters raise the consumer's overflow limit so a reply that
|
||||
fits one rich message isn't fragmented at the legacy 4,096 edit limit."""
|
||||
|
||||
def test_raw_message_limit_uses_adapter_rich_cap(self):
|
||||
adapter = _make_rich_capable_adapter(overflow_limit=32768)
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", StreamConsumerConfig())
|
||||
assert consumer._raw_message_limit() == 32768
|
||||
|
||||
def test_raw_message_limit_falls_back_to_max_length(self):
|
||||
# Adapter whose hook returns None (default) keeps the legacy limit.
|
||||
adapter = _make_rich_capable_adapter()
|
||||
adapter.streaming_overflow_limit = lambda: None
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", StreamConsumerConfig())
|
||||
assert consumer._raw_message_limit() == 4096
|
||||
|
||||
def test_raw_message_limit_mock_adapter_is_safe(self):
|
||||
# MagicMock adapters (many existing tests) must not crash or wrongly
|
||||
# inflate the limit from a truthy auto-attribute.
|
||||
adapter = MagicMock()
|
||||
adapter.MAX_MESSAGE_LENGTH = 4096
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", StreamConsumerConfig())
|
||||
assert consumer._raw_message_limit() == 4096
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_long_rich_reply_not_split_and_final_is_whole(self):
|
||||
from gateway.platforms.base import SendResult
|
||||
|
||||
long_text = "x" * 5000 # > 4096 legacy limit, < 32768 rich limit
|
||||
adapter = _make_rich_capable_adapter(send_results=[
|
||||
SendResult(success=True, message_id="preview1"),
|
||||
SendResult(success=True, message_id="final1"),
|
||||
])
|
||||
cfg = StreamConsumerConfig(
|
||||
transport="auto", chat_type="dm",
|
||||
edit_interval=0.01, buffer_threshold=5, cursor="",
|
||||
fresh_final_after_seconds=0.0,
|
||||
)
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", cfg)
|
||||
|
||||
consumer.on_delta(long_text)
|
||||
task = asyncio.create_task(consumer.run())
|
||||
await asyncio.sleep(0.05)
|
||||
consumer.finish()
|
||||
await task
|
||||
|
||||
# Exactly two whole sends: the preview and the fresh final — NOT split
|
||||
# into ~4096 chunks. Both carry the full 5,000-char reply.
|
||||
assert adapter.send.await_count == 2
|
||||
assert adapter.send.call_args_list[0].kwargs.get("content") == long_text
|
||||
assert adapter.send.call_args_list[1].kwargs.get("content") == long_text
|
||||
adapter.edit_message.assert_not_called()
|
||||
adapter.delete_message.assert_awaited_once_with("12345", "preview1")
|
||||
assert consumer.final_response_sent is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_fresh_final_deletes_all_preview_fragments(self):
|
||||
from gateway.platforms.base import SendResult
|
||||
|
||||
adapter = _make_rich_capable_adapter(send_results=[
|
||||
SendResult(success=True, message_id="final1"),
|
||||
])
|
||||
consumer = GatewayStreamConsumer(adapter, "12345", StreamConsumerConfig())
|
||||
# Simulate a reply that was split across the edit limit while streaming:
|
||||
# three preview fragments, the last of which is the current message.
|
||||
consumer._message_id = "frag3"
|
||||
consumer._preview_message_ids = {"frag1", "frag2", "frag3"}
|
||||
|
||||
ok = await consumer._try_fresh_final("the whole completed answer")
|
||||
|
||||
assert ok is True
|
||||
# All three stale fragments deleted; the fresh final never deleted.
|
||||
deleted = {c.args[1] for c in adapter.delete_message.await_args_list}
|
||||
assert deleted == {"frag1", "frag2", "frag3"}
|
||||
assert "final1" not in deleted
|
||||
assert consumer._message_id == "final1"
|
||||
assert consumer._preview_message_ids == set()
|
||||
assert consumer.final_response_sent is True
|
||||
|
||||
@@ -103,7 +103,8 @@ class TestInitialReplyToId:
|
||||
await consumer._send_or_edit("Test")
|
||||
|
||||
call_kwargs = adapter.send.call_args[1]
|
||||
assert call_kwargs["metadata"] == metadata
|
||||
assert call_kwargs["metadata"] == {**metadata, "expect_edits": True}
|
||||
assert metadata == {"thread_id": "omt_topic789"}
|
||||
|
||||
|
||||
class TestOverflowFirstMessage:
|
||||
|
||||
@@ -25,19 +25,24 @@ from telegram.error import BadRequest, NetworkError, TimedOut
|
||||
# and a task list. Pipes / brackets must survive untouched into the payload.
|
||||
RICH_CONTENT = "## Results\n\n| Case | Status |\n|---|---|\n| rich | ✅ |\n\n- [x] table renders"
|
||||
|
||||
# PTB 22.6's real unknown-endpoint errors: do_api_request can raise
|
||||
# EndPointNotFound for Bot API 404s, and the request layer can wrap that same
|
||||
# missing endpoint as InvalidToken. Use class names here so the tests don't
|
||||
# depend on optional PTB internals.
|
||||
EndPointNotFound = type("EndPointNotFound", (Exception,), {})
|
||||
InvalidToken = type("InvalidToken", (Exception,), {})
|
||||
PTB_ENDPOINT_NOT_FOUND = EndPointNotFound(
|
||||
"Endpoint 'sendRichMessage' not found in Bot API"
|
||||
)
|
||||
PTB_INVALID_TOKEN_404 = InvalidToken(
|
||||
"Either the bot token was rejected by Telegram or the endpoint "
|
||||
"'sendRichMessage' does not exist."
|
||||
)
|
||||
|
||||
|
||||
def _make_adapter(extra=None):
|
||||
"""Build a TelegramAdapter with a mock bot wired for the rich path.
|
||||
|
||||
Rich messages are opt-in (default off) while the Bot API 10.1 endpoint
|
||||
is validated live, so tests that exercise the rich path enable it
|
||||
explicitly here; opt-out tests pass their own ``extra``.
|
||||
"""
|
||||
config = PlatformConfig(
|
||||
enabled=True,
|
||||
token="fake-token",
|
||||
extra={"rich_messages": True} if extra is None else extra,
|
||||
)
|
||||
"""Build a TelegramAdapter with a mock bot wired for the rich path."""
|
||||
config = PlatformConfig(enabled=True, token="fake-token", extra=extra or {})
|
||||
adapter = TelegramAdapter(config)
|
||||
bot = MagicMock()
|
||||
# do_api_request as an AsyncMock makes inspect.iscoroutinefunction(...) True,
|
||||
@@ -57,6 +62,31 @@ def _rich_api_kwargs(adapter):
|
||||
return call.kwargs["api_kwargs"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
("raw", "expected_id"),
|
||||
[
|
||||
(SimpleNamespace(message_id=123), "123"),
|
||||
({"message_id": 123}, "123"),
|
||||
({"result": {"message_id": 123}}, "123"),
|
||||
({"result": None}, None),
|
||||
],
|
||||
)
|
||||
async def test_rich_result_shapes_extract_message_id(raw, expected_id):
|
||||
"""The raw Bot API path may return either a PTB object or a raw dict."""
|
||||
adapter = _make_adapter()
|
||||
adapter._bot.do_api_request = AsyncMock(return_value=raw)
|
||||
|
||||
result = await adapter.send("12345", RICH_CONTENT)
|
||||
|
||||
assert result.success is True
|
||||
assert result.message_id == expected_id
|
||||
bot = adapter._bot
|
||||
assert bot is not None
|
||||
bot.do_api_request.assert_awaited_once()
|
||||
bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_happy_path_sends_raw_markdown():
|
||||
adapter = _make_adapter()
|
||||
@@ -76,32 +106,43 @@ async def test_rich_happy_path_sends_raw_markdown():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_opt_out_uses_legacy():
|
||||
async def test_legacy_rich_messages_config_is_ignored():
|
||||
adapter = _make_adapter(extra={"rich_messages": False})
|
||||
|
||||
result = await adapter.send("12345", RICH_CONTENT)
|
||||
|
||||
assert result.success is True
|
||||
adapter._bot.do_api_request.assert_not_called()
|
||||
adapter._bot.send_message.assert_awaited()
|
||||
# The legacy toggle was removed; stale config entries must not disable the
|
||||
# rich path.
|
||||
adapter._bot.do_api_request.assert_awaited_once()
|
||||
adapter._bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_opt_out_accepts_string_false():
|
||||
adapter = _make_adapter(extra={"rich_messages": "false"})
|
||||
async def test_expect_edits_metadata_keeps_preview_on_legacy_path():
|
||||
adapter = _make_adapter()
|
||||
|
||||
await adapter.send("12345", RICH_CONTENT)
|
||||
result = await adapter.send(
|
||||
"12345",
|
||||
RICH_CONTENT,
|
||||
metadata={"expect_edits": True},
|
||||
)
|
||||
|
||||
adapter._bot.do_api_request.assert_not_called()
|
||||
adapter._bot.send_message.assert_awaited()
|
||||
assert result.success is True
|
||||
# Streaming preview sends will be edited later, so they must not be born as
|
||||
# rich messages until Hermes wires rich_message edits directly.
|
||||
bot = adapter._bot
|
||||
assert bot is not None
|
||||
bot.do_api_request.assert_not_called()
|
||||
bot.send_message.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oversized_content_skips_rich_and_chunks():
|
||||
adapter = _make_adapter()
|
||||
# > 32,768 UTF-8 bytes -> rich pre-check fails, legacy chunking takes over.
|
||||
# > 32,768 characters -> rich pre-check fails, legacy chunking takes over.
|
||||
oversized = "a" * 40000
|
||||
assert len(oversized.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
|
||||
assert len(oversized) > TelegramAdapter.RICH_MESSAGE_MAX_CHARS
|
||||
|
||||
result = await adapter.send("12345", oversized)
|
||||
|
||||
@@ -111,6 +152,23 @@ async def test_oversized_content_skips_rich_and_chunks():
|
||||
assert adapter._bot.send_message.await_count > 1
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_limit_is_characters_not_bytes():
|
||||
"""Telegram's rich limit is UTF-8 characters, not encoded bytes."""
|
||||
adapter = _make_adapter()
|
||||
cjk = "测" * 20000 # 20k chars, 60k UTF-8 bytes
|
||||
assert len(cjk.encode("utf-8")) > TelegramAdapter.RICH_MESSAGE_MAX_BYTES
|
||||
assert len(cjk) <= TelegramAdapter.RICH_MESSAGE_MAX_CHARS
|
||||
|
||||
result = await adapter.send("12345", cjk)
|
||||
|
||||
assert result.success is True
|
||||
bot = adapter._bot
|
||||
assert bot is not None
|
||||
bot.do_api_request.assert_awaited_once()
|
||||
bot.send_message.assert_not_called()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize(
|
||||
"exc",
|
||||
@@ -162,6 +220,33 @@ async def test_capability_error_latches_rich_send_off():
|
||||
adapter._bot.send_message.assert_awaited()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.parametrize("exc", [PTB_ENDPOINT_NOT_FOUND, PTB_INVALID_TOKEN_404])
|
||||
async def test_real_ptb_endpoint_missing_falls_back_and_latches_off(exc):
|
||||
adapter = _make_adapter()
|
||||
adapter._bot.do_api_request = AsyncMock(side_effect=exc)
|
||||
|
||||
result = await adapter.send("12345", RICH_CONTENT)
|
||||
|
||||
assert result.success is True
|
||||
bot = adapter._bot
|
||||
assert bot is not None
|
||||
bot.do_api_request.assert_awaited_once()
|
||||
bot.send_message.assert_awaited()
|
||||
assert adapter._rich_send_disabled is True
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_payload_preserves_link_preview_disable():
|
||||
adapter = _make_adapter(extra={"disable_link_previews": True})
|
||||
|
||||
result = await adapter.send("12345", "See https://example.com")
|
||||
|
||||
assert result.success is True
|
||||
api_kwargs = _rich_api_kwargs(adapter)
|
||||
assert api_kwargs["link_preview_options"] == {"is_disabled": True}
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_per_message_bad_request_does_not_latch_off():
|
||||
"""A parser/limit BadRequest is per-message — rich must stay enabled
|
||||
@@ -265,13 +350,9 @@ async def test_notification_opt_in_drops_disable_flag():
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_gate_tolerates_missing_enabled_attr():
|
||||
"""Adapters missing _rich_messages_enabled (object.__new__ in some tests)
|
||||
must not raise — the gate reads it via getattr(default=True), and a bot
|
||||
without an async do_api_request falls through to the legacy path."""
|
||||
async def test_rich_gate_tolerates_minimal_bot_without_raw_endpoint():
|
||||
"""A bot without an async do_api_request falls through to the legacy path."""
|
||||
adapter = _make_adapter()
|
||||
del adapter._rich_messages_enabled # simulate object.__new__ construction
|
||||
# SimpleNamespace bot has no do_api_request -> _bot_supports_rich() False.
|
||||
adapter._bot = SimpleNamespace(
|
||||
send_message=AsyncMock(return_value=SimpleNamespace(message_id=42)),
|
||||
send_chat_action=AsyncMock(),
|
||||
@@ -337,17 +418,6 @@ async def test_rich_draft_transient_failure_does_not_latch_off():
|
||||
assert adapter._rich_draft_disabled is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_draft_opt_out_uses_legacy():
|
||||
adapter = _make_adapter(extra={"rich_messages": False})
|
||||
|
||||
result = await adapter.send_draft("12345", draft_id=7, content=RICH_CONTENT)
|
||||
|
||||
assert result.success is True
|
||||
adapter._bot.do_api_request.assert_not_called()
|
||||
adapter._bot.send_message_draft.assert_awaited_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_rich_draft_oversized_uses_legacy():
|
||||
adapter = _make_adapter()
|
||||
@@ -358,3 +428,40 @@ async def test_rich_draft_oversized_uses_legacy():
|
||||
assert result.success is True
|
||||
adapter._bot.do_api_request.assert_not_called()
|
||||
adapter._bot.send_message_draft.assert_awaited_once()
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# prefers_fresh_final_streaming: the stream consumer asks the adapter whether
|
||||
# to finalize a streamed reply by sending a fresh (rich) message + deleting the
|
||||
# preview, instead of final-editing the preview through the non-rich edit path.
|
||||
# Telegram opts in exactly when the content is rich-eligible.
|
||||
# ----------------------------------------------------------------------
|
||||
def test_prefers_fresh_final_streaming_when_rich_enabled():
|
||||
adapter = _make_adapter()
|
||||
assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is True
|
||||
|
||||
|
||||
def test_prefers_fresh_final_streaming_ignores_legacy_toggle():
|
||||
adapter = _make_adapter(extra={"rich_messages": False})
|
||||
assert adapter.prefers_fresh_final_streaming(RICH_CONTENT) is True
|
||||
|
||||
|
||||
# ----------------------------------------------------------------------
|
||||
# streaming_overflow_limit: with rich on, the stream consumer may accumulate up
|
||||
# to the 32,768-char rich cap before splitting, so a reply that fits one
|
||||
# sendRichMessage / sendRichMessageDraft isn't fragmented at the 4,096 limit.
|
||||
# ----------------------------------------------------------------------
|
||||
def test_streaming_overflow_limit_is_rich_cap_when_enabled():
|
||||
adapter = _make_adapter()
|
||||
assert adapter.streaming_overflow_limit() == TelegramAdapter.RICH_MESSAGE_MAX_CHARS
|
||||
|
||||
|
||||
def test_streaming_overflow_limit_ignores_legacy_toggle():
|
||||
adapter = _make_adapter(extra={"rich_messages": False})
|
||||
assert adapter.streaming_overflow_limit() == TelegramAdapter.RICH_MESSAGE_MAX_CHARS
|
||||
|
||||
|
||||
def test_streaming_overflow_limit_none_when_rich_latched_off():
|
||||
adapter = _make_adapter()
|
||||
adapter._rich_send_disabled = True
|
||||
assert adapter.streaming_overflow_limit() is None
|
||||
|
||||
@@ -76,6 +76,7 @@ def test_resolve_codex_runtime_credentials_missing_access_token(tmp_path, monkey
|
||||
hermes_home = tmp_path / "hermes"
|
||||
_setup_hermes_auth(hermes_home, access_token="")
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(tmp_path / "missing-codex"))
|
||||
|
||||
with pytest.raises(AuthError) as exc:
|
||||
resolve_codex_runtime_credentials()
|
||||
|
||||
@@ -0,0 +1,208 @@
|
||||
"""Regression tests for Codex refresh_token self-heal (cross-store rotation).
|
||||
|
||||
Hermes keeps its OWN copy of the Codex OAuth token (per profile + top-level),
|
||||
separate from the Codex CLI's ``~/.codex/auth.json``. OAuth refresh_tokens are
|
||||
single-use, so when the Codex CLI (or another Hermes process) rotates the shared
|
||||
token, the frozen copy's refresh_token goes stale and ``refresh_codex_oauth_pure``
|
||||
fails with a relogin-required error. ``_refresh_codex_auth_tokens`` must then
|
||||
recover by re-importing the canonical token from ``~/.codex/auth.json`` instead of
|
||||
surfacing a hard 401 — but ONLY for relogin-required failures, never for transient
|
||||
ones (e.g. 429 quota, where the stored token is still valid).
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
import hermes_cli.auth as auth
|
||||
from hermes_cli.auth import AuthError, _refresh_codex_auth_tokens, resolve_codex_runtime_credentials
|
||||
|
||||
STALE = {"access_token": "stale-access", "refresh_token": "stale-refresh"}
|
||||
|
||||
|
||||
def test_self_heals_on_stale_refresh_token(monkeypatch):
|
||||
"""invalid_grant (relogin-required) → reimport from ~/.codex and persist it."""
|
||||
saved = {}
|
||||
fresh = {
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
"last_refresh": "2026-06-12T00:00:00Z",
|
||||
}
|
||||
|
||||
def _rejected(*_a, **_k):
|
||||
raise AuthError(
|
||||
"refresh token rejected",
|
||||
provider="openai-codex",
|
||||
code="invalid_grant",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: dict(fresh))
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
||||
|
||||
out = _refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert out["access_token"] == "fresh-access"
|
||||
assert out["refresh_token"] == "fresh-refresh"
|
||||
# the recovered token was persisted to the Hermes auth store
|
||||
assert saved["access_token"] == "fresh-access"
|
||||
|
||||
|
||||
def test_does_not_self_heal_on_rate_limit(monkeypatch):
|
||||
"""429 quota keeps relogin_required=False — token still valid, must NOT reimport."""
|
||||
import_calls = {"n": 0}
|
||||
|
||||
def _rate_limited(*_a, **_k):
|
||||
raise AuthError(
|
||||
"quota exhausted",
|
||||
provider="openai-codex",
|
||||
code="codex_rate_limited",
|
||||
relogin_required=False,
|
||||
)
|
||||
|
||||
def _import_spy():
|
||||
import_calls["n"] += 1
|
||||
return {"access_token": "should-not-be-used"}
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rate_limited)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
|
||||
|
||||
with pytest.raises(AuthError) as ei:
|
||||
_refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert ei.value.code == "codex_rate_limited"
|
||||
assert import_calls["n"] == 0 # never touched ~/.codex on a transient failure
|
||||
|
||||
|
||||
def test_reraises_when_codex_cli_token_absent(monkeypatch):
|
||||
"""relogin-required but ~/.codex unavailable/expired → propagate original error."""
|
||||
|
||||
def _reused(*_a, **_k):
|
||||
raise AuthError(
|
||||
"refresh token reused",
|
||||
provider="openai-codex",
|
||||
code="refresh_token_reused",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _reused)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: None)
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda *a, **k: None)
|
||||
|
||||
with pytest.raises(AuthError) as ei:
|
||||
_refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert ei.value.code == "refresh_token_reused"
|
||||
|
||||
|
||||
def test_happy_path_unchanged(monkeypatch):
|
||||
"""Normal refresh succeeds → rotated tokens persisted, ~/.codex never consulted."""
|
||||
saved = {}
|
||||
import_calls = {"n": 0}
|
||||
|
||||
def _import_spy():
|
||||
import_calls["n"] += 1
|
||||
return None
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth,
|
||||
"refresh_codex_oauth_pure",
|
||||
lambda *a, **k: {"access_token": "rotated", "refresh_token": "rotated-r"},
|
||||
)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", _import_spy)
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
||||
|
||||
out = _refresh_codex_auth_tokens({"access_token": "a", "refresh_token": "b"}, 20.0)
|
||||
|
||||
assert out["access_token"] == "rotated"
|
||||
assert out["refresh_token"] == "rotated-r"
|
||||
assert saved["access_token"] == "rotated"
|
||||
assert import_calls["n"] == 0 # happy path must not consult ~/.codex
|
||||
|
||||
|
||||
def test_reraises_when_imported_token_lacks_refresh_token(monkeypatch):
|
||||
"""relogin-required, but ~/.codex returns an access_token with NO refresh_token →
|
||||
re-raise rather than persist a half-token that would break the next refresh."""
|
||||
saved = {}
|
||||
|
||||
def _rejected(*_a, **_k):
|
||||
raise AuthError(
|
||||
"refresh token rejected",
|
||||
provider="openai-codex",
|
||||
code="invalid_grant",
|
||||
relogin_required=True,
|
||||
)
|
||||
|
||||
monkeypatch.setattr(auth, "refresh_codex_oauth_pure", _rejected)
|
||||
monkeypatch.setattr(auth, "_import_codex_cli_tokens", lambda: {"access_token": "fresh-only"})
|
||||
monkeypatch.setattr(auth, "_save_codex_tokens", lambda t, *a, **k: saved.update(t))
|
||||
|
||||
with pytest.raises(AuthError) as ei:
|
||||
_refresh_codex_auth_tokens(STALE, 20.0)
|
||||
|
||||
assert ei.value.code == "invalid_grant"
|
||||
assert saved == {} # nothing was persisted
|
||||
|
||||
|
||||
def test_self_heals_missing_singleton_access_token_from_codex_cli(tmp_path, monkeypatch):
|
||||
"""Exact cron failure path: Hermes auth has refresh_token but missing access_token."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex"
|
||||
hermes_home.mkdir()
|
||||
codex_home.mkdir()
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"refresh_token": "stale-refresh"},
|
||||
"last_refresh": "2026-06-01T00:00:00Z",
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
}))
|
||||
(codex_home / "auth.json").write_text(json.dumps({
|
||||
"tokens": {
|
||||
"access_token": "fresh-access",
|
||||
"refresh_token": "fresh-refresh",
|
||||
},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
resolved = resolve_codex_runtime_credentials()
|
||||
|
||||
assert resolved["api_key"] == "fresh-access"
|
||||
assert resolved["source"] == "hermes-auth-store"
|
||||
stored = json.loads((hermes_home / "auth.json").read_text())
|
||||
tokens = stored["providers"]["openai-codex"]["tokens"]
|
||||
assert tokens["access_token"] == "fresh-access"
|
||||
assert tokens["refresh_token"] == "fresh-refresh"
|
||||
|
||||
|
||||
def test_missing_singleton_access_token_reraises_when_codex_cli_half_token(tmp_path, monkeypatch):
|
||||
"""Missing access_token must not be masked by a malformed Codex CLI import."""
|
||||
hermes_home = tmp_path / "hermes"
|
||||
codex_home = tmp_path / "codex"
|
||||
hermes_home.mkdir()
|
||||
codex_home.mkdir()
|
||||
(hermes_home / "auth.json").write_text(json.dumps({
|
||||
"version": 1,
|
||||
"providers": {
|
||||
"openai-codex": {
|
||||
"tokens": {"refresh_token": "stale-refresh"},
|
||||
"auth_mode": "chatgpt",
|
||||
},
|
||||
},
|
||||
}))
|
||||
(codex_home / "auth.json").write_text(json.dumps({
|
||||
"tokens": {"access_token": "fresh-only"},
|
||||
}))
|
||||
monkeypatch.setenv("HERMES_HOME", str(hermes_home))
|
||||
monkeypatch.setenv("CODEX_HOME", str(codex_home))
|
||||
|
||||
with pytest.raises(AuthError) as ei:
|
||||
resolve_codex_runtime_credentials()
|
||||
|
||||
assert ei.value.code == "codex_auth_missing_access_token"
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Unit tests for find_custom_provider_identity (base_url → custom:<name>).
|
||||
|
||||
Reverse lookup used by tui_gateway session persistence to recover a named
|
||||
``providers:`` / ``custom_providers:`` entry from the only durable fact the
|
||||
session row keeps once the provider has been resolved to the literal string
|
||||
"custom": the endpoint URL. See
|
||||
tests/tui_gateway/test_custom_provider_session_persistence.py for the
|
||||
end-to-end persist/resume round-trip.
|
||||
"""
|
||||
|
||||
import hermes_cli.runtime_provider as rp
|
||||
|
||||
|
||||
def test_matches_legacy_custom_providers_list(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{"name": "MiMo v2.5 Pro", "base_url": "https://api.mimo.example/v1"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert (
|
||||
rp.find_custom_provider_identity("https://api.mimo.example/v1")
|
||||
== "custom:mimo-v2.5-pro"
|
||||
)
|
||||
|
||||
|
||||
def test_matches_providers_dict_by_key(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {"providers": {"local": {"api": "http://127.0.0.1:8000/v1"}}},
|
||||
)
|
||||
assert (
|
||||
rp.find_custom_provider_identity("http://127.0.0.1:8000/v1")
|
||||
== "custom:local"
|
||||
)
|
||||
|
||||
|
||||
def test_match_ignores_trailing_slash_and_case(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{"name": "local", "base_url": "http://Localhost:8000/v1/"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert (
|
||||
rp.find_custom_provider_identity("http://localhost:8000/v1")
|
||||
== "custom:local"
|
||||
)
|
||||
|
||||
|
||||
def test_no_match_returns_none(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp,
|
||||
"load_config",
|
||||
lambda: {
|
||||
"custom_providers": [
|
||||
{"name": "other", "base_url": "https://elsewhere.example/v1"}
|
||||
]
|
||||
},
|
||||
)
|
||||
assert rp.find_custom_provider_identity("https://api.mimo.example/v1") is None
|
||||
|
||||
|
||||
def test_empty_base_url_returns_none(monkeypatch):
|
||||
monkeypatch.setattr(
|
||||
rp, "load_config", lambda: {"custom_providers": [{"name": "x"}]}
|
||||
)
|
||||
assert rp.find_custom_provider_identity("") is None
|
||||
assert rp.find_custom_provider_identity(None) is None
|
||||
|
||||
|
||||
def test_identity_resolves_back_through_named_lookup(monkeypatch):
|
||||
"""The returned slug must be accepted by _get_named_custom_provider —
|
||||
that is the whole point of persisting it."""
|
||||
config = {
|
||||
"custom_providers": [
|
||||
{
|
||||
"name": "mimo-v2.5-pro",
|
||||
"base_url": "https://api.mimo.example/v1",
|
||||
"api_key": "sk-entry",
|
||||
}
|
||||
]
|
||||
}
|
||||
monkeypatch.setattr(rp, "load_config", lambda: config)
|
||||
|
||||
slug = rp.find_custom_provider_identity("https://api.mimo.example/v1")
|
||||
assert slug == "custom:mimo-v2.5-pro"
|
||||
|
||||
entry = rp._get_named_custom_provider(slug)
|
||||
assert entry is not None
|
||||
assert entry["base_url"] == "https://api.mimo.example/v1"
|
||||
assert entry["api_key"] == "sk-entry"
|
||||
@@ -96,3 +96,40 @@ def test_status_preserves_existing_fields(loopback_client):
|
||||
}
|
||||
missing = expected_keys - set(body.keys())
|
||||
assert not missing, f"/api/status dropped fields: {missing}"
|
||||
|
||||
|
||||
# Host-local detail (absolute paths, PID, internal gateway URL) is deployment
|
||||
# recon a liveness probe never needs. ``/api/status`` bypasses dashboard auth
|
||||
# (it is in ``PUBLIC_API_PATHS``), so on a network-exposed bind it must not
|
||||
# leak that detail to anonymous callers.
|
||||
_HOST_DETAIL_FIELDS = frozenset({
|
||||
"hermes_home", "config_path", "env_path", "gateway_pid",
|
||||
"gateway_health_url",
|
||||
})
|
||||
|
||||
|
||||
def test_status_withholds_host_detail_in_gated_mode(gated_client):
|
||||
"""On a gated (non-loopback) bind, the public ``/api/status`` probe must
|
||||
expose only the liveness + auth-gate shape — never absolute host paths,
|
||||
the gateway PID, or the internal gateway health URL. The endpoint
|
||||
bypasses dashboard auth, so anyone who can reach the host hits it cold."""
|
||||
r = gated_client.get("/api/status")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
# Liveness / auth-gate shape stays public.
|
||||
for key in ("version", "gateway_state", "auth_required", "auth_providers"):
|
||||
assert key in body, f"liveness field {key!r} must stay public"
|
||||
# Deployment recon must be withheld from the anonymous public probe.
|
||||
leaked = _HOST_DETAIL_FIELDS & set(body.keys())
|
||||
assert not leaked, f"/api/status leaked host detail under the gate: {leaked}"
|
||||
|
||||
|
||||
def test_status_includes_host_detail_in_loopback_mode(loopback_client):
|
||||
"""Counterpart to the gated case: a loopback bind is local-only, so the
|
||||
full payload (including host paths and PID) is still served — preserving
|
||||
the StatusPage / ``hermes status`` experience for local operators."""
|
||||
r = loopback_client.get("/api/status")
|
||||
assert r.status_code == 200
|
||||
body = r.json()
|
||||
missing = _HOST_DETAIL_FIELDS - set(body.keys())
|
||||
assert not missing, f"loopback /api/status should keep host detail: {missing}"
|
||||
|
||||
@@ -1406,3 +1406,72 @@ class TestDoctorStaleMaxIterationsDrift:
|
||||
monkeypatch, tmp_path, fix=False, ghost=None, cfg_turns=400,
|
||||
)
|
||||
assert "shadows" not in out
|
||||
|
||||
|
||||
def test_npm_audit_fix_hint_avoids_crashing_workspace_flag(monkeypatch, tmp_path):
|
||||
"""`hermes doctor` must not hand users `npm audit fix --workspace <name>`:
|
||||
that exact form crashes npm with "Cannot read properties of null (reading
|
||||
'edgesOut')" (an arborist bug with workspace-filtered audit fix).
|
||||
|
||||
It must not recommend root-level `npm audit fix` for workspace advisories
|
||||
either: current npm can crash there too with "Cannot read properties of null
|
||||
(reading 'isDescendantOf')" on this tree. The safe guidance is that these
|
||||
build-tool advisories clear via the lockfile/package bump.
|
||||
|
||||
Regression for user reports where doctor flagged the web/ui-tui workspaces
|
||||
and the suggested fix command errored out.
|
||||
"""
|
||||
home = tmp_path / ".hermes"
|
||||
home.mkdir(parents=True, exist_ok=True)
|
||||
project = tmp_path / "project"
|
||||
(project / "node_modules").mkdir(parents=True)
|
||||
|
||||
monkeypatch.setenv("HERMES_HOME", str(home))
|
||||
monkeypatch.setattr(doctor_mod, "HERMES_HOME", home)
|
||||
monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project)
|
||||
|
||||
# Only npm is "installed" — keeps the rest of run_doctor's external checks
|
||||
# quiet without affecting the npm-audit branch under test.
|
||||
monkeypatch.setattr(
|
||||
doctor_mod.shutil, "which", lambda cmd: "/usr/bin/npm" if cmd == "npm" else None
|
||||
)
|
||||
|
||||
def mock_run(cmd, **kwargs):
|
||||
if "audit" in cmd and "--workspace" in cmd:
|
||||
payload = (
|
||||
'{"metadata": {"vulnerabilities": '
|
||||
'{"critical": 0, "high": 2, "moderate": 0}}}'
|
||||
)
|
||||
return SimpleNamespace(returncode=1, stdout=payload, stderr="")
|
||||
if "audit" in cmd:
|
||||
payload = (
|
||||
'{"metadata": {"vulnerabilities": '
|
||||
'{"critical": 0, "high": 0, "moderate": 0}}}'
|
||||
)
|
||||
return SimpleNamespace(returncode=0, stdout=payload, stderr="")
|
||||
return SimpleNamespace(returncode=0, stdout="", stderr="")
|
||||
|
||||
import subprocess
|
||||
|
||||
monkeypatch.setattr(subprocess, "run", mock_run)
|
||||
|
||||
buf = io.StringIO()
|
||||
with contextlib.redirect_stdout(buf):
|
||||
doctor_mod.run_doctor(Namespace(fix=False))
|
||||
out = buf.getvalue()
|
||||
|
||||
# The workspace vulnerability is still reported ...
|
||||
assert "web workspace" in out
|
||||
# ... but the remediation must NOT use the npm-crashing per-workspace form
|
||||
# (`npm audit fix --workspace web` / `--workspace ui-tui`).
|
||||
assert "npm audit fix --workspace web" not in out
|
||||
assert "npm audit fix --workspace ui-tui" not in out
|
||||
# ... and it must not point at the root-level form either: npm can crash
|
||||
# there too with `isDescendantOf` on this monorepo tree.
|
||||
assert "npm audit fix" not in out
|
||||
# ... and explains the workspace advisories are build-time tooling whose
|
||||
# manual remediation may hit a known npm arborist crash, so the user isn't
|
||||
# left thinking a crashing command means a broken Hermes install.
|
||||
assert "build-time tooling" in out
|
||||
assert "known npm bug" in out
|
||||
assert "lockfile bump" in out
|
||||
|
||||
@@ -274,6 +274,20 @@ def test_gateway_start_in_container_with_operational_systemd_uses_systemd(monkey
|
||||
assert calls == [False]
|
||||
|
||||
|
||||
def test_gateway_start_ignores_legacy_platform_selector(monkeypatch):
|
||||
monkeypatch.setattr(gateway, "supports_systemd_services", lambda: True)
|
||||
monkeypatch.setattr(gateway, "is_wsl", lambda: False)
|
||||
monkeypatch.setattr(gateway, "is_macos", lambda: False)
|
||||
|
||||
calls = []
|
||||
monkeypatch.setattr(gateway, "systemd_start", lambda system=False: calls.append(system))
|
||||
|
||||
args = SimpleNamespace(gateway_command="start", system=False, all=False, platform="photon")
|
||||
gateway.gateway_command(args)
|
||||
|
||||
assert calls == [False]
|
||||
|
||||
|
||||
def test_gateway_restart_on_windows_without_service_uses_detached_backend(monkeypatch):
|
||||
"""Windows manual restart must not fall back to foreground run_gateway().
|
||||
|
||||
|
||||
@@ -143,7 +143,8 @@ class TestGeminiModelNormalization:
|
||||
assert normalize_model_for_provider("gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
|
||||
|
||||
def test_strip_vendor_prefix(self):
|
||||
assert normalize_model_for_provider("google/gemini-2.5-flash", "gemini") == "google/gemini-2.5-flash"
|
||||
assert normalize_model_for_provider("google/gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
|
||||
assert normalize_model_for_provider("gemini/gemini-2.5-flash", "gemini") == "gemini-2.5-flash"
|
||||
|
||||
def test_gemma_vendor_detection(self):
|
||||
assert detect_vendor("gemma-4-31b-it") == "google"
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
|
||||
|
||||
def _make_task(kb, *, assignee: str):
|
||||
return kb.Task(
|
||||
id="t_spawn_tools",
|
||||
title="spawn tools",
|
||||
body=None,
|
||||
assignee=assignee,
|
||||
status="running",
|
||||
priority=0,
|
||||
created_by="test",
|
||||
created_at=1,
|
||||
started_at=None,
|
||||
completed_at=None,
|
||||
workspace_kind="dir",
|
||||
workspace_path=None,
|
||||
claim_lock="lock",
|
||||
claim_expires=None,
|
||||
tenant=None,
|
||||
current_run_id=7,
|
||||
)
|
||||
|
||||
|
||||
def test_default_spawn_pins_assignee_profile_cli_toolsets(monkeypatch, tmp_path):
|
||||
"""Manual profile assignment should keep that profile's CLI tools.
|
||||
|
||||
Regression guard for dispatcher-spawned workers that boot with
|
||||
HERMES_KANBAN_TASK: the worker must not collapse to only kanban lifecycle
|
||||
tools when the assigned profile's top-level ``toolsets`` is the default
|
||||
composite. The spawned CLI gets an explicit --toolsets pin resolved from
|
||||
platform_toolsets.cli; model_tools appends task-scoped kanban tools later.
|
||||
"""
|
||||
root = tmp_path / ".hermes"
|
||||
profile = root / "profiles" / "elias"
|
||||
profile.mkdir(parents=True)
|
||||
profile.joinpath("config.yaml").write_text(
|
||||
"""
|
||||
platform_toolsets:
|
||||
cli:
|
||||
- clarify
|
||||
- code_execution
|
||||
- delegation
|
||||
- file
|
||||
- memory
|
||||
- session_search
|
||||
- skills
|
||||
- terminal
|
||||
- web
|
||||
toolsets:
|
||||
- hermes-cli
|
||||
agent:
|
||||
disabled_toolsets: []
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
root.joinpath("config.yaml").write_text("toolsets:\n - kanban\n", encoding="utf-8")
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
monkeypatch.setattr(kb, "_resolve_hermes_argv", lambda: ["hermes"])
|
||||
|
||||
captured = {}
|
||||
|
||||
class FakeProc:
|
||||
pid = 4242
|
||||
|
||||
def fake_popen(cmd, *args, **kwargs):
|
||||
captured["cmd"] = list(cmd)
|
||||
captured["env"] = dict(kwargs.get("env") or {})
|
||||
captured["cwd"] = kwargs.get("cwd")
|
||||
return FakeProc()
|
||||
|
||||
monkeypatch.setattr(subprocess, "Popen", fake_popen)
|
||||
|
||||
workspace = tmp_path / "workspace"
|
||||
workspace.mkdir()
|
||||
pid = kb._default_spawn(_make_task(kb, assignee="elias"), str(workspace))
|
||||
|
||||
assert pid == 4242
|
||||
assert captured["env"]["HERMES_HOME"] == str(profile)
|
||||
assert captured["env"]["HERMES_KANBAN_TASK"] == "t_spawn_tools"
|
||||
assert "--toolsets" in captured["cmd"]
|
||||
pinned = captured["cmd"][captured["cmd"].index("--toolsets") + 1].split(",")
|
||||
for required in ("terminal", "web", "file", "skills", "code_execution", "delegation"):
|
||||
assert required in pinned
|
||||
|
||||
|
||||
def test_resolve_worker_cli_toolsets_uses_profile_home_not_parent_config(monkeypatch, tmp_path):
|
||||
root = tmp_path / ".hermes"
|
||||
profile = root / "profiles" / "elias"
|
||||
profile.mkdir(parents=True)
|
||||
root.joinpath("config.yaml").write_text("platform_toolsets:\n cli:\n - kanban\n", encoding="utf-8")
|
||||
profile.joinpath("config.yaml").write_text(
|
||||
"""
|
||||
platform_toolsets:
|
||||
cli:
|
||||
- terminal
|
||||
- web
|
||||
toolsets:
|
||||
- hermes-cli
|
||||
""".lstrip(),
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setenv("HERMES_HOME", str(root))
|
||||
|
||||
from hermes_cli import kanban_db as kb
|
||||
|
||||
resolved = kb._resolve_worker_cli_toolsets(str(profile))
|
||||
|
||||
assert resolved is not None
|
||||
assert "terminal" in resolved
|
||||
assert "web" in resolved
|
||||
assert "kanban" in resolved # recovered worker lifecycle surface
|
||||
assert resolved != ["kanban"]
|
||||
@@ -167,10 +167,13 @@ class TestAggregatorProviders:
|
||||
class TestIssue6211NativeProviderPrefixNormalization:
|
||||
@pytest.mark.parametrize("model,target_provider,expected", [
|
||||
("zai/glm-5.1", "zai", "glm-5.1"),
|
||||
("google/gemini-2.5-pro", "gemini", "google/gemini-2.5-pro"),
|
||||
("google/gemini-2.5-pro", "gemini", "gemini-2.5-pro"),
|
||||
("gemini/gemini-2.5-pro", "gemini", "gemini-2.5-pro"),
|
||||
("moonshot/kimi-k2.5", "kimi-coding", "kimi-k2.5"),
|
||||
("anthropic/claude-sonnet-4.6", "openrouter", "anthropic/claude-sonnet-4.6"),
|
||||
("x-ai/grok-4-fast-reasoning", "xai", "grok-4-fast-reasoning"),
|
||||
("Qwen/Qwen3.5-397B-A17B", "huggingface", "Qwen/Qwen3.5-397B-A17B"),
|
||||
("openai/gpt-5.4", "xai", "openai/gpt-5.4"),
|
||||
("modal/zai-org/GLM-5-FP8", "custom", "modal/zai-org/GLM-5-FP8"),
|
||||
])
|
||||
def test_native_provider_prefixes_are_only_stripped_on_matching_provider(
|
||||
|
||||
@@ -65,6 +65,61 @@ def test_resolve_provider_full_finds_named_custom_provider():
|
||||
assert resolved.source == "user-config"
|
||||
|
||||
|
||||
def test_list_authenticated_providers_includes_active_bare_custom_endpoint(monkeypatch):
|
||||
"""Bare model.provider=custom + model.base_url should still populate /model.
|
||||
|
||||
Users can configure a one-off OpenAI-compatible endpoint directly under
|
||||
``model:`` without a named ``providers:`` or ``custom_providers:`` row.
|
||||
The gateway picker receives only the current model/base_url slice, so it
|
||||
must surface that active endpoint rather than looking like config was
|
||||
ignored.
|
||||
"""
|
||||
monkeypatch.setattr("agent.models_dev.fetch_models_dev", lambda: {})
|
||||
monkeypatch.setattr(providers_mod, "HERMES_OVERLAYS", {})
|
||||
|
||||
providers = list_authenticated_providers(
|
||||
current_provider="custom",
|
||||
current_base_url="https://www.ccsub.net/v1",
|
||||
current_model="gpt-4o",
|
||||
user_providers={},
|
||||
custom_providers=[],
|
||||
max_models=50,
|
||||
)
|
||||
|
||||
bare_custom = next((p for p in providers if p["slug"] == "custom"), None)
|
||||
assert bare_custom is not None
|
||||
assert bare_custom["name"] == "Custom endpoint"
|
||||
assert bare_custom["is_current"] is True
|
||||
assert bare_custom["is_user_defined"] is True
|
||||
assert bare_custom["models"] == ["gpt-4o"]
|
||||
assert bare_custom["api_url"] == "https://www.ccsub.net/v1"
|
||||
|
||||
|
||||
def test_switch_model_accepts_explicit_bare_custom_current_endpoint(monkeypatch):
|
||||
"""Picker selections for bare custom endpoints should route to current base_url."""
|
||||
monkeypatch.setattr("hermes_cli.models.validate_requested_model", lambda *a, **k: _MOCK_VALIDATION)
|
||||
monkeypatch.setattr("hermes_cli.model_switch.get_model_info", lambda *a, **k: None)
|
||||
monkeypatch.setattr("hermes_cli.model_switch.get_model_capabilities", lambda *a, **k: None)
|
||||
|
||||
result = switch_model(
|
||||
raw_input="gpt-4o-mini",
|
||||
current_provider="custom",
|
||||
current_model="gpt-4o",
|
||||
current_base_url="https://www.ccsub.net/v1",
|
||||
current_api_key="sk-test",
|
||||
explicit_provider="custom",
|
||||
user_providers={},
|
||||
custom_providers=[],
|
||||
)
|
||||
|
||||
assert result.success is True
|
||||
assert result.target_provider == "custom"
|
||||
assert result.provider_label == "Custom endpoint"
|
||||
assert result.new_model == "gpt-4o-mini"
|
||||
assert result.base_url == "https://www.ccsub.net/v1"
|
||||
assert result.api_key == "sk-test"
|
||||
|
||||
|
||||
def test_is_aggregator_recognizes_named_custom_provider():
|
||||
assert providers_mod.is_aggregator("custom:hpc-ai") is True
|
||||
assert providers_mod.is_aggregator("custom:litellm") is True
|
||||
|
||||
@@ -173,6 +173,54 @@ class TestResolveGitUrl:
|
||||
assert url == "git@github.com:owner/repo.git"
|
||||
assert subdir == "sub"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier",
|
||||
[
|
||||
"https://github.com/owner/repo/tree/main",
|
||||
"https://github.com/owner/repo/blob/main/README.md",
|
||||
"https://github.com/owner/repo/pull/123",
|
||||
"https://github.com/owner/repo/commit/abc123def",
|
||||
"https://github.com/owner/repo/releases/tag/v1.0",
|
||||
"https://github.com/owner/repo/issues/42",
|
||||
],
|
||||
)
|
||||
def test_github_browser_url_normalized_to_repo(self, identifier):
|
||||
url, subdir = _resolve_git_url(identifier)
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
assert subdir is None
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("identifier", "expected_subdir"),
|
||||
[
|
||||
("https://github.com/owner/repo/tree/main/plugins/foo", "plugins/foo"),
|
||||
("https://github.com/owner/repo/tree/feature-branch/plugin", "plugin"),
|
||||
("https://github.com/owner/repo/tree/main/plugins/foo?plain=1", "plugins/foo"),
|
||||
("https://github.com/owner/repo.git/tree/main/plugins/foo", "plugins/foo"),
|
||||
],
|
||||
)
|
||||
def test_github_tree_browser_url_preserves_subdir(self, identifier, expected_subdir):
|
||||
url, subdir = _resolve_git_url(identifier)
|
||||
assert url == "https://github.com/owner/repo.git"
|
||||
assert subdir == expected_subdir
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier",
|
||||
[
|
||||
"https://github.com/owner/repo",
|
||||
"https://github.com/owner/repo.git",
|
||||
"https://github.com/owner",
|
||||
"https://github.com/owner/repo/branches",
|
||||
"https://github.com/owner//tree/main",
|
||||
"https://gitlab.com/owner/repo/tree/main",
|
||||
"git@github.com:owner/repo.git",
|
||||
"file:///tmp/repo/tree/main",
|
||||
],
|
||||
)
|
||||
def test_non_browser_urls_passthrough(self, identifier):
|
||||
url, subdir = _resolve_git_url(identifier)
|
||||
assert url == identifier
|
||||
assert subdir is None
|
||||
|
||||
|
||||
# ── _resolve_subdir_within ──────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -81,3 +81,12 @@ def test_gateway_accept_hooks_flag():
|
||||
p = _gateway_parser()
|
||||
ns = p.parse_args(["gateway", "run", "--accept-hooks"])
|
||||
assert ns.accept_hooks is True
|
||||
|
||||
|
||||
def test_gateway_lifecycle_accepts_legacy_platform_flag():
|
||||
p = _gateway_parser()
|
||||
for action in ("start", "restart", "status"):
|
||||
ns = p.parse_args(["gateway", action, "--platform", "photon"])
|
||||
assert ns.gateway_command == action
|
||||
assert ns.platform == "photon"
|
||||
assert ns.func is _h_gateway
|
||||
|
||||
@@ -7,6 +7,7 @@ Windows-specific code paths can be exercised on any host.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import types
|
||||
@@ -446,6 +447,97 @@ def test_quarantine_actionable_warning_when_everything_fails(
|
||||
assert "Hermes Desktop" in captured or "gateway" in captured.lower()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Windows gateway pause/resume before update mutation
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_pause_windows_gateways_for_update_stops_profile_and_unmapped_pids(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
tmp_path,
|
||||
capsys,
|
||||
):
|
||||
import gateway.status as status_mod
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
profile_home = tmp_path / "profiles" / "work"
|
||||
profile_home.mkdir(parents=True)
|
||||
profile_proc = SimpleNamespace(profile="work", path=profile_home, pid=101)
|
||||
|
||||
monkeypatch.setattr(gateway_mod, "find_gateway_pids", lambda **_k: [101, 202])
|
||||
monkeypatch.setattr(
|
||||
gateway_mod,
|
||||
"find_profile_gateway_processes",
|
||||
lambda **_k: [profile_proc],
|
||||
)
|
||||
monkeypatch.setattr(gateway_mod, "_get_restart_drain_timeout", lambda: 0.1)
|
||||
waited_for = []
|
||||
|
||||
def fake_wait(pids, *, timeout):
|
||||
waited_for.extend(pids)
|
||||
return set()
|
||||
|
||||
monkeypatch.setattr(cli_main, "_wait_for_windows_update_gateway_exit", fake_wait)
|
||||
|
||||
terminated = []
|
||||
monkeypatch.setattr(
|
||||
status_mod,
|
||||
"terminate_pid",
|
||||
lambda pid, force=False: terminated.append((pid, force)),
|
||||
)
|
||||
|
||||
token = cli_main._pause_windows_gateways_for_update()
|
||||
|
||||
assert token == {
|
||||
"resume_needed": True,
|
||||
"profiles": {"work": 101},
|
||||
"unmapped_pids": [202],
|
||||
}
|
||||
assert waited_for == [101]
|
||||
assert terminated == [(202, True)]
|
||||
|
||||
marker = json.loads((profile_home / ".gateway-planned-stop.json").read_text())
|
||||
assert marker["target_pid"] == 101
|
||||
assert marker["stopper_pid"] == os.getpid()
|
||||
|
||||
captured = capsys.readouterr().out
|
||||
assert "Paused gateway profile(s): work" in captured
|
||||
assert "without profile mapping" in captured
|
||||
|
||||
|
||||
@patch.object(cli_main, "_is_windows", return_value=True)
|
||||
def test_resume_windows_gateways_after_update_relaunches_paused_profiles(
|
||||
_winp,
|
||||
monkeypatch,
|
||||
capsys,
|
||||
):
|
||||
import hermes_cli.gateway as gateway_mod
|
||||
|
||||
relaunched = []
|
||||
monkeypatch.setattr(
|
||||
gateway_mod,
|
||||
"launch_detached_profile_gateway_restart",
|
||||
lambda profile, old_pid: relaunched.append((profile, old_pid)) or True,
|
||||
)
|
||||
|
||||
token = {
|
||||
"resume_needed": True,
|
||||
"profiles": {"default": 101, "work": 202},
|
||||
"unmapped_pids": [],
|
||||
}
|
||||
|
||||
cli_main._resume_windows_gateways_after_update(token)
|
||||
|
||||
assert token["resume_needed"] is False
|
||||
assert relaunched == [("default", 101), ("work", 202)]
|
||||
assert (
|
||||
"Restarting Windows gateway profile(s): default, work"
|
||||
in capsys.readouterr().out
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# cmd_update integration — concurrent-instance gate
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -520,6 +520,87 @@ class TestWebServerEndpoints:
|
||||
resp = self.client.get("/api/profiles/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_sessions_endpoint_reads_requested_profile(self):
|
||||
"""The machine dashboard's global profile switcher must retarget
|
||||
the Sessions page, not just config/skills/model pages."""
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
worker_home = profiles_mod.get_profile_dir("worker")
|
||||
worker_home.mkdir(parents=True)
|
||||
|
||||
default_db = SessionDB()
|
||||
try:
|
||||
default_db.create_session(session_id="default-only", source="cli")
|
||||
default_db.append_message("default-only", role="user", content="default")
|
||||
finally:
|
||||
default_db.close()
|
||||
|
||||
worker_db = SessionDB(db_path=worker_home / "state.db")
|
||||
try:
|
||||
worker_db.create_session(session_id="worker-only", source="cli")
|
||||
worker_db.append_message("worker-only", role="user", content="worker")
|
||||
finally:
|
||||
worker_db.close()
|
||||
|
||||
resp = self.client.get("/api/sessions?profile=worker&limit=20&min_messages=0")
|
||||
assert resp.status_code == 200
|
||||
data = resp.json()
|
||||
ids = {s["id"] for s in data["sessions"]}
|
||||
assert "worker-only" in ids
|
||||
assert "default-only" not in ids
|
||||
row = next(s for s in data["sessions"] if s["id"] == "worker-only")
|
||||
assert row["profile"] == "worker"
|
||||
assert row["is_default_profile"] is False
|
||||
|
||||
stats = self.client.get("/api/sessions/stats?profile=worker").json()
|
||||
assert stats["total"] == 1
|
||||
assert stats["messages"] == 1
|
||||
|
||||
messages = self.client.get("/api/sessions/worker-only/messages?profile=worker").json()
|
||||
assert [m["content"] for m in messages["messages"]] == ["worker"]
|
||||
|
||||
def test_analytics_endpoints_read_requested_profile(self):
|
||||
from hermes_state import SessionDB
|
||||
from hermes_cli import profiles as profiles_mod
|
||||
|
||||
worker_home = profiles_mod.get_profile_dir("worker")
|
||||
worker_home.mkdir(parents=True)
|
||||
|
||||
default_db = SessionDB()
|
||||
try:
|
||||
default_db.create_session(session_id="default-usage", source="cli", model="default/model")
|
||||
default_db.update_token_counts("default-usage", input_tokens=10, output_tokens=5)
|
||||
finally:
|
||||
default_db.close()
|
||||
|
||||
worker_db = SessionDB(db_path=worker_home / "state.db")
|
||||
try:
|
||||
worker_db.create_session(session_id="worker-usage", source="cli", model="worker/model")
|
||||
worker_db.update_token_counts(
|
||||
"worker-usage",
|
||||
input_tokens=123,
|
||||
output_tokens=45,
|
||||
billing_provider="worker-provider",
|
||||
)
|
||||
finally:
|
||||
worker_db.close()
|
||||
|
||||
usage = self.client.get("/api/analytics/usage?days=7&profile=worker").json()
|
||||
assert usage["totals"]["total_sessions"] == 1
|
||||
assert usage["totals"]["total_input"] == 123
|
||||
assert [m["model"] for m in usage["by_model"]] == ["worker/model"]
|
||||
|
||||
models = self.client.get("/api/analytics/models?days=7&profile=worker").json()
|
||||
assert models["totals"]["distinct_models"] == 1
|
||||
assert models["totals"]["total_input"] == 123
|
||||
assert models["models"][0]["model"] == "worker/model"
|
||||
assert models["models"][0]["provider"] == "worker-provider"
|
||||
|
||||
default_usage = self.client.get("/api/analytics/usage?days=7").json()
|
||||
assert default_usage["totals"]["total_input"] == 10
|
||||
assert default_usage["totals"]["total_output"] == 5
|
||||
|
||||
def test_get_sessions_rejects_unknown_archived_value(self):
|
||||
resp = self.client.get("/api/sessions?archived=bogus")
|
||||
assert resp.status_code == 400
|
||||
@@ -2552,7 +2633,7 @@ class TestNewEndpoints:
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={"name": "writer", "clone_from_default": False},
|
||||
json={"name": "writer", "clone_from": None},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -2560,7 +2641,7 @@ class TestNewEndpoints:
|
||||
assert wrapper_path.exists()
|
||||
assert wrapper_path.read_text() == '#!/bin/sh\nexec hermes -p writer "$@"\n'
|
||||
|
||||
def test_profiles_create_with_clone_from_default_copies_default_skills(self, monkeypatch):
|
||||
def test_profiles_create_with_clone_from_copies_source_skills(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
|
||||
@@ -2571,7 +2652,7 @@ class TestNewEndpoints:
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={"name": "cloned", "clone_from_default": True},
|
||||
json={"name": "cloned", "clone_from": "default"},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -2604,6 +2685,28 @@ class TestNewEndpoints:
|
||||
)
|
||||
assert cloned_skill.exists()
|
||||
|
||||
def test_profiles_create_clone_all_from_named_source(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
|
||||
monkeypatch.setattr(profiles_mod, "create_wrapper_script", lambda name: None)
|
||||
|
||||
assert self.client.post("/api/profiles", json={"name": "full-src"}).status_code == 200
|
||||
source_dir = get_hermes_home() / "profiles" / "full-src"
|
||||
(source_dir / "config.yaml").write_text("model:\n provider: source-only\n", encoding="utf-8")
|
||||
(source_dir / "workspace" / "artifact.txt").parent.mkdir(parents=True, exist_ok=True)
|
||||
(source_dir / "workspace" / "artifact.txt").write_text("copied", encoding="utf-8")
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={"name": "full-copy", "clone_from": "full-src", "clone_all": True},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
target_dir = get_hermes_home() / "profiles" / "full-copy"
|
||||
assert (target_dir / "config.yaml").read_text(encoding="utf-8") == "model:\n provider: source-only\n"
|
||||
assert (target_dir / "workspace" / "artifact.txt").read_text(encoding="utf-8") == "copied"
|
||||
|
||||
def test_profiles_create_without_clone_seeds_bundled_skills(self, monkeypatch):
|
||||
from hermes_constants import get_hermes_home
|
||||
import hermes_cli.profiles as profiles_mod
|
||||
@@ -2620,7 +2723,7 @@ class TestNewEndpoints:
|
||||
|
||||
resp = self.client.post(
|
||||
"/api/profiles",
|
||||
json={"name": "fresh", "clone_from_default": False},
|
||||
json={"name": "fresh", "clone_from": None},
|
||||
)
|
||||
|
||||
assert resp.status_code == 200
|
||||
@@ -3196,6 +3299,56 @@ class TestNewEndpoints:
|
||||
"top_skills": [],
|
||||
}
|
||||
|
||||
def test_models_analytics_merges_session_only_duplicate_into_accounted_provider(self):
|
||||
"""Session-only model rows should not render as duplicate zero-token cards.
|
||||
|
||||
Direct-provider-on-OpenRouter sessions can leave one row with only
|
||||
``model`` populated and another row with token/API accounting plus
|
||||
``billing_provider``. The Models dashboard should show one provider
|
||||
card, not a real card plus a misleading duplicate empty card.
|
||||
"""
|
||||
from hermes_state import SessionDB
|
||||
|
||||
db = SessionDB()
|
||||
try:
|
||||
db.create_session(
|
||||
session_id="deepseek-session-only",
|
||||
source="cli",
|
||||
model="deepseek/deepseek-v4-flash",
|
||||
)
|
||||
db.create_session(
|
||||
session_id="deepseek-accounted",
|
||||
source="cli",
|
||||
model="deepseek/deepseek-v4-flash",
|
||||
)
|
||||
db.update_token_counts(
|
||||
"deepseek-accounted",
|
||||
input_tokens=20_000,
|
||||
output_tokens=7_100,
|
||||
billing_provider="openrouter",
|
||||
api_call_count=9,
|
||||
)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
resp = self.client.get("/api/analytics/models?days=7")
|
||||
assert resp.status_code == 200
|
||||
|
||||
models = resp.json()["models"]
|
||||
deepseek_rows = [
|
||||
row for row in models
|
||||
if row["model"] == "deepseek/deepseek-v4-flash"
|
||||
]
|
||||
|
||||
assert len(deepseek_rows) == 1
|
||||
row = deepseek_rows[0]
|
||||
assert row["provider"] == "openrouter"
|
||||
assert row["sessions"] == 2
|
||||
assert row["input_tokens"] == 20_000
|
||||
assert row["output_tokens"] == 7_100
|
||||
assert row["api_calls"] == 9
|
||||
assert row["avg_tokens_per_session"] == 13_550
|
||||
|
||||
def test_analytics_usage_includes_skill_breakdown(self):
|
||||
from hermes_state import SessionDB
|
||||
|
||||
|
||||
@@ -193,6 +193,33 @@ def test_local_mode_defaults_to_home_and_can_jump_to_absolute_path(local_files_c
|
||||
assert other_listing.json()["entries"][0]["path"] == str(other / "other.txt")
|
||||
|
||||
|
||||
def test_gated_local_mode_still_defaults_to_home(monkeypatch, tmp_path):
|
||||
home = tmp_path / "home"
|
||||
home.mkdir()
|
||||
monkeypatch.delenv("HERMES_DASHBOARD_FILES_ROOT", raising=False)
|
||||
monkeypatch.delenv("HERMES_MANAGED", raising=False)
|
||||
monkeypatch.setenv("HOME", str(home))
|
||||
monkeypatch.setenv("HERMES_HOME", str(home / ".hermes"))
|
||||
|
||||
prev_auth_required = getattr(web_server.app.state, "auth_required", None)
|
||||
prev_bound_host = getattr(web_server.app.state, "bound_host", None)
|
||||
web_server.app.state.auth_required = True
|
||||
web_server.app.state.bound_host = "0.0.0.0"
|
||||
try:
|
||||
request = SimpleNamespace(
|
||||
app=web_server.app,
|
||||
client=SimpleNamespace(host="10.0.0.2"),
|
||||
url=SimpleNamespace(hostname="example.com"),
|
||||
)
|
||||
policy = web_server._managed_files_policy(request, create_root=False)
|
||||
finally:
|
||||
_restore_app_state(prev_auth_required, prev_bound_host)
|
||||
|
||||
assert policy.default_path == home.resolve()
|
||||
assert policy.locked_root is None
|
||||
assert policy.can_change_path is True
|
||||
|
||||
|
||||
def test_local_mode_upload_read_mkdir_delete_roundtrip(local_files_client):
|
||||
client, home = local_files_client
|
||||
folder = home / "workspace"
|
||||
|
||||
@@ -7,6 +7,8 @@ never clobbers a hand-tuned allowlist.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
|
||||
import pytest
|
||||
|
||||
from hermes_cli.config import get_env_value, save_env_value
|
||||
@@ -67,3 +69,44 @@ def test_env_enablement_home_channel_defaults_name(monkeypatch: pytest.MonkeyPat
|
||||
"chat_id": "+15551234567",
|
||||
"name": "Home",
|
||||
}
|
||||
|
||||
|
||||
def test_setup_hint_uses_gateway_service_command(monkeypatch: pytest.MonkeyPatch, capsys) -> None:
|
||||
monkeypatch.setattr(cli.photon_auth, "load_photon_token", lambda: "token")
|
||||
monkeypatch.setattr(cli.photon_auth, "load_dashboard_project_id", lambda: "dashboard")
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"ensure_spectrum_enabled",
|
||||
lambda token, dashboard_id: {"spectrumProjectId": "project_123"},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"regenerate_project_secret",
|
||||
lambda token, dashboard_id: "secret_123",
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "store_project_credentials", lambda **kwargs: None)
|
||||
monkeypatch.setattr(
|
||||
cli.photon_auth,
|
||||
"register_user_if_absent",
|
||||
lambda *args, **kwargs: ({"id": "user_123", "phoneNumber": "+15551234567"}, True),
|
||||
)
|
||||
monkeypatch.setattr(cli.photon_auth, "user_assigned_line", lambda user: "+15557654321")
|
||||
monkeypatch.setattr(cli.photon_auth, "store_user_numbers", lambda **kwargs: None)
|
||||
monkeypatch.setattr(cli, "_install_sidecar", lambda: 0)
|
||||
|
||||
rc = cli._cmd_setup(
|
||||
argparse.Namespace(
|
||||
project_name=None,
|
||||
phone="+15551234567",
|
||||
first_name=None,
|
||||
last_name=None,
|
||||
email=None,
|
||||
no_browser=True,
|
||||
skip_sidecar_install=False,
|
||||
)
|
||||
)
|
||||
|
||||
assert rc == 0
|
||||
out = capsys.readouterr().out
|
||||
assert "Start the gateway: hermes gateway start" in out
|
||||
assert "--platform photon" not in out
|
||||
|
||||
@@ -154,6 +154,22 @@ def _codex_ack_message_response(text: str):
|
||||
)
|
||||
|
||||
|
||||
def _codex_final_answer_with_top_level_incomplete_response(text: str):
|
||||
return SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
phase="final_answer",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text=text)],
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
|
||||
status="incomplete",
|
||||
model="gpt-5.4",
|
||||
)
|
||||
|
||||
|
||||
class _FakeCreateStream:
|
||||
"""Iterable-only fake for ``responses.create(stream=True)`` outputs.
|
||||
|
||||
@@ -1351,6 +1367,92 @@ def test_normalize_codex_response_marks_commentary_only_message_as_incomplete(mo
|
||||
assert "inspect the repository" in (assistant_message.content or "")
|
||||
|
||||
|
||||
def test_normalize_codex_response_final_answer_overrides_top_level_incomplete(monkeypatch):
|
||||
from agent.codex_responses_adapter import _normalize_codex_response
|
||||
|
||||
assistant_message, finish_reason = _normalize_codex_response(
|
||||
_codex_final_answer_with_top_level_incomplete_response(
|
||||
"Briefly:\n\n- I'm Ramsay, your assistant."
|
||||
)
|
||||
)
|
||||
|
||||
assert finish_reason == "stop"
|
||||
assert "Ramsay" in (assistant_message.content or "")
|
||||
|
||||
|
||||
def test_normalize_codex_response_top_level_incomplete_without_final_answer_stays_incomplete(monkeypatch):
|
||||
from agent.codex_responses_adapter import _normalize_codex_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text="Partial...")],
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
|
||||
status="incomplete",
|
||||
model="gpt-5.4",
|
||||
)
|
||||
|
||||
_, finish_reason = _normalize_codex_response(response)
|
||||
|
||||
assert finish_reason == "incomplete"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("top_level_status", ["queued", "in_progress"])
|
||||
def test_normalize_codex_response_final_answer_does_not_override_streaming_status(
|
||||
monkeypatch, top_level_status
|
||||
):
|
||||
from agent.codex_responses_adapter import _normalize_codex_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
phase="final_answer",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text="Interim answer.")],
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
|
||||
status=top_level_status,
|
||||
model="gpt-5.4",
|
||||
)
|
||||
|
||||
_, finish_reason = _normalize_codex_response(response)
|
||||
|
||||
assert finish_reason == "incomplete"
|
||||
|
||||
|
||||
def test_normalize_codex_response_final_answer_does_not_override_per_item_in_progress(monkeypatch):
|
||||
from agent.codex_responses_adapter import _normalize_codex_response
|
||||
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
phase="final_answer",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text="Partial final.")],
|
||||
),
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
status="in_progress",
|
||||
content=[SimpleNamespace(type="output_text", text="")],
|
||||
),
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
|
||||
status="completed",
|
||||
model="gpt-5.4",
|
||||
)
|
||||
|
||||
_, finish_reason = _normalize_codex_response(response)
|
||||
|
||||
assert finish_reason == "incomplete"
|
||||
|
||||
|
||||
def test_normalize_codex_response_preserves_message_status_for_replay(monkeypatch):
|
||||
"""Incomplete Codex output messages must not be replayed as completed."""
|
||||
agent = _build_agent(monkeypatch)
|
||||
@@ -1418,6 +1520,42 @@ def test_normalize_codex_response_detects_leaked_tool_call_text(monkeypatch):
|
||||
assert assistant_message.tool_calls == []
|
||||
|
||||
|
||||
def test_scan_for_leaked_tool_call_checks_prefix_window_only(monkeypatch):
|
||||
from agent.codex_responses_adapter import (
|
||||
_TOOL_CALL_LEAK_SCAN_LIMIT,
|
||||
_scan_for_leaked_tool_call,
|
||||
)
|
||||
|
||||
marker = "to=functions.terminal {\"command\": \"pwd\"}"
|
||||
|
||||
assert _scan_for_leaked_tool_call(marker) is True
|
||||
assert _scan_for_leaked_tool_call("x" * (_TOOL_CALL_LEAK_SCAN_LIMIT - 10) + " " + marker) is True
|
||||
assert _scan_for_leaked_tool_call("x" * (_TOOL_CALL_LEAK_SCAN_LIMIT + 10) + marker) is False
|
||||
|
||||
|
||||
def test_normalize_codex_response_ignores_late_tool_call_marker_past_scan_window(monkeypatch):
|
||||
from agent.codex_responses_adapter import _TOOL_CALL_LEAK_SCAN_LIMIT, _normalize_codex_response
|
||||
|
||||
late_marker = "x" * (_TOOL_CALL_LEAK_SCAN_LIMIT + 100) + " to=functions.terminal {\"command\": \"pwd\"}"
|
||||
response = SimpleNamespace(
|
||||
output=[
|
||||
SimpleNamespace(
|
||||
type="message",
|
||||
status="completed",
|
||||
content=[SimpleNamespace(type="output_text", text=late_marker)],
|
||||
)
|
||||
],
|
||||
usage=SimpleNamespace(input_tokens=4, output_tokens=2, total_tokens=6),
|
||||
status="completed",
|
||||
model="gpt-5.4",
|
||||
)
|
||||
|
||||
assistant_message, finish_reason = _normalize_codex_response(response)
|
||||
|
||||
assert finish_reason == "stop"
|
||||
assert assistant_message.content == late_marker
|
||||
|
||||
|
||||
def test_normalize_codex_response_ignores_tool_call_text_when_real_tool_call_present(monkeypatch):
|
||||
"""If the model emitted BOTH a structured function_call AND some text that
|
||||
happens to contain `to=functions.*` (unlikely but possible), trust the
|
||||
|
||||
@@ -104,6 +104,38 @@ class TestIsThinkingOnlyAssistant:
|
||||
}
|
||||
assert AIAgent._is_thinking_only_assistant(msg)
|
||||
|
||||
def test_codex_reasoning_items_list_form_detected(self):
|
||||
msg = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"codex_reasoning_items": [
|
||||
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"}
|
||||
],
|
||||
}
|
||||
assert AIAgent._is_thinking_only_assistant(msg)
|
||||
|
||||
def test_codex_reasoning_items_with_visible_text_is_not_thinking_only(self):
|
||||
msg = {
|
||||
"role": "assistant",
|
||||
"content": "Visible answer",
|
||||
"codex_reasoning_items": [
|
||||
{"type": "reasoning", "id": "rs_123", "encrypted_content": "enc_blob"}
|
||||
],
|
||||
}
|
||||
assert not AIAgent._is_thinking_only_assistant(msg)
|
||||
|
||||
def test_empty_codex_reasoning_items_list_is_not_thinking_only(self):
|
||||
msg = {"role": "assistant", "content": "", "codex_reasoning_items": []}
|
||||
assert not AIAgent._is_thinking_only_assistant(msg)
|
||||
|
||||
def test_non_reasoning_codex_items_are_not_thinking_only(self):
|
||||
msg = {
|
||||
"role": "assistant",
|
||||
"content": "",
|
||||
"codex_reasoning_items": [None, "x", {"type": "other"}],
|
||||
}
|
||||
assert not AIAgent._is_thinking_only_assistant(msg)
|
||||
|
||||
def test_user_message_never_thinking_only(self):
|
||||
assert not AIAgent._is_thinking_only_assistant({"role": "user", "content": ""})
|
||||
|
||||
|
||||
@@ -12,6 +12,7 @@ the codebase were migrated to the helper; these tests pin that invariant.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
@@ -158,3 +159,113 @@ def test_atomic_replace_broken_symlink_creates_target(tmp_path: Path) -> None:
|
||||
assert link.is_symlink(), "symlink must be preserved"
|
||||
assert missing.exists(), "real target should now exist"
|
||||
assert missing.read_text(encoding="utf-8") == "created-through-link\n"
|
||||
|
||||
|
||||
# ─── EXDEV / EBUSY copy fallback ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.parametrize("fail_errno", [errno.EXDEV, errno.EBUSY])
|
||||
def test_atomic_replace_copy_fallback(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch, fail_errno: int
|
||||
) -> None:
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old\n", encoding="utf-8")
|
||||
tmp = _write_tmp(tmp_path, "new\n")
|
||||
|
||||
def fail_replace(src: str, dst: str) -> None:
|
||||
raise OSError(fail_errno, os.strerror(fail_errno), src, None, dst)
|
||||
|
||||
monkeypatch.setattr("utils.os.replace", fail_replace)
|
||||
|
||||
assert Path(atomic_replace(tmp, target)) == target
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
assert not tmp.exists()
|
||||
|
||||
|
||||
def test_atomic_replace_copy_fallback_preserves_symlink(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
real = tmp_path / "real.yaml"
|
||||
link = tmp_path / "link.yaml"
|
||||
real.write_text("old\n", encoding="utf-8")
|
||||
link.symlink_to(real)
|
||||
tmp = _write_tmp(tmp_path, "new\n")
|
||||
|
||||
def fail_replace(src: str, dst: str) -> None:
|
||||
raise OSError(errno.EXDEV, os.strerror(errno.EXDEV), src, None, dst)
|
||||
|
||||
monkeypatch.setattr("utils.os.replace", fail_replace)
|
||||
|
||||
assert Path(atomic_replace(tmp, link)) == real
|
||||
assert link.is_symlink()
|
||||
assert real.read_text(encoding="utf-8") == "new\n"
|
||||
assert not tmp.exists()
|
||||
|
||||
|
||||
def test_atomic_replace_copy_fallback_preserves_metadata(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
if os.name != "posix":
|
||||
pytest.skip("POSIX-only")
|
||||
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old\n", encoding="utf-8")
|
||||
os.chmod(target, 0o600)
|
||||
tmp = _write_tmp(tmp_path, "new\n")
|
||||
os.chmod(tmp, 0o644)
|
||||
|
||||
def fail_replace(src: str, dst: str) -> None:
|
||||
raise OSError(errno.EBUSY, os.strerror(errno.EBUSY), src, None, dst)
|
||||
|
||||
monkeypatch.setattr("utils.os.replace", fail_replace)
|
||||
|
||||
atomic_replace(tmp, target)
|
||||
assert target.read_text(encoding="utf-8") == "new\n"
|
||||
assert target.stat().st_mode & 0o777 == 0o644
|
||||
|
||||
|
||||
def test_atomic_replace_other_oserror_propagates(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
target = tmp_path / "config.yaml"
|
||||
target.write_text("old\n", encoding="utf-8")
|
||||
tmp = _write_tmp(tmp_path, "new\n")
|
||||
|
||||
def fail_replace(src: str, dst: str) -> None:
|
||||
raise OSError(errno.EACCES, os.strerror(errno.EACCES), src, None, dst)
|
||||
|
||||
monkeypatch.setattr("utils.os.replace", fail_replace)
|
||||
|
||||
with pytest.raises(OSError) as excinfo:
|
||||
atomic_replace(tmp, target)
|
||||
assert excinfo.value.errno == errno.EACCES
|
||||
assert target.read_text(encoding="utf-8") == "old\n"
|
||||
assert tmp.exists()
|
||||
|
||||
|
||||
def test_atomic_replace_real_cross_device(tmp_path: Path) -> None:
|
||||
shm = Path("/dev/shm")
|
||||
if os.name != "posix" or not os.access(shm, os.W_OK):
|
||||
pytest.skip("requires writable /dev/shm")
|
||||
|
||||
import shutil as _shutil
|
||||
import uuid as _uuid
|
||||
|
||||
other_fs_dir = shm / f"hermes-exdev-test-{_uuid.uuid4().hex[:8]}"
|
||||
other_fs_dir.mkdir()
|
||||
try:
|
||||
real = other_fs_dir / "config.yaml"
|
||||
real.write_text("old\n", encoding="utf-8")
|
||||
if os.stat(real).st_dev == os.stat(tmp_path).st_dev:
|
||||
pytest.skip("/dev/shm is not a separate filesystem here")
|
||||
|
||||
link = tmp_path / "config.yaml"
|
||||
link.symlink_to(real)
|
||||
tmp = _write_tmp(tmp_path, "new\n")
|
||||
|
||||
assert Path(atomic_replace(tmp, link)) == real
|
||||
assert link.is_symlink()
|
||||
assert real.read_text(encoding="utf-8") == "new\n"
|
||||
assert not tmp.exists()
|
||||
finally:
|
||||
_shutil.rmtree(other_fs_dir, ignore_errors=True)
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
"""Regression: installer fails when the existing checkout has an unmerged index.
|
||||
|
||||
A previously interrupted update can leave ``$INSTALL_DIR`` with unmerged index
|
||||
entries (files in a conflicted, "needs merge" state). In that state the update
|
||||
path's ``git stash`` aborts with "could not write index" and the following
|
||||
``git checkout <branch>`` aborts with "you need to resolve your current index
|
||||
first" -- surfacing to GUI/bootstrap users as ``git checkout main failed
|
||||
(exit 1)`` and failing the whole install at the repository stage.
|
||||
|
||||
The ``hermes update`` Python path already clears the conflict with ``git reset``
|
||||
before stashing (#4735); both installer scripts must do the same.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
INSTALL_SH = REPO_ROOT / "scripts" / "install.sh"
|
||||
INSTALL_PS1 = REPO_ROOT / "scripts" / "install.ps1"
|
||||
|
||||
pytestmark = pytest.mark.skipif(
|
||||
shutil.which("git") is None or shutil.which("bash") is None,
|
||||
reason="needs git and bash",
|
||||
)
|
||||
|
||||
|
||||
def _git(cwd: Path, *args: str, check: bool = True) -> subprocess.CompletedProcess:
|
||||
return subprocess.run(
|
||||
["git", "-c", "user.email=t@t", "-c", "user.name=t", *args],
|
||||
cwd=cwd,
|
||||
check=check,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
)
|
||||
|
||||
|
||||
def _extract_autostash_block() -> str:
|
||||
"""Pull the autostash if-block from install.sh's update_repo()."""
|
||||
text = INSTALL_SH.read_text()
|
||||
m = re.search(
|
||||
r'local autostash_ref="".*?\n fi\n',
|
||||
text,
|
||||
re.DOTALL,
|
||||
)
|
||||
assert m is not None, "autostash block not found in install.sh"
|
||||
return m.group(0)
|
||||
|
||||
|
||||
def _make_unmerged_repo(repo: Path) -> None:
|
||||
"""Leave ``repo`` with a conflicted (unmerged) index, as an interrupted
|
||||
update would."""
|
||||
_git(repo, "init")
|
||||
(repo / "f.txt").write_text("base\n")
|
||||
_git(repo, "add", "f.txt")
|
||||
_git(repo, "commit", "-m", "base")
|
||||
# Capture the default branch name only after the first commit exists
|
||||
# (rev-parse on an unborn HEAD errors).
|
||||
start = _git(repo, "rev-parse", "--abbrev-ref", "HEAD").stdout.strip()
|
||||
|
||||
_git(repo, "checkout", "-b", "feature")
|
||||
(repo / "f.txt").write_text("feature side\n")
|
||||
_git(repo, "add", "f.txt")
|
||||
_git(repo, "commit", "-m", "feature")
|
||||
|
||||
_git(repo, "checkout", start)
|
||||
(repo / "f.txt").write_text("main side\n")
|
||||
_git(repo, "add", "f.txt")
|
||||
_git(repo, "commit", "-m", "mainside")
|
||||
|
||||
# Conflicting merge — exits non-zero and leaves the index unmerged.
|
||||
_git(repo, "merge", "feature", check=False)
|
||||
|
||||
|
||||
@pytest.mark.live_system_guard_bypass # runs against a dedicated throwaway repo
|
||||
def test_install_sh_clears_unmerged_index_then_stashes(tmp_path: Path) -> None:
|
||||
repo = tmp_path / "hermes-agent"
|
||||
repo.mkdir()
|
||||
_make_unmerged_repo(repo)
|
||||
|
||||
# Sanity: this is exactly the state that breaks `git stash` / `git checkout`.
|
||||
assert _git(repo, "ls-files", "--unmerged").stdout.strip(), (
|
||||
"test setup failed to produce an unmerged index"
|
||||
)
|
||||
|
||||
block = _extract_autostash_block()
|
||||
script = (
|
||||
"set -e\n"
|
||||
'log_info() { echo "INFO: $*"; }\n'
|
||||
"run() {\n"
|
||||
f"{block}"
|
||||
"}\n"
|
||||
"run\n"
|
||||
"echo BLOCK_OK\n"
|
||||
)
|
||||
res = subprocess.run(
|
||||
["bash", "-c", script], cwd=repo, capture_output=True, text=True
|
||||
)
|
||||
|
||||
# The block must complete (previously `git stash` failed with "could not
|
||||
# write index" on the unmerged tree).
|
||||
assert res.returncode == 0, res.stderr
|
||||
assert "BLOCK_OK" in res.stdout
|
||||
assert "Clearing unmerged index entries" in res.stdout
|
||||
|
||||
# The conflict state is gone ...
|
||||
assert _git(repo, "ls-files", "--unmerged").stdout.strip() == "", (
|
||||
"unmerged entries should have been cleared"
|
||||
)
|
||||
# ... and the local changes were preserved in a stash, not discarded.
|
||||
assert _git(repo, "stash", "list").stdout.strip(), (
|
||||
"local changes should be preserved in a stash"
|
||||
)
|
||||
|
||||
|
||||
def test_install_ps1_clears_unmerged_index_before_stash() -> None:
|
||||
"""install.ps1 must clear an unmerged index before stash/checkout, and do
|
||||
so *before* the stash push (order matters — the fix is a no-op otherwise)."""
|
||||
text = INSTALL_PS1.read_text()
|
||||
assert "ls-files --unmerged" in text, (
|
||||
"install.ps1 must detect an unmerged index before updating"
|
||||
)
|
||||
idx_unmerged = text.index("ls-files --unmerged")
|
||||
idx_reset = text.index("reset -q", idx_unmerged)
|
||||
idx_stash = text.index("stash push --include-untracked")
|
||||
assert idx_unmerged < idx_stash, (
|
||||
"the unmerged-index clear must run before `git stash push`"
|
||||
)
|
||||
assert idx_reset < idx_stash, "`git reset` must run before `git stash push`"
|
||||
|
||||
|
||||
def test_install_sh_clears_unmerged_index_before_stash_source_order() -> None:
|
||||
"""Same ordering contract for install.sh's source."""
|
||||
text = INSTALL_SH.read_text()
|
||||
assert "ls-files --unmerged" in text
|
||||
idx_unmerged = text.index("ls-files --unmerged")
|
||||
idx_stash = text.index("stash push --include-untracked")
|
||||
assert idx_unmerged < idx_stash
|
||||
@@ -847,6 +847,41 @@ def test_history_to_messages_preserves_tool_calls_for_resume_display():
|
||||
]
|
||||
|
||||
|
||||
def test_history_to_messages_keeps_reasoning_only_assistant_turn():
|
||||
# A thinking-only assistant turn (reasoning present, no visible text) is
|
||||
# persisted and recallable, but was dropped from the resumed session view
|
||||
# as "empty" -- so it vanished while the agent could still recall it from
|
||||
# the transcript. Keep it (with reasoning) so the desktop "Thinking…"
|
||||
# disclosure renders. (#44022)
|
||||
history = [
|
||||
{"role": "user", "content": "think about this"},
|
||||
{"role": "assistant", "content": "", "reasoning": "step-by-step thoughts"},
|
||||
{"role": "assistant", "content": "here is the answer"},
|
||||
]
|
||||
|
||||
assert server._history_to_messages(history) == [
|
||||
{"role": "user", "text": "think about this"},
|
||||
{"role": "assistant", "text": "", "reasoning": "step-by-step thoughts"},
|
||||
{"role": "assistant", "text": "here is the answer"},
|
||||
]
|
||||
|
||||
|
||||
def test_history_to_messages_still_drops_empty_assistant_without_reasoning():
|
||||
# A genuinely empty assistant turn (no text, no reasoning, no tool calls)
|
||||
# remains filtered out -- the fix only spares reasoning-bearing turns.
|
||||
history = [
|
||||
{"role": "user", "content": "hi"},
|
||||
{"role": "assistant", "content": "", "reasoning": ""},
|
||||
{"role": "assistant", "content": " "},
|
||||
{"role": "assistant", "content": "real reply"},
|
||||
]
|
||||
|
||||
assert server._history_to_messages(history) == [
|
||||
{"role": "user", "text": "hi"},
|
||||
{"role": "assistant", "text": "real reply"},
|
||||
]
|
||||
|
||||
|
||||
def test_history_to_messages_renders_multimodal_content():
|
||||
# bb/gui preserves image URLs in the resume payload so the desktop
|
||||
# renderer's extractEmbeddedImages can pull them back out and display
|
||||
@@ -971,6 +1006,35 @@ def test_session_resume_passes_stored_runtime_to_agent(monkeypatch):
|
||||
assert server._sessions[runtime_sid]["model_override"] == captured["model_override"]
|
||||
|
||||
|
||||
def test_stored_session_runtime_overrides_skips_bare_billing_provider():
|
||||
"""A bare billing bucket ("custom"/"auto"/"openrouter") must not be restored as the
|
||||
provider identity on resume. A custom endpoint that never used `/model` persists only
|
||||
`billing_provider="custom"`; restoring that broke `session.resume` with "No LLM provider
|
||||
configured" (agent_init treats it as non-routable). A real provider, or an explicit
|
||||
`model_config.provider`, is still restored.
|
||||
"""
|
||||
# Bare "custom" bucket, no explicit model_config.provider: no provider override restored.
|
||||
ov = server._stored_session_runtime_overrides({"model": "my-model", "billing_provider": "custom"})
|
||||
assert "provider_override" not in ov
|
||||
assert ov["model_override"]["provider"] is None
|
||||
|
||||
for bare in ("auto", "openrouter", "custom"):
|
||||
ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": bare})
|
||||
assert "provider_override" not in ov
|
||||
|
||||
# A real provider in billing_provider is still restored.
|
||||
ov = server._stored_session_runtime_overrides({"model": "m", "billing_provider": "anthropic"})
|
||||
assert ov["provider_override"] == "anthropic"
|
||||
assert ov["model_override"]["provider"] == "anthropic"
|
||||
|
||||
# An explicit routable provider in model_config wins over the bare billing bucket.
|
||||
ov = server._stored_session_runtime_overrides(
|
||||
{"model": "m", "billing_provider": "custom", "model_config": {"provider": "custom:myendpoint"}}
|
||||
)
|
||||
assert ov["provider_override"] == "custom:myendpoint"
|
||||
assert ov["model_override"]["provider"] == "custom:myendpoint"
|
||||
|
||||
|
||||
def test_persist_live_session_runtime_preserves_resume_metadata(monkeypatch):
|
||||
updates = {}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user