Merge master into stage-5155 (catch up to v0.51.728)

This commit is contained in:
nesquena-hermes committed 2026-06-28 23:25:57 +00:00
commit 4d79de266c
12 files changed
+1301 -40

No files matched your search

+3
View File
@@ -24,6 +24,9 @@
### Fixed
- **Provider auth failures (e.g. an invalidated 401 token) now surface as a visible error turn in chat instead of a silent empty assistant turn.** Some terminal provider failures returned a result that escaped the existing `apperror` settlement, so the WebUI finalized the turn "done" with an empty body — no `stream error`, no inline error, and a saved transcript that pretended the assistant never responded (so a reload disagreed with what you just saw). The settlement path now evaluates terminal failure against the *merged, save-ready* transcript rather than the raw agent result, and persists the same `_error` turn + `apperror` payload the exception path already uses (even after partially-streamed output). User cancel/interrupt is still never mislabeled as a provider failure, and a legitimate repeated assistant answer still completes normally. Thanks @rodboev. (#5129, fixes #5121)
- **Background-process wakeups on a slash-qualified custom provider no longer fail with "Invalid model format."** When a process wakeup took the fast model-resolution path, a bare runtime model id (`grok-composer-2.5-fast`) could reach an OpenAI-compatible proxy that requires the slash-qualified form (`x-ai/grok-composer-2.5-fast`), so the upstream rejected the turn with HTTP 400 exactly when a `terminal(notify_on_complete=true)` wakeup needed to consume completion output. The resolver now repairs a suffix-only custom-provider model to the profile's qualified default — gated on an exact suffix match **and** the profile's configured provider matching the session's requested provider, so it can never re-point a different custom provider's bare model. Thanks @claw-io (co-author @b3nw). (#5128, fixes #5127)
- **Gateway-mode approval prompts now distinguish "gateway offline" from "gateway too old."** When the WebUI ran in gateway mode and the gateway process was down, an approval-requiring turn showed a generic "Approvals not supported" toast — which reads like a feature limitation, not a connectivity failure, so a user had no reason to check whether the gateway was running. The capability probe now tracks whether `/v1/capabilities` was reachable: a genuine connection failure surfaces "Gateway connection failed — check that the connected Hermes gateway is running and reachable," while a reachable-but-older gateway (HTTP error or a slow/timed-out probe) keeps the existing "Approvals require a newer gateway" message. Thanks @rodboev. (#5151, fixes #5139)
- **Switching sessions feels snappier — non-critical refreshes now happen after the first paint.** The session-switch path deferred three non-essential operations (the workspace-tree/git-badge refresh, the model-dropdown option-list refresh, and a redundant empty-composer-draft save) until after the new session renders, instead of blocking the switch on them. Each deferred op re-checks the active session id at fire time, so a refresh scheduled for a session you've already switched away from is dropped rather than applied to the wrong session; non-empty composer drafts are never skipped. (Internal: added `requestIdleCallback`/`cancelIdleCallback` to the static-JS scope-undef gate's browser-globals allowlist.) Thanks @santastabber. (#5117)
- **Stale approval cards now clear on stream teardown in legacy gateway mode.** When the WebUI ran against a legacy gateway backend, an approval card could linger after the underlying approval was already resolved/gone, until the next fallback poll reconciled it. Stream teardown now reconciles the gateway approval mirrors against the live queue (under the approval lock), so a stale card clears as soon as teardown proves the approval is gone — still-pending gateway and local approvals are preserved. Thanks @rodboev. (#5147, fixes #5135)
- **A pinned reader no longer gets bounced off the live tail when a streaming turn rebuilds its rows.** While a response streams, live activity/worklog DOM updates rebuild rows; for a reader pinned to the live tail, restoring the first-visible viewport anchor could remount an older row and jump them away from the bottom. Pinned/following readers now keep a tail-relative scroll position across the rebuild (restored before the semantic anchor pass), while explicitly-unpinned/manual reader positions still use the semantic viewport-anchor restore. Thanks @santastabber. (#5118)
+42 -3
View File
@@ -17,6 +17,7 @@ import logging
import os
import queue
import re
import socket
import sys
import threading
import time
@@ -7433,6 +7434,15 @@ _GATEWAY_CAPS_LOCK = threading.Lock()
_GATEWAY_CAPS_TTL_S: float = 60.0
def _gateway_caps_probe_timed_out(exc: BaseException) -> bool:
"""Keep slow capability probes on the legacy reachable-but-unsupported path."""
reason = exc.reason if isinstance(exc, urllib.error.URLError) else exc
if isinstance(reason, (TimeoutError, socket.timeout)):
return True
reason_text = str(reason).lower()
return "timed out" in reason_text or "timeout" in reason_text
def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
"""Return cached gateway capability flags, probing /v1/capabilities if stale."""
base_url = str(base_url or "").rstrip("/")
@@ -7443,21 +7453,40 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
cached = _GATEWAY_CAPS_CACHE.get(cache_key)
if cached and now - cached.get("fetched_at", 0) < _GATEWAY_CAPS_TTL_S:
return cached
caps = {"approval_events": False, "run_approval_response": False, "fetched_at": 0.0}
caps = {
"approval_events": False,
"run_approval_response": False,
"capabilities_reachable": False,
"probe_error": None,
"fetched_at": 0.0,
}
try:
headers = {}
if api_key:
headers["Authorization"] = f"Bearer {api_key}"
req = urllib.request.Request(f"{base_url}/v1/capabilities", headers=headers, method="GET")
with urllib.request.urlopen(req, timeout=3) as resp:
caps["capabilities_reachable"] = True
body = json.loads(resp.read(65536))
features = body.get("features") if isinstance(body, dict) else {}
if not isinstance(features, dict):
features = {}
caps["approval_events"] = bool(features.get("approval_events"))
caps["run_approval_response"] = bool(features.get("run_approval_response"))
except Exception:
pass
except urllib.error.HTTPError as exc:
caps["capabilities_reachable"] = True
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
except urllib.error.URLError as exc:
if _gateway_caps_probe_timed_out(exc):
caps["capabilities_reachable"] = True
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
except (TimeoutError, socket.timeout) as exc:
caps["capabilities_reachable"] = True
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
except OSError as exc:
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
except Exception as exc:
caps["probe_error"] = f"{type(exc).__name__}: {exc}"
with _GATEWAY_CAPS_LOCK:
current = _GATEWAY_CAPS_CACHE.get(cache_key)
if current and current.get("fetched_at", 0) > probe_started_at:
@@ -7467,6 +7496,16 @@ def get_gateway_caps(base_url: str, api_key: str = "") -> dict:
return caps
def gateway_approval_unavailable_reason(base_url: str, api_key: str = "") -> str | None:
"""Return why approval support is unavailable, if it is unavailable."""
caps = get_gateway_caps(base_url, api_key)
if bool(caps.get("approval_events") and caps.get("run_approval_response")):
return None
if not caps.get("capabilities_reachable"):
return "unreachable"
return "unsupported"
def gateway_supports_approval(base_url: str, api_key: str = "") -> bool:
"""True only when the gateway advertises both approval_events and run_approval_response."""
caps = get_gateway_caps(base_url, api_key)
+10 -3
View File
@@ -20,6 +20,7 @@ from api.config import (
STREAM_REASONING_TEXT,
_get_session_agent_lock,
coerce_reasoning_effort_for_model,
gateway_approval_unavailable_reason,
gateway_supports_approval,
register_active_run,
unregister_active_run,
@@ -653,13 +654,19 @@ def _run_gateway_chat_streaming(
else:
# Legacy gateway path: emit unsupported approval notice once per session,
# but only when the gateway genuinely lacks approval capability.
if not gateway_supports_approval(base_url, api_key):
approval_reason = gateway_approval_unavailable_reason(base_url, api_key)
if approval_reason is not None:
if not hasattr(s, "_approval_notice_emitted"):
s._approval_notice_emitted = False
if not s._approval_notice_emitted:
approval_message = "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this."
approval_type = "approval_gateway_unsupported"
if approval_reason == "unreachable":
approval_type = "approval_gateway_offline"
approval_message = "Gateway connection failed. Check that the connected Hermes gateway is running and reachable."
put_gateway_event("warning", {
"type": "approval_gateway_unsupported",
"message": "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.",
"type": approval_type,
"message": approval_message,
})
s._approval_notice_emitted = True
+47 -9
View File
@@ -5215,31 +5215,52 @@ def _read_profile_model_config(
) -> tuple[str | None, str | None]:
"""Read model.provider and model.default from the session's profile config.
Returns (profile_provider, profile_default_model). Both are None when
the session has no profile, the profile config is unreadable, or an
explicit ``requested_provider`` is already set (profile should not
override explicit selections).
Returns (profile_provider, profile_default_model). Both are None when the
session has no profile or the profile config is unreadable.
When the session already has an explicit ``requested_provider``, the profile
``model.provider`` is not returned (first tuple element is None) so profile
does not override the session provider. ``profile_default_model`` is still
returned for suffix repair (#5127) only when the profile's configured
provider matches ``requested_provider`` after normalization.
"""
if _clean_session_model_provider(requested_provider) or not getattr(session, "profile", None):
if not getattr(session, "profile", None):
return None, None
try:
from api.profiles import get_hermes_home_for_profile
_profile_home = get_hermes_home_for_profile(session.profile)
_profile_cfg_path = os.path.join(str(_profile_home), "config.yaml")
if not os.path.isfile(_profile_cfg_path):
return None, None
import yaml
with open(_profile_cfg_path, encoding="utf-8") as _f:
_pcfg = yaml.safe_load(_f) or {}
if not isinstance(_pcfg, dict):
return None, None
_provider = (_pcfg.get("model", {}).get("provider") or "").strip() or None
_default = (_pcfg.get("model", {}).get("default") or "").strip() or None
return _provider, _default
_model_cfg = _pcfg.get("model") or {}
if not isinstance(_model_cfg, dict):
return None, None
_provider = (_model_cfg.get("provider") or "").strip() or None
_default = (_model_cfg.get("default") or "").strip() or None
except Exception:
logger.warning("profile provider read failed for %r", getattr(session, "profile", None), exc_info=True)
logger.warning(
"profile provider read failed for %r",
getattr(session, "profile", None),
exc_info=True,
)
return None, None
_requested = _clean_session_model_provider(requested_provider)
if _requested:
_profile_prov = _clean_session_model_provider(_provider)
if _profile_prov != _requested:
return None, None
return None, _default
return _provider, _default
def _resolve_compatible_session_model_state(
model_id: str | None,
@@ -5303,6 +5324,23 @@ def _resolve_compatible_session_model_state(
and model_prefix == "openai"
)
if not explicit_provider and not stale_codex_openai_slash_id:
_profile_default = str(profile_default_model or "").strip()
_profile_prov = _clean_session_model_provider(profile_provider)
_providers_match_for_repair = (
_profile_prov is None or _profile_prov == requested_provider
)
if (
_profile_default
and "/" in _profile_default
and "/" not in model
and _profile_default.rsplit("/", 1)[-1] == model
and _providers_match_for_repair
and (
requested_provider == "custom"
or str(requested_provider).startswith("custom:")
)
):
return _profile_default, requested_provider, True
return model, requested_provider, False
# Default (human chat/start) path calls get_available_models() with NO
+148 -17
View File
@@ -1100,13 +1100,61 @@ def _cancelled_turn_hint(agent_name: str | None = None) -> str:
return f'The run was cancelled by the user before {name} finished. No provider failure occurred.'
def _provider_error_probe_text(value) -> tuple[str, int | None]:
"""Flatten structured provider-error payloads into searchable text."""
_texts: list[str] = []
_status_code: int | None = None
_seen: set[int] = set()
def _walk(node):
nonlocal _status_code
if node is None:
return
if isinstance(node, (dict, list, tuple, set)):
_node_id = id(node)
if _node_id in _seen:
return
_seen.add(_node_id)
if isinstance(node, dict):
for _key in ('type', 'code', 'message', 'detail', 'details', 'name', 'status', 'status_code'):
_val = node.get(_key)
if _val is None:
continue
if _key in ('status', 'status_code') and _status_code is None:
try:
_status_code = int(_val)
except Exception:
pass
_texts.append(str(_val))
for _key, _val in node.items():
if _key in ('type', 'code', 'message', 'detail', 'details', 'name', 'status', 'status_code'):
continue
_walk(_val)
return
if isinstance(node, (list, tuple, set)):
for _item in node:
_walk(_item)
return
_texts.append(str(node))
_walk(value)
return ' '.join(t for t in _texts if t).strip(), _status_code
def _classify_provider_error(err_str: str, exc=None, *, silent_failure: bool = False) -> dict:
"""Classify provider/agent failure text for WebUI apperror UX.
Keep this string-based until hermes-agent exposes stable structured
provider error classes for Codex OAuth plan limits.
"""
err_str = str(err_str or '')
_probe_text, _probe_status_code = _provider_error_probe_text(err_str)
if exc is not None:
_exc_probe_text, _exc_status_code = _provider_error_probe_text(exc)
if _exc_probe_text:
_probe_text = f"{_probe_text} {_exc_probe_text}".strip()
if _probe_status_code is None:
_probe_status_code = _exc_status_code
err_str = str(_probe_text or err_str or '')
_err_lower = err_str.lower()
_exc_name = type(exc).__name__ if exc is not None else ''
_is_cancelled = (
@@ -1147,6 +1195,8 @@ def _classify_provider_error(err_str: str, exc=None, *, silent_failure: bool = F
_is_quota = _is_quota_error_text(err_str)
_is_auth = (
not _is_quota and (
_probe_status_code == 401
or
'401' in err_str
or (exc is not None and 'AuthenticationError' in _exc_name)
or 'authentication' in _err_lower
@@ -4890,8 +4940,22 @@ def _merge_display_messages_after_agent_result(previous_display, previous_contex
previous_user_tail,
previous_context=previous_context,
)
candidates = _strip_replayed_prefix(previous_display, candidates)
candidates = _strip_replayed_prefix(previous_context, candidates)
current_user_key = _message_identity({'role': 'user', 'content': msg_text})
current_user_in_candidates = any(
_message_identity(m) == current_user_key or _looks_like_current_user_turn(m, msg_text)
for m in candidates
)
assistant_or_tool_only_candidates = bool(candidates) and all(
_is_context_compression_marker(m)
or (
isinstance(m, dict)
and m.get('role') in ('assistant', 'tool')
)
for m in candidates
)
if not (assistant_or_tool_only_candidates and not current_user_in_candidates):
candidates = _strip_replayed_prefix(previous_display, candidates)
candidates = _strip_replayed_prefix(previous_context, candidates)
else:
current_user_idx = _find_current_user_turn(result_messages, msg_text)
turn_candidates = result_messages[current_user_idx:] if current_user_idx is not None else []
@@ -5056,6 +5120,64 @@ def _session_lacks_final_assistant_answer(messages) -> bool:
return True
def _merged_transcript_lacks_final_assistant_answer(
previous_display,
previous_context,
result_messages,
msg_text,
source: str = "webui",
drop_replayed_assistant: bool = False,
) -> bool:
"""Return True when the current turn still lacks a final assistant answer."""
previous_display = list(previous_display or [])
merged_messages = _merge_display_messages_after_agent_result(
previous_display,
previous_context,
_restore_reasoning_metadata(previous_display, result_messages),
msg_text,
source=source,
)
current_user_idx = _find_current_user_turn(merged_messages, msg_text)
if current_user_idx is None or current_user_idx < len(previous_display):
# The active turn lives after the durable transcript boundary. If the
# merged display only exposes an older user row, materialize the pending
# prompt so a replayed assistant row cannot satisfy the wrong turn.
pending_user = {
'role': 'user',
'content': msg_text,
}
if source and source != 'webui':
pending_user['_source'] = source
merged_messages.append(pending_user)
current_user_idx = len(merged_messages) - 1
current_user_key = _message_identity(merged_messages[current_user_idx])
filtered_messages = merged_messages[:current_user_idx + 1]
if drop_replayed_assistant:
prior_id_set = {
_message_identity(msg)
for msg in merged_messages[:current_user_idx]
if isinstance(msg, dict)
}
for msg in merged_messages[current_user_idx + 1:]:
if not isinstance(msg, dict):
filtered_messages.append(msg)
continue
if msg.get('role') == 'assistant':
key = _message_identity(msg)
if key is not None and key in prior_id_set:
continue
filtered_messages.append(msg)
else:
filtered_messages.extend(merged_messages[current_user_idx + 1:])
if current_user_key is not None:
filtered_messages = [
msg for msg in filtered_messages
if _message_identity(msg) != current_user_key or msg is merged_messages[current_user_idx]
]
return _session_lacks_final_assistant_answer(filtered_messages)
def _agent_result_terminal_failure(result) -> bool:
"""Return True for agent results that must not be finalized as done."""
if not isinstance(result, dict):
@@ -7973,15 +8095,32 @@ def _run_agent_streaming(
_previous_context_messages,
msg_text,
)
_last_err = getattr(agent, '_last_error', None) or result.get('error') or ''
_classification = _classify_provider_error(
str(_last_err) if _last_err else '',
_last_err,
silent_failure=not bool(_last_err),
)
_is_quota = _classification['type'] == 'quota_exhausted'
_is_auth = _classification['type'] == 'auth_mismatch'
_drop_replayed_assistant = (
_agent_result_terminal_failure(result)
or bool(getattr(agent, '_last_error', None))
or ('error' in result and result.get('error') is not None)
)
_saved_transcript_lacks_final_answer = _merged_transcript_lacks_final_assistant_answer(
_previous_messages,
_previous_context_messages,
_all_result_messages,
msg_text,
source=getattr(s, 'pending_user_source', None) or 'webui',
drop_replayed_assistant=_drop_replayed_assistant,
)
_terminal_failure = (
_agent_result_terminal_failure(result)
or (
_tool_limit_reached
and _session_lacks_final_assistant_answer(_all_result_messages)
)
or (
not _token_sent
and _session_lacks_final_assistant_answer(_all_result_messages)
_saved_transcript_lacks_final_answer
and _classification['type'] not in {'cancelled', 'interrupted'}
)
)
if _terminal_failure:
@@ -8007,15 +8146,7 @@ def _run_agent_streaming(
logger.debug("Failed to append cancelled turn journal event", exc_info=True)
put('cancel', _cancel_event_payload('Cancelled by user'))
return
_last_err = getattr(agent, '_last_error', None) or result.get('error') or ''
_err_str = str(_last_err) if _last_err else ''
_classification = _classify_provider_error(
_err_str,
_last_err,
silent_failure=not bool(_err_str),
)
_is_quota = _classification['type'] == 'quota_exhausted'
_is_auth = _classification['type'] == 'auth_mismatch'
if _is_quota:
_err_label = _classification['label']
_err_type = _classification['type']
+4
View File
@@ -5447,6 +5447,10 @@ function attachLiveStream(activeSid, streamId, uploaded=[], options={}){
if(typeof showToast==='function') showToast(typeof t==='function'?t('approval_gateway_unsupported_label'):'Approvals not supported',4000,'warning');
return;
}
if(d.type==='approval_gateway_offline'){
if(typeof showToast==='function') showToast(d.message||'Gateway offline',4000,'warning');
return;
}
// Show as a small inline notice, not a full error
setComposerStatus(`${d.message||'Warning'}`);
// If it's a fallback notice, show it briefly then clear
@@ -177,9 +177,10 @@ def test_terminal_failure_gates_shape_check_to_no_streamed_text():
block = src[start:end]
assert "_agent_result_terminal_failure(result)" in block
assert "not _token_sent" in block
assert "_session_lacks_final_assistant_answer(_all_result_messages)" in block
assert "not _assistant_added" not in block
assert "_saved_transcript_lacks_final_answer" in block
assert "_classification['type'] not in {'cancelled', 'interrupted'}" in block
assert "not _token_sent" not in block
assert "_session_lacks_final_assistant_answer(_all_result_messages)" not in block
def test_completed_tool_tail_without_final_assistant_is_not_successful_done():
+86
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import io
import json
import socket
import threading
import urllib.error
from unittest.mock import MagicMock, patch
@@ -15,7 +16,9 @@ from unittest.mock import MagicMock, patch
def test_gateway_capability_detection():
"""get_gateway_caps / gateway_supports_approval correctly parse /v1/capabilities."""
from api.config import (
gateway_approval_unavailable_reason,
gateway_supports_approval,
get_gateway_caps,
invalidate_gateway_caps,
)
@@ -38,6 +41,10 @@ def test_gateway_capability_detection():
return resp
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_capable):
caps = get_gateway_caps("http://fake:1234", "secret")
assert caps["capabilities_reachable"] is True
assert caps["probe_error"] is None
assert gateway_approval_unavailable_reason("http://fake:1234", "secret") is None
assert gateway_supports_approval("http://fake:1234", "secret") is True
invalidate_gateway_caps()
@@ -52,11 +59,90 @@ def test_gateway_capability_detection():
return resp
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_incapable):
caps = get_gateway_caps("http://fake:5678")
assert caps["capabilities_reachable"] is True
assert caps["probe_error"] is None
assert gateway_approval_unavailable_reason("http://fake:5678") == "unsupported"
assert gateway_supports_approval("http://fake:5678") is False
invalidate_gateway_caps()
def test_gateway_capability_detection_marks_probe_failures_unreachable():
"""Probe failures stay non-fatal but remain distinguishable from unsupported gateways."""
from api.config import (
gateway_approval_unavailable_reason,
gateway_supports_approval,
get_gateway_caps,
invalidate_gateway_caps,
)
invalidate_gateway_caps()
def _fake_urlopen_fail(req, *, timeout=None):
assert req.full_url == "http://fake:9999/v1/capabilities"
raise urllib.error.URLError(ConnectionRefusedError("connection refused"))
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_fail):
caps = get_gateway_caps("http://fake:9999", "secret")
assert caps["capabilities_reachable"] is False
assert caps["probe_error"]
assert gateway_approval_unavailable_reason("http://fake:9999", "secret") == "unreachable"
assert gateway_supports_approval("http://fake:9999", "secret") is False
invalidate_gateway_caps()
def test_gateway_capability_detection_treats_timeout_probe_as_reachable_unsupported():
"""Slow probes should preserve the reachable-but-unsupported warning contract."""
from api.config import (
gateway_approval_unavailable_reason,
gateway_supports_approval,
get_gateway_caps,
invalidate_gateway_caps,
)
invalidate_gateway_caps()
def _fake_urlopen_timeout(req, *, timeout=None):
assert req.full_url == "http://fake:8888/v1/capabilities"
raise socket.timeout("timed out")
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_timeout):
caps = get_gateway_caps("http://fake:8888", "secret")
assert caps["capabilities_reachable"] is True
assert caps["probe_error"]
assert gateway_approval_unavailable_reason("http://fake:8888", "secret") == "unsupported"
assert gateway_supports_approval("http://fake:8888", "secret") is False
invalidate_gateway_caps()
def test_gateway_capability_detection_treats_404_probe_as_reachable_unsupported():
"""Older reachable gateways can 404 /v1/capabilities without becoming "offline"."""
from api.config import (
gateway_approval_unavailable_reason,
gateway_supports_approval,
get_gateway_caps,
invalidate_gateway_caps,
)
invalidate_gateway_caps()
def _fake_urlopen_404(req, *, timeout=None):
assert req.full_url == "http://fake:7777/v1/capabilities"
raise urllib.error.HTTPError(req.full_url, 404, "Not Found", hdrs=None, fp=io.BytesIO(b""))
with patch("urllib.request.urlopen", side_effect=_fake_urlopen_404):
caps = get_gateway_caps("http://fake:7777", "secret")
assert caps["capabilities_reachable"] is True
assert caps["probe_error"]
assert gateway_approval_unavailable_reason("http://fake:7777", "secret") == "unsupported"
assert gateway_supports_approval("http://fake:7777", "secret") is False
invalidate_gateway_caps()
def test_gateway_capability_cache_keeps_fresher_success_on_probe_race():
"""A slower failed probe must not overwrite a fresher successful capability result."""
from api.config import gateway_supports_approval, invalidate_gateway_caps
@@ -17,17 +17,18 @@ def test_gateway_chat_has_approval_notice_emitted_attribute_check():
def test_gateway_chat_emits_approval_gateway_unsupported_event():
"""Verify put_gateway_event is called with approval_gateway_unsupported type on non-terminal channel."""
assert "put_gateway_event(\"warning\"" in GATEWAY_CHAT
assert "\"type\": \"approval_gateway_unsupported\"" in GATEWAY_CHAT
assert "approval_type = \"approval_gateway_unsupported\"" in GATEWAY_CHAT
def test_gateway_chat_once_per_session_guard_pattern():
"""Verify the once-per-session guard: capability check + hasattr + flag check + flag set."""
assert "if not gateway_supports_approval(base_url, api_key):" in GATEWAY_CHAT
assert "approval_reason = gateway_approval_unavailable_reason(base_url, api_key)" in GATEWAY_CHAT
assert "if approval_reason is not None:" in GATEWAY_CHAT
assert "if not hasattr(s, \"_approval_notice_emitted\"):" in GATEWAY_CHAT
assert "if not s._approval_notice_emitted:" in GATEWAY_CHAT
assert "s._approval_notice_emitted = True" in GATEWAY_CHAT
# Verify order: capability gate before session guard before flag set
cap_pos = GATEWAY_CHAT.find("if not gateway_supports_approval(base_url, api_key):")
cap_pos = GATEWAY_CHAT.find("if approval_reason is not None:")
hasattr_pos = GATEWAY_CHAT.find("if not hasattr(s, \"_approval_notice_emitted\"):")
flag_check_pos = GATEWAY_CHAT.find("if not s._approval_notice_emitted:")
flag_set_pos = GATEWAY_CHAT.find("s._approval_notice_emitted = True")
@@ -36,8 +37,8 @@ def test_gateway_chat_once_per_session_guard_pattern():
def test_gateway_chat_event_payload_contains_type_and_message():
"""Verify the event payload has type and message fields."""
assert "\"type\": \"approval_gateway_unsupported\"" in GATEWAY_CHAT
assert "\"message\": \"Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.\"" in GATEWAY_CHAT
assert "approval_type = \"approval_gateway_unsupported\"" in GATEWAY_CHAT
assert "approval_message = \"Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this.\"" in GATEWAY_CHAT
def test_messages_js_handles_approval_gateway_unsupported_event():
@@ -0,0 +1,544 @@
"""Regression tests for issue #5121: provider auth failures must persist as terminal error turns."""
from __future__ import annotations
import queue
import sys
import types
from unittest import mock
import pytest
import api.config as config
import api.models as models
import api.streaming as streaming
from api.models import Session
@pytest.fixture(autouse=True)
def _isolate_session_dir(tmp_path, monkeypatch):
session_dir = tmp_path / "sessions"
session_dir.mkdir()
index_file = session_dir / "_index.json"
monkeypatch.setattr(models, "SESSION_DIR", session_dir)
monkeypatch.setattr(models, "SESSION_INDEX_FILE", index_file)
models.SESSIONS.clear()
yield
models.SESSIONS.clear()
@pytest.fixture(autouse=True)
def _isolate_stream_state():
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.STREAM_PARTIAL_TEXT.clear()
if hasattr(config, "STREAM_REASONING_TEXT"):
config.STREAM_REASONING_TEXT.clear()
if hasattr(config, "STREAM_LIVE_TOOL_CALLS"):
config.STREAM_LIVE_TOOL_CALLS.clear()
yield
config.STREAMS.clear()
config.CANCEL_FLAGS.clear()
config.AGENT_INSTANCES.clear()
config.STREAM_PARTIAL_TEXT.clear()
if hasattr(config, "STREAM_REASONING_TEXT"):
config.STREAM_REASONING_TEXT.clear()
if hasattr(config, "STREAM_LIVE_TOOL_CALLS"):
config.STREAM_LIVE_TOOL_CALLS.clear()
@pytest.fixture(autouse=True)
def _isolate_agent_locks():
config.SESSION_AGENT_LOCKS.clear()
yield
config.SESSION_AGENT_LOCKS.clear()
@pytest.fixture(autouse=True)
def _neutralize_credential_self_heal(monkeypatch):
"""Make the 401 self-heal path a deterministic no-op by default.
The terminal-auth-failure tests assert that an unrecoverable 401 surfaces
an ``apperror`` / persisted ``_error`` turn. The streaming settlement path
first tries ``_attempt_credential_self_heal`` (#1401), which calls
``read_auth_json()``. On a host with a populated ``~/.hermes/auth.json``
(e.g. a developer's real Hermes box) self-heal can succeed and silently
retry the mock agent, swallowing the error the test expects — so the
outcome would depend on host credentials. CI / Windows boxes have no such
credentials, which is why the tests pass there but fail on a live agent
host. Force self-heal off by default so every host exercises the
unrecoverable-failure path; the one test that intentionally verifies a
successful retry patches this symbol explicitly inside its own body.
"""
monkeypatch.setattr(streaming, "_attempt_credential_self_heal", lambda *a, **k: None)
yield
@pytest.fixture(autouse=True)
def _mock_hermes_modules(monkeypatch):
fake_runtime_module = types.ModuleType("hermes_cli.runtime_provider")
fake_runtime_module.resolve_runtime_provider = lambda requested=None, **_kw: {
"provider": requested or "test-provider",
"api_key": "synthetic-key",
"base_url": None,
}
fake_hermes_cli = types.ModuleType("hermes_cli")
fake_hermes_cli.runtime_provider = fake_runtime_module
fake_hermes_state = types.ModuleType("hermes_state")
fake_hermes_state.SessionDB = mock.Mock(return_value=None)
injected = {
"hermes_cli": fake_hermes_cli,
"hermes_cli.runtime_provider": fake_runtime_module,
"hermes_state": fake_hermes_state,
}
missing = object()
saved = {k: sys.modules.get(k, missing) for k in injected}
sys.modules.update(injected)
yield
for name, prev in saved.items():
if prev is missing:
sys.modules.pop(name, None)
else:
sys.modules[name] = prev
class MockAgent:
def __init__(self, **kwargs):
self.session_id = kwargs.get("session_id")
self.stream_delta_callback = kwargs.get("stream_delta_callback")
self.reasoning_callback = kwargs.get("reasoning_callback")
self.tool_progress_callback = kwargs.get("tool_progress_callback")
self.session_prompt_tokens = 0
self.session_completion_tokens = 0
self.session_estimated_cost_usd = 0.0
self.context_compressor = None
self._last_error = None
self.ephemeral_system_prompt = None
def run_conversation(self, **kwargs):
raise NotImplementedError
def interrupt(self, _message):
pass
def _prepare_session(session_id: str, stream_id: str, *, pending_user_message: str, partial_source: str = "cli"):
session = Session(session_id=session_id, title="Test Session")
session.messages = []
session.context_messages = []
session.pending_user_message = pending_user_message
session.pending_attachments = ["attachment.txt"]
session.pending_started_at = 1234567890.0
session.pending_user_source = partial_source
session.active_stream_id = stream_id
session.save()
models.SESSIONS[session_id] = session
return session
def _seed_prior_turn(session, *, prior_user: str, prior_assistant: str):
session.messages = [
{"role": "user", "content": prior_user, "timestamp": 1},
{"role": "assistant", "content": prior_assistant, "timestamp": 2},
]
session.context_messages = [
{"role": "user", "content": prior_user},
{"role": "assistant", "content": prior_assistant},
]
session.save()
def _queue_events(fake_queue):
return [(item[0], item[1]) for item in list(fake_queue.queue)]
def _auth_failure_error_payload():
return {
"error": {
"type": "authentication_error",
"status_code": 401,
"code": "auth_unavailable",
"message": "Your authentication token has been invalidated. Please try signing in again.",
}
}
def _build_auth_failure_agent(*, token_text: str | None, success_text: str = "Recovered auth reply"):
class AuthFailureAgent(MockAgent):
runs = 0
def run_conversation(self, **kwargs):
type(self).runs += 1
history = list(kwargs.get("conversation_history") or [])
if type(self).runs == 1:
if self.stream_delta_callback is not None and token_text is not None:
self.stream_delta_callback(token_text)
return {
"messages": history,
"error": _auth_failure_error_payload(),
}
return {
"status": "ok",
"messages": history + [{"role": "assistant", "content": success_text}],
}
return AuthFailureAgent
def _run_stream(monkeypatch, session, stream_id, agent_cls, *, workspace):
fake_queue = queue.Queue()
streaming.STREAMS[stream_id] = fake_queue
config.STREAM_PARTIAL_TEXT[stream_id] = ""
with mock.patch.object(streaming, "get_session", return_value=session), \
mock.patch.object(streaming, "_get_ai_agent", return_value=agent_cls), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]):
streaming._run_agent_streaming(
session_id=session.session_id,
msg_text=session.pending_user_message,
model="test-model",
workspace=workspace,
stream_id=stream_id,
)
return fake_queue
def test_auth_401_without_delivery_persists_error_turn(tmp_path, monkeypatch):
session = _prepare_session("auth_no_delivery", "stream_auth_no_delivery", pending_user_message="Please respond")
agent_cls = _build_auth_failure_agent(token_text=None)
fake_queue = _run_stream(monkeypatch, session, "stream_auth_no_delivery", agent_cls, workspace=str(tmp_path))
saved = Session.load("auth_no_delivery")
assert saved is not None
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors, "expected apperror for auth failure"
assert apperrors[-1]["type"] == "auth_mismatch"
assert not any(event == "done" for event, _ in events)
assert saved.active_stream_id is None
assert saved.pending_user_message is None
assert saved.pending_attachments == []
assert saved.pending_started_at is None
assert saved.pending_user_source is None
assert saved.messages[-1]["_error"] is True
assert saved.messages[-1]["role"] == "assistant"
assert any(msg.get("role") == "user" for msg in saved.messages)
def test_auth_401_after_partial_preserves_partial_then_error(tmp_path, monkeypatch):
session = _prepare_session("auth_partial", "stream_auth_partial", pending_user_message="Please stream then fail")
agent_cls = _build_auth_failure_agent(token_text="Partial auth text")
fake_queue = _run_stream(monkeypatch, session, "stream_auth_partial", agent_cls, workspace=str(tmp_path))
saved = Session.load("auth_partial")
assert saved is not None
partial = next((msg for msg in saved.messages if msg.get("_partial")), None)
assert partial is not None
assert partial["role"] == "assistant"
assert partial["content"] == "Partial auth text"
error_idx = next(i for i, msg in enumerate(saved.messages) if msg.get("_error"))
partial_idx = saved.messages.index(partial)
assert partial_idx < error_idx
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors and apperrors[-1]["type"] == "auth_mismatch"
def test_auth_401_seeded_multi_turn_partial_persists_error_turn(tmp_path, monkeypatch):
session = _prepare_session("auth_seeded_partial", "stream_auth_seeded_partial", pending_user_message="Please stream then fail")
_seed_prior_turn(
session,
prior_user="Earlier question",
prior_assistant="Earlier answer",
)
agent_cls = _build_auth_failure_agent(token_text="Partial auth text")
fake_queue = _run_stream(monkeypatch, session, "stream_auth_seeded_partial", agent_cls, workspace=str(tmp_path))
saved = Session.load("auth_seeded_partial")
assert saved is not None
assert any(msg.get("role") == "assistant" and msg.get("content") == "Earlier answer" for msg in saved.messages)
assert any(msg.get("_partial") and msg.get("content") == "Partial auth text" for msg in saved.messages)
assert saved.messages[-1]["_error"] is True
assert saved.messages[-1]["role"] == "assistant"
assert any(msg.get("role") == "user" and msg.get("content") == "Please stream then fail" for msg in saved.messages)
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors and apperrors[-1]["type"] == "auth_mismatch"
assert not any(event == "done" for event, _ in events)
def test_auth_401_classification_receives_stringified_probe_text(tmp_path, monkeypatch):
session = _prepare_session("auth_probe_text", "stream_auth_probe_text", pending_user_message="Please fail")
agent_cls = _build_auth_failure_agent(token_text=None)
observed = {}
real_classify = streaming._classify_provider_error
def _spy_classify_provider_error(err_str, exc=None, *, silent_failure=False):
observed["err_str"] = err_str
observed["exc"] = exc
observed["silent_failure"] = silent_failure
return real_classify(err_str, exc, silent_failure=silent_failure)
with mock.patch.object(streaming, "_classify_provider_error", side_effect=_spy_classify_provider_error):
_run_stream(monkeypatch, session, "stream_auth_probe_text", agent_cls, workspace=str(tmp_path))
assert observed["err_str"] == str(_auth_failure_error_payload())
assert observed["exc"] == _auth_failure_error_payload()
assert observed["silent_failure"] is False
def test_auth_401_seeded_replayed_assistant_does_not_satisfy_current_turn(tmp_path, monkeypatch):
session = _prepare_session("auth_seeded_replay", "stream_auth_seeded_replay", pending_user_message="Please respond now")
_seed_prior_turn(
session,
prior_user="Earlier question",
prior_assistant="Earlier answer",
)
class ReplayAssistantAuthFailureAgent(MockAgent):
def run_conversation(self, **kwargs):
history = list(kwargs.get("conversation_history") or [])
return {
"messages": history + [{"role": "assistant", "content": "Earlier answer"}],
"error": _auth_failure_error_payload(),
}
fake_queue = _run_stream(monkeypatch, session, "stream_auth_seeded_replay", ReplayAssistantAuthFailureAgent, workspace=str(tmp_path))
saved = Session.load("auth_seeded_replay")
assert saved is not None
assert any(msg.get("role") == "user" and msg.get("content") == "Please respond now" for msg in saved.messages)
assert saved.messages[-1]["_error"] is True
assert saved.messages[-1]["role"] == "assistant"
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors and apperrors[-1]["type"] == "auth_mismatch"
assert not any(event == "done" for event, _ in events)
def test_auth_retry_success_does_not_append_error_turn(tmp_path, monkeypatch):
session = _prepare_session("auth_retry", "stream_auth_retry", pending_user_message="Please retry")
agent_cls = _build_auth_failure_agent(token_text="")
heal_rt = {
"provider": "test-provider",
"api_key": "fresh-key",
"base_url": None,
}
fake_queue = queue.Queue()
streaming.STREAMS["stream_auth_retry"] = fake_queue
config.STREAM_PARTIAL_TEXT["stream_auth_retry"] = ""
with mock.patch.object(streaming, "get_session", return_value=session), \
mock.patch.object(streaming, "_get_ai_agent", return_value=agent_cls), \
mock.patch.object(streaming, "resolve_model_provider", return_value=("test-model", "test-provider", None)), \
mock.patch("api.config.get_config", return_value={}), \
mock.patch("api.config._resolve_cli_toolsets", return_value=[]), \
mock.patch.object(streaming, "_attempt_credential_self_heal", return_value=heal_rt):
streaming._run_agent_streaming(
session_id=session.session_id,
msg_text=session.pending_user_message,
model="test-model",
workspace=str(tmp_path),
stream_id="stream_auth_retry",
)
saved = Session.load("auth_retry")
assert saved is not None
events = _queue_events(fake_queue)
assert not any(event == "apperror" for event, _ in events)
assert any(event == "done" for event, _ in events)
assert saved.messages[-1]["role"] == "assistant"
assert saved.messages[-1]["content"] == "Recovered auth reply"
assert not any(msg.get("_error") for msg in saved.messages)
def test_success_repeated_assistant_text_stays_successful_current_turn(tmp_path, monkeypatch):
session = _prepare_session("repeat_success", "stream_repeat_success", pending_user_message="Please say it again")
_seed_prior_turn(
session,
prior_user="Earlier question",
prior_assistant="Same answer",
)
class RepeatedSuccessAgent(MockAgent):
def run_conversation(self, **kwargs):
history = list(kwargs.get("conversation_history") or [])
return {
"messages": history + [{"role": "assistant", "content": "Same answer"}],
}
fake_queue = _run_stream(monkeypatch, session, "stream_repeat_success", RepeatedSuccessAgent, workspace=str(tmp_path))
saved = Session.load("repeat_success")
assert saved is not None
assert any(msg.get("role") == "user" and msg.get("content") == "Please say it again" for msg in saved.messages)
assert saved.messages[-1]["role"] == "assistant"
assert saved.messages[-1]["content"] == "Same answer"
assert not any(msg.get("_error") for msg in saved.messages)
events = _queue_events(fake_queue)
assert any(event == "done" for event, _ in events)
assert not any(event == "apperror" for event, _ in events)
def test_success_repeated_assistant_text_ignores_empty_error_field(tmp_path, monkeypatch):
session = _prepare_session("repeat_success_empty_error", "stream_repeat_success_empty_error", pending_user_message="Please say it again")
_seed_prior_turn(
session,
prior_user="Earlier question",
prior_assistant="Same answer",
)
class RepeatedSuccessWithEmptyErrorAgent(MockAgent):
def run_conversation(self, **kwargs):
history = list(kwargs.get("conversation_history") or [])
return {
"messages": history + [{"role": "assistant", "content": "Same answer"}],
"error": None,
}
fake_queue = _run_stream(
monkeypatch,
session,
"stream_repeat_success_empty_error",
RepeatedSuccessWithEmptyErrorAgent,
workspace=str(tmp_path),
)
saved = Session.load("repeat_success_empty_error")
assert saved is not None
assert any(msg.get("role") == "user" and msg.get("content") == "Please say it again" for msg in saved.messages)
assert saved.messages[-1]["role"] == "assistant"
assert saved.messages[-1]["content"] == "Same answer"
assert not any(msg.get("_error") for msg in saved.messages)
events = _queue_events(fake_queue)
assert any(event == "done" for event, _ in events)
assert not any(event == "apperror" for event, _ in events)
def test_non_auth_silent_failure_still_uses_no_response(tmp_path, monkeypatch):
session = _prepare_session("silent_failure", "stream_silent_failure", pending_user_message="Please handle silence")
class SilentFailureAgent(MockAgent):
def run_conversation(self, **kwargs):
return {
"messages": list(kwargs.get("conversation_history") or []),
"error": "",
}
fake_queue = _run_stream(monkeypatch, session, "stream_silent_failure", SilentFailureAgent, workspace=str(tmp_path))
saved = Session.load("silent_failure")
assert saved is not None
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors, "expected apperror for silent failure"
assert apperrors[-1]["type"] == "no_response"
assert apperrors[-1]["type"] != "auth_mismatch"
assert saved.messages[-1]["_error"] is True
def test_non_auth_partial_delivery_persists_error_turn(tmp_path, monkeypatch):
session = _prepare_session("partial_escape", "stream_partial_escape", pending_user_message="Please handle partial silence")
class PartialSilentFailureAgent(MockAgent):
def run_conversation(self, **kwargs):
if self.stream_delta_callback is not None:
self.stream_delta_callback("Partial text before failure")
return {
"messages": list(kwargs.get("conversation_history") or []),
"error": "",
}
fake_queue = _run_stream(monkeypatch, session, "stream_partial_escape", PartialSilentFailureAgent, workspace=str(tmp_path))
saved = Session.load("partial_escape")
assert saved is not None
partial = next((msg for msg in saved.messages if msg.get("_partial")), None)
assert partial is not None
assert partial["content"] == "Partial text before failure"
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors, "expected apperror for partial silent failure"
assert apperrors[-1]["type"] == "no_response"
assert saved.messages[-1]["_error"] is True
def test_non_auth_seeded_multi_turn_partial_persists_error_turn(tmp_path, monkeypatch):
session = _prepare_session("seeded_partial_escape", "stream_seeded_partial_escape", pending_user_message="Please handle partial silence")
_seed_prior_turn(
session,
prior_user="Earlier question",
prior_assistant="Earlier answer",
)
class PartialSilentFailureAgent(MockAgent):
def run_conversation(self, **kwargs):
if self.stream_delta_callback is not None:
self.stream_delta_callback("Partial text before failure")
return {
"messages": list(kwargs.get("conversation_history") or []),
"error": "",
}
fake_queue = _run_stream(monkeypatch, session, "stream_seeded_partial_escape", PartialSilentFailureAgent, workspace=str(tmp_path))
saved = Session.load("seeded_partial_escape")
assert saved is not None
assert any(msg.get("role") == "assistant" and msg.get("content") == "Earlier answer" for msg in saved.messages)
assert any(msg.get("_partial") and msg.get("content") == "Partial text before failure" for msg in saved.messages)
assert saved.messages[-1]["_error"] is True
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors, "expected apperror for seeded partial silent failure"
assert apperrors[-1]["type"] == "no_response"
assert not any(event == "done" for event, _ in events)
def test_non_auth_seeded_replayed_assistant_does_not_satisfy_current_turn(tmp_path, monkeypatch):
session = _prepare_session("seeded_replay_escape", "stream_seeded_replay_escape", pending_user_message="Please handle this now")
_seed_prior_turn(
session,
prior_user="Earlier question",
prior_assistant="Earlier answer",
)
class ReplayAssistantSilentFailureAgent(MockAgent):
def run_conversation(self, **kwargs):
history = list(kwargs.get("conversation_history") or [])
return {
"messages": history + [{"role": "assistant", "content": "Earlier answer"}],
"error": "",
}
fake_queue = _run_stream(monkeypatch, session, "stream_seeded_replay_escape", ReplayAssistantSilentFailureAgent, workspace=str(tmp_path))
saved = Session.load("seeded_replay_escape")
assert saved is not None
assert any(msg.get("role") == "user" and msg.get("content") == "Please handle this now" for msg in saved.messages)
assert saved.messages[-1]["_error"] is True
events = _queue_events(fake_queue)
apperrors = [data for event, data in events if event == "apperror"]
assert apperrors, "expected apperror for seeded replay silent failure"
assert apperrors[-1]["type"] == "no_response"
assert not any(event == "done" for event, _ in events)
@@ -0,0 +1,152 @@
"""Tests for issue #5127 — process-wakeup bare custom-provider model repair.
When ``_resolve_compatible_session_model_state`` takes the #1855 fast path
(both model and model_provider set), a bare runtime model that matches the
suffix of a slash-qualified ``profile_default_model`` must repair to the full
qualified ID for named custom providers.
"""
from unittest.mock import patch
class TestIssue5127CustomProviderBareSuffixRepair:
def test_fast_path_repairs_bare_suffix_to_profile_default(self):
from api.routes import _resolve_compatible_session_model_state
with patch("api.routes.get_available_models") as mock_catalog:
result = _resolve_compatible_session_model_state(
"grok-composer-2.5-fast",
"custom:my-proxy",
profile_provider="custom:my-proxy",
profile_default_model="x-ai/grok-composer-2.5-fast",
prefer_cached_catalog=True,
)
assert mock_catalog.call_count == 0
assert result == ("x-ai/grok-composer-2.5-fast", "custom:my-proxy", True)
def test_fast_path_skips_repair_when_profile_provider_mismatches(self):
"""Regression: custom:other-proxy must not inherit my-proxy's qualified default."""
from api.routes import _resolve_compatible_session_model_state
with patch("api.routes.get_available_models") as mock_catalog:
result = _resolve_compatible_session_model_state(
"grok-composer-2.5-fast",
"custom:other-proxy",
profile_provider="custom:my-proxy",
profile_default_model="x-ai/grok-composer-2.5-fast",
prefer_cached_catalog=True,
)
assert mock_catalog.call_count == 0
assert result == ("grok-composer-2.5-fast", "custom:other-proxy", False)
def test_fast_path_repairs_generic_custom_provider(self):
from api.routes import _resolve_compatible_session_model_state
with patch("api.routes.get_available_models") as mock_catalog:
result = _resolve_compatible_session_model_state(
"some-model",
"custom",
profile_default_model="vendor/some-model",
prefer_cached_catalog=True,
)
assert mock_catalog.call_count == 0
assert result == ("vendor/some-model", "custom", True)
def test_fast_path_does_not_rewrite_unrelated_bare_model(self):
from api.routes import _resolve_compatible_session_model_state
with patch("api.routes.get_available_models") as mock_catalog:
result = _resolve_compatible_session_model_state(
"other-model",
"custom:my-proxy",
profile_default_model="x-ai/grok-composer-2.5-fast",
prefer_cached_catalog=True,
)
assert mock_catalog.call_count == 0
assert result == ("other-model", "custom:my-proxy", False)
def test_fast_path_keeps_qualified_model_unchanged(self):
from api.routes import _resolve_compatible_session_model_state
with patch("api.routes.get_available_models") as mock_catalog:
result = _resolve_compatible_session_model_state(
"x-ai/grok-composer-2.5-fast",
"custom:my-proxy",
profile_default_model="x-ai/grok-composer-2.5-fast",
prefer_cached_catalog=True,
)
assert mock_catalog.call_count == 0
assert result == ("x-ai/grok-composer-2.5-fast", "custom:my-proxy", False)
def test_openai_codex_stale_openai_slash_still_uses_slow_path(self):
"""Regression: openai/... under openai-codex must not take bare fast return."""
from api.routes import _resolve_compatible_session_model_state
with patch("api.routes.get_available_models") as mock_catalog:
mock_catalog.return_value = {
"active_provider": "openai-codex",
"default_model": "gpt-5.5",
"groups": [],
}
result = _resolve_compatible_session_model_state(
"openai/gpt-5.4-mini",
"openai-codex",
profile_default_model="gpt-5.5",
prefer_cached_catalog=True,
)
assert mock_catalog.call_count >= 1
assert result[0] == "gpt-5.5"
assert result[2] is True
class TestReadProfileModelConfigWithExplicitProvider:
def test_returns_profile_default_when_session_has_model_provider(self, tmp_path, monkeypatch):
from api.routes import _read_profile_model_config
profile_home = tmp_path / "prof"
profile_home.mkdir()
(profile_home / "config.yaml").write_text(
"model:\n provider: custom:my-proxy\n default: x-ai/grok-composer-2.5-fast\n",
encoding="utf-8",
)
class _Session:
profile = "testprof"
monkeypatch.setattr(
"api.profiles.get_hermes_home_for_profile",
lambda _p: str(profile_home),
)
provider, default = _read_profile_model_config(_Session(), "custom:my-proxy")
assert provider is None
assert default == "x-ai/grok-composer-2.5-fast"
def test_returns_none_default_when_session_provider_differs_from_profile(
self, tmp_path, monkeypatch,
):
from api.routes import _read_profile_model_config
profile_home = tmp_path / "prof"
profile_home.mkdir()
(profile_home / "config.yaml").write_text(
"model:\n provider: custom:my-proxy\n default: x-ai/grok-composer-2.5-fast\n",
encoding="utf-8",
)
class _Session:
profile = "testprof"
monkeypatch.setattr(
"api.profiles.get_hermes_home_for_profile",
lambda _p: str(profile_home),
)
provider, default = _read_profile_model_config(_Session(), "custom:other-proxy")
assert provider is None
assert default is None
@@ -0,0 +1,255 @@
"""Regression coverage for #5139 gateway approval offline notice."""
from __future__ import annotations
import io
import json
import urllib.error
from pathlib import Path
from unittest.mock import MagicMock, patch
from api.config import STREAMS, STREAMS_LOCK, invalidate_gateway_caps
from api.gateway_chat import _run_gateway_chat_streaming
REPO = Path(__file__).resolve().parents[1]
GATEWAY_CHAT = (REPO / "api" / "gateway_chat.py").read_text(encoding="utf-8")
MESSAGES_JS = (REPO / "static" / "messages.js").read_text(encoding="utf-8")
def _run_gateway_warning_case(unavailable_reason: str) -> list:
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = f"sid-{unavailable_reason}"
with STREAMS_LOCK:
STREAMS[stream_id] = q
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
mock_session.workspace = "/tmp"
mock_session.model = "test"
mock_session.model_provider = None
mock_session.profile = None
mock_session.context_messages = []
mock_session.messages = []
mock_session.pending_user_message = None
mock_session.pending_attachments = None
mock_session.pending_started_at = None
mock_session._approval_notice_emitted = False
def fake_urlopen(req, *, timeout=None):
assert req.full_url == "http://127.0.0.1:8642/v1/chat/completions"
assert req.get_method() == "POST"
payload = req.data.decode("utf-8")
assert json.loads(payload)["messages"][-1]["content"] == "hi"
resp = MagicMock()
resp.__iter__ = lambda s: iter(
[
b'data: {"choices":[{"delta":{"content":"Done"}}]}\n',
b"\n",
b"data: [DONE]\n",
]
)
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
try:
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
with patch("api.gateway_chat.gateway_supports_approval", return_value=False), \
patch("api.gateway_chat.gateway_approval_unavailable_reason", return_value=unavailable_reason), \
patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.get_session", return_value=mock_session), \
patch("api.gateway_chat._stream_writeback_is_current", return_value=True), \
patch("api.gateway_chat.merge_session_messages_append_only", return_value=[]):
_run_gateway_chat_streaming(
session_id="sess-warning",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
)
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
return events
def test_gateway_chat_emits_offline_warning_for_unreachable_probe():
events = _run_gateway_warning_case("unreachable")
warnings = [item for item in events if isinstance(item, tuple) and item[0] == "warning"]
assert warnings
assert warnings[0][1]["type"] == "approval_gateway_offline"
assert warnings[0][1]["message"] == "Gateway connection failed. Check that the connected Hermes gateway is running and reachable."
assert any(isinstance(item, tuple) and item[0] == "done" for item in events)
assert not any(
isinstance(item, tuple) and item[0] == "warning" and item[1].get("type") == "approval_gateway_unsupported"
for item in events
)
def test_gateway_chat_keeps_unsupported_warning_for_reachable_older_gateway():
events = _run_gateway_warning_case("unsupported")
warnings = [item for item in events if isinstance(item, tuple) and item[0] == "warning"]
assert warnings
assert warnings[0][1]["type"] == "approval_gateway_unsupported"
assert warnings[0][1]["message"] == "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this."
assert any(isinstance(item, tuple) and item[0] == "done" for item in events)
assert not any(
isinstance(item, tuple) and item[0] == "warning" and item[1].get("type") == "approval_gateway_offline"
for item in events
)
def test_gateway_chat_keeps_unsupported_warning_for_404_capabilities_probe():
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-http-404"
with STREAMS_LOCK:
STREAMS[stream_id] = q
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
mock_session.workspace = "/tmp"
mock_session.model = "test"
mock_session.model_provider = None
mock_session.profile = None
mock_session.context_messages = []
mock_session.messages = []
mock_session.pending_user_message = None
mock_session.pending_attachments = None
mock_session.pending_started_at = None
mock_session._approval_notice_emitted = False
def fake_urlopen(req, *, timeout=None):
if req.full_url == "http://127.0.0.1:8642/v1/capabilities":
raise urllib.error.HTTPError(req.full_url, 404, "Not Found", hdrs=None, fp=io.BytesIO(b""))
assert req.full_url == "http://127.0.0.1:8642/v1/chat/completions"
resp = MagicMock()
resp.__iter__ = lambda s: iter(
[
b'data: {"choices":[{"delta":{"content":"Done"}}]}\n',
b"\n",
b"data: [DONE]\n",
]
)
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
invalidate_gateway_caps()
try:
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
with patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.get_session", return_value=mock_session), \
patch("api.gateway_chat._stream_writeback_is_current", return_value=True), \
patch("api.gateway_chat.merge_session_messages_append_only", return_value=[]):
_run_gateway_chat_streaming(
session_id="sess-404",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
)
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
invalidate_gateway_caps()
warnings = [item for item in events if isinstance(item, tuple) and item[0] == "warning"]
assert warnings
assert warnings[0][1]["type"] == "approval_gateway_unsupported"
assert warnings[0][1]["message"] == "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this."
assert any(isinstance(item, tuple) and item[0] == "done" for item in events)
assert not any(
isinstance(item, tuple) and item[0] == "warning" and item[1].get("type") == "approval_gateway_offline"
for item in events
)
def test_gateway_chat_keeps_unsupported_warning_for_timeout_capabilities_probe():
events = []
q = MagicMock()
q.put_nowait = lambda item: events.append(item)
stream_id = "sid-timeout"
with STREAMS_LOCK:
STREAMS[stream_id] = q
mock_session = MagicMock()
mock_session.active_stream_id = stream_id
mock_session.workspace = "/tmp"
mock_session.model = "test"
mock_session.model_provider = None
mock_session.profile = None
mock_session.context_messages = []
mock_session.messages = []
mock_session.pending_user_message = None
mock_session.pending_attachments = None
mock_session.pending_started_at = None
mock_session._approval_notice_emitted = False
def fake_urlopen(req, *, timeout=None):
if req.full_url == "http://127.0.0.1:8642/v1/capabilities":
raise TimeoutError("timed out")
assert req.full_url == "http://127.0.0.1:8642/v1/chat/completions"
resp = MagicMock()
resp.__iter__ = lambda s: iter(
[
b'data: {"choices":[{"delta":{"content":"Done"}}]}\n',
b"\n",
b"data: [DONE]\n",
]
)
resp.__enter__ = lambda s: s
resp.__exit__ = lambda s, *a: None
return resp
invalidate_gateway_caps()
try:
with patch.dict("os.environ", {"HERMES_WEBUI_CHAT_BACKEND": "gateway"}):
with patch("urllib.request.urlopen", side_effect=fake_urlopen), \
patch("api.gateway_chat.get_session", return_value=mock_session), \
patch("api.gateway_chat._stream_writeback_is_current", return_value=True), \
patch("api.gateway_chat.merge_session_messages_append_only", return_value=[]):
_run_gateway_chat_streaming(
session_id="sess-timeout",
msg_text="hi",
model="test-model",
workspace="/tmp",
stream_id=stream_id,
)
finally:
with STREAMS_LOCK:
STREAMS.pop(stream_id, None)
invalidate_gateway_caps()
warnings = [item for item in events if isinstance(item, tuple) and item[0] == "warning"]
assert warnings
assert warnings[0][1]["type"] == "approval_gateway_unsupported"
assert warnings[0][1]["message"] == "Approvals require a newer gateway. Upgrade the connected Hermes gateway to enable this."
assert any(isinstance(item, tuple) and item[0] == "done" for item in events)
assert not any(
isinstance(item, tuple) and item[0] == "warning" and item[1].get("type") == "approval_gateway_offline"
for item in events
)
def test_messages_js_handles_offline_warning_without_touching_unsupported_branch():
assert "d.type==='approval_gateway_offline'" in MESSAGES_JS
assert "Gateway offline" in MESSAGES_JS
assert "d.type==='approval_gateway_unsupported'" in MESSAGES_JS
assert "Approvals not supported" in MESSAGES_JS
assert "setComposerStatus(`${d.message||'Warning'}`);" in MESSAGES_JS
def test_gateway_chat_source_mentions_offline_warning_type():
assert "approval_type = \"approval_gateway_offline\"" in GATEWAY_CHAT
assert "approval_type = \"approval_gateway_unsupported\"" in GATEWAY_CHAT
assert "approval_message = \"Gateway connection failed. Check that the connected Hermes gateway is running and reachable.\"" in GATEWAY_CHAT