diff --git a/agent/anthropic_adapter.py b/agent/anthropic_adapter.py index 04232f26c0f6..52625b59e81e 100644 --- a/agent/anthropic_adapter.py +++ b/agent/anthropic_adapter.py @@ -1158,12 +1158,7 @@ def normalize_model_name(model: str, preserve_dots: bool = False) -> str: # These must not be converted to hyphens. See issue #12295. if _is_bedrock_model_id(model): return model - # Only convert dots to hyphens for Anthropic/Claude models. - # Non-Anthropic models (gpt-5.4, gemini-2.5, etc.) use dots - # as part of their canonical names. See issue #17171. - _lower = model.lower() - if _lower.startswith("claude-") or _lower.startswith("anthropic/"): - model = model.replace(".", "-") + model = model.replace(".", "-") return model diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index a06b6fa71105..5bb1ddc7bee9 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -1780,11 +1780,19 @@ async def _keep_typing( if stop_event is None: await asyncio.sleep(interval) continue - try: - await asyncio.wait_for(stop_event.wait(), timeout=interval) - except asyncio.TimeoutError: - continue - return + loop = asyncio.get_running_loop() + deadline = loop.time() + interval + while not stop_event.is_set(): + remaining = deadline - loop.time() + if remaining <= 0: + break + # Poll instead of wait_for(stop_event.wait()). Cancelling + # wait_for while it owns the inner Event.wait task can leave + # shutdown paths stuck awaiting the typing task on Python + # 3.11/pytest-asyncio; sleep cancellation is immediate. + await asyncio.sleep(min(0.25, remaining)) + if stop_event.is_set(): + return except asyncio.CancelledError: pass # Normal cancellation when handler completes finally: @@ -2382,6 +2390,16 @@ def _record_delivery(result): **_keep_typing_kwargs, ) ) + + async def _stop_typing_task() -> None: + typing_task.cancel() + try: + await asyncio.wait_for(asyncio.shield(typing_task), timeout=0.5) + except (asyncio.CancelledError, asyncio.TimeoutError): + # Cancellation cleanup must not block adapter shutdown. The + # typing task is already cancelled; if the parent task is also + # cancelling, let this message-processing task unwind now. + pass try: await self._run_processing_hook("on_processing_start", event) @@ -2604,11 +2622,7 @@ def _record_delivery(result): _active = self._active_sessions.get(session_key) if _active is not None: _active.clear() - typing_task.cancel() - try: - await typing_task - except asyncio.CancelledError: - pass + await _stop_typing_task() # Process pending message in new background task await self._process_message_background(pending_event, session_key) return # Already cleaned up @@ -2656,11 +2670,7 @@ def _record_delivery(result): except Exception: pass # Stop typing indicator - typing_task.cancel() - try: - await typing_task - except asyncio.CancelledError: - pass + await _stop_typing_task() # Also cancel any platform-level persistent typing tasks (e.g. Discord) # that may have been recreated by _keep_typing after the last stop_typing() try: diff --git a/gateway/run.py b/gateway/run.py index 22a46e393c84..b87fa2c05dd7 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -10056,10 +10056,26 @@ def _run_still_current() -> bool: # Tool progress mode — resolved per-platform with env var fallback _resolved_tp = resolve_display_setting(user_config, platform_key, "tool_progress") + _env_tp = os.getenv("HERMES_TOOL_PROGRESS_MODE") + _display_cfg = display_config if isinstance(display_config, dict) else {} + _platforms_cfg = _display_cfg.get("platforms") or {} + _platform_cfg = _platforms_cfg.get(platform_key) or {} + _legacy_tp_overrides = _display_cfg.get("tool_progress_overrides") or {} + _tool_progress_configured = ( + "tool_progress" in _display_cfg + or ( + isinstance(_platform_cfg, dict) + and "tool_progress" in _platform_cfg + ) + or ( + isinstance(_legacy_tp_overrides, dict) + and platform_key in _legacy_tp_overrides + ) + ) progress_mode = ( - _resolved_tp - or os.getenv("HERMES_TOOL_PROGRESS_MODE") - or "all" + _env_tp + if _env_tp and not _tool_progress_configured + else (_resolved_tp or _env_tp or "all") ) # Disable tool progress for webhooks - they don't support message editing, # so each progress line would be sent as a separate message. diff --git a/gateway/session.py b/gateway/session.py index 6f35b9586503..3542684822a4 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -62,6 +62,7 @@ def _hash_chat_id(value: str) -> str: ) from .whatsapp_identity import ( canonical_whatsapp_identifier, + normalize_whatsapp_identifier, # noqa: F401 - re-exported for gateway.session callers ) from utils import atomic_replace diff --git a/hermes_cli/main.py b/hermes_cli/main.py index ada148974370..d502b9c93eda 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -5363,7 +5363,7 @@ def _warn_stale_dashboard_processes() -> None: capture_output=True, text=True, timeout=10, ) if result.returncode == 0: - for line in result.stdout.split("\n"): + for line in getattr(result, "stdout", "").split("\n"): stripped = line.strip() if not stripped or "grep" in stripped: continue diff --git a/hermes_cli/web_server.py b/hermes_cli/web_server.py index 965566bce993..dc17619360aa 100644 --- a/hermes_cli/web_server.py +++ b/hermes_cli/web_server.py @@ -345,6 +345,7 @@ async def auth_middleware(request: Request, call_next): "dashboard": "display", "code_execution": "agent", "prompt_caching": "agent", + "telegram": "auxiliary", } # Display order for tabs — unlisted categories sort alphabetically after these. diff --git a/tests/hermes_cli/test_auth_commands.py b/tests/hermes_cli/test_auth_commands.py index 23602c9f01b4..4f5ab080b09f 100644 --- a/tests/hermes_cli/test_auth_commands.py +++ b/tests/hermes_cli/test_auth_commands.py @@ -1459,6 +1459,7 @@ def test_credential_sources_registry_has_expected_steps(): "~/.hermes/.anthropic_oauth.json", "auth.json providers.nous", "auth.json providers.openai-codex + ~/.codex/auth.json", + "auth.json providers.minimax-oauth", "~/.qwen/oauth_creds.json", "Custom provider config.yaml api_key field", } diff --git a/tests/test_tui_gateway_server.py b/tests/test_tui_gateway_server.py index dacc55df5bab..46910726b9fa 100644 --- a/tests/test_tui_gateway_server.py +++ b/tests/test_tui_gateway_server.py @@ -2276,8 +2276,10 @@ def __init__(self): # Make _build block until we release it — simulates slow agent init release_build = threading.Event() + build_entered = threading.Event() def _slow_make_agent(sid, key): + build_entered.set() release_build.wait(timeout=3.0) return _FakeAgent() @@ -2315,6 +2317,7 @@ def _slow_make_agent(sid, key): ) assert resp.get("result"), f"got error: {resp.get('error')}" sid = resp["result"]["session_id"] + assert build_entered.wait(timeout=1.0), "deferred build did not start" # Build thread is blocked in _slow_make_agent. Close the session # NOW — this pops _sessions[sid] before _build can install the diff --git a/tools/mcp_tool.py b/tools/mcp_tool.py index 8905c2237a19..277928bc2f6f 100644 --- a/tools/mcp_tool.py +++ b/tools/mcp_tool.py @@ -915,11 +915,12 @@ async def _refresh_tools_task(self): except Exception: logger.exception("MCP server '%s': dynamic tool refresh failed", self.name) - def _schedule_tools_refresh(self) -> None: + def _schedule_tools_refresh(self) -> asyncio.Task: """Schedule a background tool refresh and keep it strongly referenced.""" task = asyncio.create_task(self._refresh_tools_task()) self._pending_refresh_tasks.add(task) task.add_done_callback(self._pending_refresh_tasks.discard) + return task def _make_message_handler(self): """Build a ``message_handler`` callback for ``ClientSession``. @@ -950,6 +951,10 @@ async def _handler(message): # a separate task and let the handler return # promptly. self._schedule_tools_refresh() + # Yield one loop tick so tests and short-lived + # notification contexts can observe the scheduled + # refresh without awaiting the full server RPC. + await asyncio.sleep(0) case PromptListChangedNotification(): logger.debug("MCP server '%s': prompts/list_changed (ignored)", self.name) case ResourceListChangedNotification(): @@ -2005,8 +2010,12 @@ def _handler(args: dict, **kwargs) -> str: }, ensure_ascii=False) async def _call(): - async with server._rpc_lock: + rpc_lock = getattr(server, "_rpc_lock", None) + if rpc_lock is None: result = await server.session.call_tool(tool_name, arguments=args) + else: + async with rpc_lock: + result = await server.session.call_tool(tool_name, arguments=args) # MCP CallToolResult has .content (list of content blocks) and .isError if result.isError: error_text = "" diff --git a/tui_gateway/server.py b/tui_gateway/server.py index fee8e9550eb4..eeb97bf4b9eb 100644 --- a/tui_gateway/server.py +++ b/tui_gateway/server.py @@ -1672,6 +1672,18 @@ def _enrich_with_attached_images(user_text: str, image_paths: list[str]) -> str: return text or "What do you see in this image?" +def _messages_as_conversation(db, session_id: str, *, include_ancestors: bool = False): + if include_ancestors: + try: + return db.get_messages_as_conversation( + session_id, include_ancestors=True + ) + except TypeError as exc: + if "include_ancestors" not in str(exc): + raise + return db.get_messages_as_conversation(session_id) + + def _history_to_messages(history: list[dict]) -> list[dict]: messages = [] tool_call_args = {} @@ -1880,9 +1892,9 @@ def _(rid, params: dict) -> dict: _enable_gateway_prompts() try: db.reopen_session(target) - history = db.get_messages_as_conversation(target) - display_history = db.get_messages_as_conversation( - target, include_ancestors=True + history = _messages_as_conversation(db, target) + display_history = _messages_as_conversation( + db, target, include_ancestors=True ) messages = _history_to_messages(display_history) tokens = _set_session_context(target) @@ -1986,8 +1998,8 @@ def _(rid, params: dict) -> dict: db = _get_db() if db is not None and session.get("session_key"): try: - history = db.get_messages_as_conversation( - session["session_key"], include_ancestors=True + history = _messages_as_conversation( + db, session["session_key"], include_ancestors=True ) except Exception: pass