diff --git a/agent/conversation_compression.py b/agent/conversation_compression.py index 49396fb342..4a6aabb685 100644 --- a/agent/conversation_compression.py +++ b/agent/conversation_compression.py @@ -3026,7 +3026,7 @@ def _reset_read_dedup_caches(task_id: str, *, skills: bool = True) -> None: omitted; later reads return stubs, and stub-hit counters restart at the same boundary (#84857). """ with contextlib.suppress(Exception): - from tools.file_tools import reset_file_dedup + from tools.file_tools_read_tracking import reset_file_dedup reset_file_dedup(task_id) if not skills: return diff --git a/agent/tool_executor.py b/agent/tool_executor.py index 67c297001f..d3bba07ae4 100644 --- a/agent/tool_executor.py +++ b/agent/tool_executor.py @@ -82,7 +82,7 @@ def _ensure_file_checkpoint(agent, function_name: str, function_args: dict, effe file_path = function_args.get("path", "") if not file_path: return - from tools.file_tools import _resolve_path_for_task + from tools.file_tools_paths import _resolve_path_for_task resolved_path = _resolve_path_for_task(file_path, effective_task_id or "default") agent._checkpoint_mgr.ensure_checkpoint( @@ -1511,10 +1511,10 @@ def _resolve_sequential_dispatch(agent, ref: _ToolCallRef, messages: list) -> _S # Registry tools: post hook is owned by this executor (inner observer suppressed). def _execute(next_args: dict) -> Any: - from model_tools import suppress_post_tool_call_hook + import model_tools - with suppress_post_tool_call_hook(): - return _ra().handle_function_call( + with model_tools.suppress_post_tool_call_hook(): + return model_tools.handle_function_call( function_name, next_args, effective_task_id, diff --git a/gateway/__init__.py b/gateway/__init__.py index 173a052b57..120327026b 100644 --- a/gateway/__init__.py +++ b/gateway/__init__.py @@ -1,10 +1,11 @@ """Hermes Gateway - multi-platform messaging integration (sessions, context injection, delivery routing, platform-specific toolsets).""" -from .config import GatewayConfig, PlatformConfig, HomeChannel, SessionResetPolicy, load_gateway_config +from .config import GatewayConfig, PlatformConfig, HomeChannel, load_gateway_config from .session import ( SessionContext, SessionStore, + SessionResetPolicy, build_session_context_prompt, ) from .delivery import DeliveryRouter, DeliveryTarget diff --git a/gateway/kanban_watchers.py b/gateway/kanban_watchers.py index a51b296dc7..a95837ce37 100644 --- a/gateway/kanban_watchers.py +++ b/gateway/kanban_watchers.py @@ -15,7 +15,7 @@ import time from pathlib import Path from typing import Any, Optional -from gateway.kanban_watchers_common import ( +from gateway.kanban_watchers_common import ( # noqa: F401 (tests import via origin) _acquire_singleton_lock, _kanban_dispatch_allowed, _release_singleton_lock, @@ -24,7 +24,11 @@ from gateway.kanban_watchers_common import ( _to_thread_process_service, logger, ) -from gateway.kanban_watchers_notifier import _KanbanNotification, _notifier_collect +from gateway.kanban_watchers_notifier import ( # noqa: F401 (_wake_scope_id: tests import via origin) + _KanbanNotification, + _notifier_collect, + _wake_scope_id, +) from gateway.kanban_watchers_dispatcher import ( _KanbanDispatcher, _log_spawn_results, diff --git a/gateway/platforms/__init__.py b/gateway/platforms/__init__.py index c756e2be96..2f69df929a 100644 --- a/gateway/platforms/__init__.py +++ b/gateway/platforms/__init__.py @@ -2,4 +2,21 @@ from .base import BasePlatformAdapter, MessageEvent, SendResult -__all__ = ["BasePlatformAdapter", "MessageEvent", "SendResult"] +# QQAdapter / YuanbaoAdapter are exposed lazily (PEP 562 ``__getattr__``): eager +# imports cost ~48 ms / ~8 MB RSS on every CLI invocation and nothing in-tree +# imports them from the package root. +__all__ = ["BasePlatformAdapter", "MessageEvent", "SendResult", "QQAdapter", "YuanbaoAdapter"] + +_LAZY_ADAPTERS = {"QQAdapter": ".qqbot", "YuanbaoAdapter": ".yuanbao"} + + +def __getattr__(name): + module = _LAZY_ADAPTERS.get(name) + if module is None: + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + from importlib import import_module + return getattr(import_module(module, __name__), name) + + +def __dir__(): + return sorted(__all__) diff --git a/gateway/platforms/api_server.py b/gateway/platforms/api_server.py index 5c26f35cfe..18ab1fab97 100644 --- a/gateway/platforms/api_server.py +++ b/gateway/platforms/api_server.py @@ -122,6 +122,7 @@ from gateway.platforms import api_server_runs as _api_runs from gateway.platforms.api_server_openai_routes import OpenAICompatRoutesMixin from gateway.platforms.base import ( MEDIA_TAG_CLEANUP_RE, BasePlatformAdapter, SendResult, is_network_accessible, validate_media_delivery_path) +# Re-exported here for existing imports and constructor monkeypatches. from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore from agent.redact import redact_sensitive_text from agent.interrupt_compat import request_hard_interrupt diff --git a/gateway/platforms/base.py b/gateway/platforms/base.py index 6e40a97af7..eb0fbb6395 100644 --- a/gateway/platforms/base.py +++ b/gateway/platforms/base.py @@ -432,8 +432,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parents[2])) from gateway.config import Platform, PlatformConfig from gateway.platforms.helpers import fence_state_after -from gateway.session import SessionSource, build_session_key -from gateway.session_transcript import TranscriptReadError +from gateway.session import SessionSource, TranscriptReadError, build_session_key from hermes_constants import get_default_hermes_root, get_hermes_dir, get_hermes_home if TYPE_CHECKING: diff --git a/gateway/platforms/signal.py b/gateway/platforms/signal.py index e9ac35529d..cfb06ffa52 100644 --- a/gateway/platforms/signal.py +++ b/gateway/platforms/signal.py @@ -632,6 +632,9 @@ class SignalAdapter(BasePlatformAdapter): logger.log(fail_level, "Signal RPC %s failed: %s", method, e) return None + # Backward-compatible alias for the shared formatting helper. + _markdown_to_signal = staticmethod(markdown_to_signal) + def format_message(self, content: str) -> str: """Plain-text fallback for the base-class send path; send() applies rich styles itself.""" return content @@ -711,7 +714,7 @@ class SignalAdapter(BasePlatformAdapter): if not content or not content.strip(): return SendResult(success=True, message_id=None) base_params = await self._with_target({"account": self.account}, chat_id) - chunks = self._split_signal_formatted_message(*markdown_to_signal(content), self.MAX_MESSAGE_LENGTH) + chunks = self._split_signal_formatted_message(*self._markdown_to_signal(content), self.MAX_MESSAGE_LENGTH) last_result = None for idx, (plain_text, text_styles) in enumerate(chunks, start=1): params: Dict[str, Any] = dict(base_params, message=plain_text) diff --git a/gateway/platforms/yuanbao.py b/gateway/platforms/yuanbao.py index cd913d2b14..90f15ee7b6 100644 --- a/gateway/platforms/yuanbao.py +++ b/gateway/platforms/yuanbao.py @@ -62,8 +62,7 @@ from gateway.platforms.yuanbao_proto import ( encode_send_private_heartbeat, encode_send_group_heartbeat, encode_query_group_info, encode_get_group_member_list, next_seq_no, ) -from gateway.session import build_session_key -from gateway.session_transcript import TranscriptReadError +from gateway.session import TranscriptReadError, build_session_key logger = logging.getLogger(__name__) @@ -145,7 +144,20 @@ async def _cancel_task(task: asyncio.Task) -> None: class MarkdownProcessor: - """Yuanbao's fence/table-aware chunking policy over the shared chunker in gateway.platforms.helpers.""" + """Thin delegates to the shared fence-aware chunker in gateway.platforms.helpers; method names + kept for existing call sites and tests.""" + @staticmethod + def has_unclosed_fence(text: str) -> bool: + return _mdchunk.text_has_unclosed_fence(text) + + @staticmethod + def ends_with_table_row(text: str) -> bool: + return _mdchunk.text_ends_with_table_row(text) + + @staticmethod + def split_at_paragraph_boundary(text: str, max_chars: int, len_fn: Optional[Callable[[str], int]] = None) -> tuple[str, str]: + return _mdchunk.split_at_paragraph_boundary(text, max_chars, len_fn=len_fn) + @classmethod def chunk_markdown_text(cls, text: str, max_chars: int = 4000, len_fn: Optional[Callable[[str], int]] = None) -> list[str]: """<= max_chars chunks at paragraph boundaries, never inside a fence or table (an oversized @@ -2187,7 +2199,7 @@ class MediaSendHandler(ABC): caption: Optional[str] = None, **kwargs: Any) -> "SendResult": if adapter._connection.ws is None: return SendResult(success=False, error="Not connected", retryable=True) - adapter._outbound.slow_notifier.cancel(chat_id) + adapter._outbound.cancel_slow_notifier(chat_id) try: file_bytes, filename, content_type = await self.acquire_file(adapter, **kwargs) if self.needs_cos_upload(): @@ -2408,7 +2420,7 @@ class MessageSender: adapter = self._adapter if adapter._connection.ws is None: return SendResult(success=False, error="Not connected", retryable=True) - adapter._outbound.slow_notifier.cancel(chat_id) + adapter._outbound.cancel_slow_notifier(chat_id) async with self.get_chat_lock(chat_id): content_to_send = self.strip_cron_wrapper(content) chunks = self.truncate_message(content_to_send, adapter.MAX_TEXT_CHUNK) @@ -2582,11 +2594,21 @@ class MessageSender: class OutboundManager: """Composes MessageSender, HeartbeatManager and SlowResponseNotifier (sender cancels the notifier before a send and emits the FINISH heartbeat after).""" + CHAT_DICT_MAX_SIZE: ClassVar[int] = MessageSender.CHAT_DICT_MAX_SIZE + def __init__(self, adapter: "YuanbaoAdapter") -> None: self._adapter = adapter self.sender: MessageSender = MessageSender(adapter) self.heartbeat: HeartbeatManager = HeartbeatManager(adapter) self.slow_notifier: SlowResponseNotifier = SlowResponseNotifier(adapter, self.sender) + # Delegates kept for callers/tests that address the outbound facade. + self.start_slow_notifier = self.slow_notifier.start + self.cancel_slow_notifier = self.slow_notifier.cancel + self.get_chat_lock = self.sender.get_chat_lock + + @property + def _chat_locks(self) -> collections.OrderedDict: + return self.sender._chat_locks async def close(self) -> None: await self.sender.close() @@ -2726,11 +2748,11 @@ class YuanbaoAdapter(BasePlatformAdapter): async def _process_message_background(self, event, session_key: str) -> None: """Wrap base class processing with a slow-response notifier.""" chat_id = event.source.chat_id - await self._outbound.slow_notifier.start(chat_id) + await self._outbound.start_slow_notifier(chat_id) try: await super()._process_message_background(event, session_key) finally: - self._outbound.slow_notifier.cancel(chat_id) + self._outbound.cancel_slow_notifier(chat_id) # Clear RecallGuard tracking only if our msg_id is still current: a concurrent message may have # overwritten it (the drain task then owns it); id-less events never wrote one and must not pop. msg_id = event.message_id @@ -2802,3 +2824,12 @@ class YuanbaoAdapter(BasePlatformAdapter): """Current valid sign token (module-level cache).""" return await SignManager.get_token(self._app_key, self._app_secret, self._api_domain, route_env=self._route_env) + +# Module-level delegates kept for external importers (tools/send_message_tool, tools/yuanbao_tools). +def get_active_adapter() -> Optional["YuanbaoAdapter"]: + return YuanbaoAdapter.get_active() + + +async def send_yuanbao_direct(adapter: "YuanbaoAdapter", chat_id: str, message: str, + media_files: Optional[List[Tuple[str, bool]]] = None) -> Dict[str, Any]: + return await adapter._outbound.sender.send_direct(chat_id, message, media_files) diff --git a/gateway/relay/descriptor.py b/gateway/relay/descriptor.py index 92e9afad34..a6793a4144 100644 --- a/gateway/relay/descriptor.py +++ b/gateway/relay/descriptor.py @@ -93,3 +93,44 @@ class CapabilityDescriptor: else () ) return cls(**filtered) + + @classmethod + def from_platform_entry( + cls, + entry, + *, + len_unit: str = "chars", + supports_draft_streaming: bool = False, + supports_edit: bool = True, + supports_threads: bool = False, + markdown_dialect: str = "plain", + ) -> "CapabilityDescriptor": + """Project a ``gateway.platform_registry.PlatformEntry`` into a descriptor. + + Demonstrates the descriptor is a *subset/projection* of what + ``PlatformEntry`` already encodes, not a parallel concept: ``label``, + ``max_message_length``, ``emoji``, ``platform_hint``, ``pii_safe`` and + the platform name come straight off the entry. The runtime capability + bits that ``PlatformEntry`` does NOT encode (length unit, draft/edit/ + thread/markdown behavior) are supplied by the caller — in production + the connector fills these from the live adapter's capability methods. + + ``max_message_length`` of 0 on a ``PlatformEntry`` means "no limit"; + we map that to the stream_consumer default of 4096 so the descriptor + always carries a concrete chunking bound. + """ + max_len = getattr(entry, "max_message_length", 0) or 4096 + return cls( + contract_version=CONTRACT_VERSION, + platform=entry.name, + label=entry.label, + max_message_length=max_len, + supports_draft_streaming=supports_draft_streaming, + supports_edit=supports_edit, + supports_threads=supports_threads, + markdown_dialect=markdown_dialect, + len_unit=len_unit, + emoji=getattr(entry, "emoji", "\U0001f50c"), + platform_hint=getattr(entry, "platform_hint", ""), + pii_safe=getattr(entry, "pii_safe", False), + ) diff --git a/gateway/run_adapters.py b/gateway/run_adapters.py index 3ea22ad640..cb59cb8919 100644 --- a/gateway/run_adapters.py +++ b/gateway/run_adapters.py @@ -28,8 +28,7 @@ from pathlib import Path from typing import Any, Awaitable, Callable, Dict, Optional if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 - from gateway.run_turn_runner import TurnRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") diff --git a/gateway/run_agent_cache.py b/gateway/run_agent_cache.py index cac5714863..fdf701a7cf 100644 --- a/gateway/run_agent_cache.py +++ b/gateway/run_agent_cache.py @@ -17,8 +17,7 @@ from gateway.session import SessionSource, build_session_context_prompt from hermes_cli.config import cfg_get if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 - from gateway.run_turn_runner import TurnRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") diff --git a/gateway/run_busy.py b/gateway/run_busy.py index 3fcf447eb3..0604a67a39 100644 --- a/gateway/run_busy.py +++ b/gateway/run_busy.py @@ -21,8 +21,7 @@ from gateway.session import SessionSource from typing import Any, Dict, Optional, Union if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 - from gateway.run_turn_runner import TurnRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") @@ -276,7 +275,7 @@ class GatewayBusySessionMixin: ) def _queue_or_replace_pending_event(self, session_key: str, event: MessageEvent) -> None: - from gateway.platforms.base import merge_pending_message_event + from gateway.run import merge_pending_message_event adapter = self._adapter_for_source(event.source) if not adapter: return diff --git a/gateway/run_config_loaders.py b/gateway/run_config_loaders.py index 4bc0e1ed6d..8d5b00fc1e 100644 --- a/gateway/run_config_loaders.py +++ b/gateway/run_config_loaders.py @@ -30,8 +30,7 @@ from hermes_cli.fallback_config import get_fallback_chain from utils import is_truthy_value if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 - from gateway.run_turn_runner import TurnRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") diff --git a/gateway/run_goals.py b/gateway/run_goals.py index ef2bd26d41..27a31fbd3b 100644 --- a/gateway/run_goals.py +++ b/gateway/run_goals.py @@ -16,8 +16,7 @@ from typing import TYPE_CHECKING, Any from gateway.platforms.base import MessageEvent, MessageType if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 - from gateway.run_turn_runner import TurnRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") diff --git a/gateway/run_inbound.py b/gateway/run_inbound.py index 989ccf794e..a0ce1e9587 100644 --- a/gateway/run_inbound.py +++ b/gateway/run_inbound.py @@ -27,8 +27,7 @@ from gateway.turn_lease import TurnLeaseTimeoutError from typing import Any, Dict, List, Optional, Tuple if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 - from gateway.run_turn_runner import TurnRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") @@ -486,7 +485,7 @@ class GatewayInboundMixin: self, source: SessionSource, _quick_key: str, event: "MessageEvent", *, merge_text: bool = False ) -> None: """Merge *event* into the source adapter's pending slot (no-op without an adapter).""" - from gateway.platforms.base import merge_pending_message_event + from gateway.run import merge_pending_message_event adapter = self._adapter_for_source(source) if adapter: merge_pending_message_event(adapter._pending_messages, _quick_key, event, merge_text=merge_text) diff --git a/gateway/run_notifications.py b/gateway/run_notifications.py index 5c1adfef0e..1f8be3857e 100644 --- a/gateway/run_notifications.py +++ b/gateway/run_notifications.py @@ -598,8 +598,7 @@ class GatewayNotificationsMixin: async def _send_restart_notification(self) -> Optional[tuple[str, str, Optional[str]]]: """Notify the chat that initiated /restart that the gateway is back.""" - from gateway.delivery import resolve_delivery_transport - from gateway.run import _hermes_home, _non_conversational_metadata + from gateway.run import _hermes_home, _non_conversational_metadata, resolve_delivery_transport notify_path = _hermes_home / ".restart_notify.json" if not notify_path.exists(): return None @@ -648,7 +647,7 @@ class GatewayNotificationsMixin: def _home_channel_transports(self): """Yield ``(platform, platform_cfg, home, transport)`` for every home channel with a live transport.""" - from gateway.delivery import resolve_delivery_transport + from gateway.run import resolve_delivery_transport for platform, platform_cfg in self.config.platforms.items(): home = platform_cfg.home_channel if not home or not home.chat_id: @@ -867,7 +866,7 @@ class GatewayNotificationsMixin: """Adapter for a synthetic-event platform: alias-aware transport resolver first (one Platform.RELAY adapter fronts N logical platforms; native wins), literal ``p.value`` scan as fallback for minimal runner stubs / exotic platform strings when the resolver can't run.""" - from gateway.delivery import resolve_delivery_transport + from gateway.run import resolve_delivery_transport try: _transport = resolve_delivery_transport(Platform(platform_name), self.config, self.adapters) except Exception: diff --git a/gateway/run_shutdown.py b/gateway/run_shutdown.py index d8b21f5153..083b59023b 100644 --- a/gateway/run_shutdown.py +++ b/gateway/run_shutdown.py @@ -1056,7 +1056,7 @@ class GatewayShutdownMixin: def _increment_restart_failure_counts(self, active_session_keys: set) -> None: """Increment persisted restart-failure counters for active sessions; drop the rest (loop broken).""" - from utils import atomic_json_write + from gateway.run import atomic_json_write path = self._stuck_loop_counts_path() counts = self._read_json_counts(path) or {} with suppress(Exception): @@ -1091,7 +1091,7 @@ class GatewayShutdownMixin: async def _clear_restart_failure_count(self, session_key: str) -> None: """Clear a completed session's restart-failure counter off-loop (atomic_json_write fsyncs).""" - from utils import atomic_json_write + from gateway.run import atomic_json_write path = self._stuck_loop_counts_path() if not path.exists(): return @@ -1666,8 +1666,10 @@ class GatewayShutdownMixin: def _stop_persist_exit_state(self, ctx: "GatewayShutdownMixin._StopContext") -> None: """PID/lock release, clean-shutdown marker, restart markers, terminal runtime status.""" - from gateway.run import _hermes_home, _planned_restart_notification_path, _shutdown_gateway_health_export - from utils import atomic_json_write + from gateway.run import ( + _hermes_home, _planned_restart_notification_path, _shutdown_gateway_health_export, + atomic_json_write, + ) from gateway.status import remove_pid_file, release_gateway_runtime_lock remove_pid_file() release_gateway_runtime_lock() diff --git a/gateway/run_startup.py b/gateway/run_startup.py index b20d49e66a..cc20ba6245 100644 --- a/gateway/run_startup.py +++ b/gateway/run_startup.py @@ -523,7 +523,7 @@ class GatewayStartupMixin: See #69089. """ - from gateway.shutdown_watchdog import _arm_loop_floor_timer, start_loop_liveness_watchdog + from gateway.run import _arm_loop_floor_timer, start_loop_liveness_watchdog config = getattr(self, "config", None) if config is not None and not getattr(config, "loop_watchdog", True): return @@ -673,7 +673,7 @@ class GatewayStartupMixin: # Loop live: the loop-liveness watchdog takes over from the startup watchdog. Disarm even # when loop guards are config-disabled; only inside this branch (no live loop = stay armed). with _log_suppressed(logging.DEBUG, "Startup watchdog disarm failed", exc_info=True): - from hermes_startup_watchdog import disarm_startup_watchdog + from gateway.startup_watchdog import disarm_startup_watchdog disarm_startup_watchdog() logger.info("Session storage: %s", self.config.sessions_dir) self._start_log_systemd_timing_alignment() @@ -1326,7 +1326,7 @@ class GatewayStartupMixin: self, row: Dict[str, Any], profile_name: Optional[str] ) -> "GatewayStartupMixin._HandoffDestination": """Resolve platform, transport, home channel, thread and destination source for a row.""" - from gateway.delivery import resolve_delivery_transport + from gateway.run import resolve_delivery_transport cli_session_id = row["id"] platform_name = (row.get("handoff_platform") or "").strip().lower() if not platform_name: diff --git a/gateway/run_topics.py b/gateway/run_topics.py index 54777a9ff0..719517dc1c 100644 --- a/gateway/run_topics.py +++ b/gateway/run_topics.py @@ -21,8 +21,7 @@ from gateway.session import SessionSource from utils import is_truthy_value if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 - from gateway.run_turn_runner import TurnRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") diff --git a/gateway/run_turn.py b/gateway/run_turn.py index 4dfe8df32f..69deeb40bd 100644 --- a/gateway/run_turn.py +++ b/gateway/run_turn.py @@ -22,10 +22,9 @@ from gateway.config import Platform from gateway.media_repair import repair_explicit_computer_use_media_paths from gateway.platforms.base import BasePlatformAdapter, MessageEvent from gateway.session import ( - SessionSource, _session_key_namespace, build_channel_continuity_note, + SessionSource, TranscriptReadError, _session_key_namespace, build_channel_continuity_note, build_session_context, ) -from gateway.session_transcript import TranscriptReadError from gateway.turn_context import TurnContext from gateway.turn_lease import DEFAULT_LEASE_WAIT, TurnLeaseTimeoutError from hermes_constants import get_hermes_home_override @@ -34,8 +33,7 @@ from typing import Any, Callable, Dict, List, Optional, Tuple from utils import base_url_hostname if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 - from gateway.run_turn_runner import TurnRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") @@ -1430,7 +1428,7 @@ class GatewayTurnMixin: last_reasoning = agent_result.get("last_reasoning") if not (_show_reasoning_effective and response and not _intentional_silence and last_reasoning): return response - from gateway.stream_consumer_fences import escape_code_fences_for_display + from gateway.stream_consumer import escape_code_fences_for_display # Collapse long reasoning to keep messages readable lines = last_reasoning.strip().splitlines() if len(lines) > 15: @@ -2694,7 +2692,7 @@ class GatewayTurnMixin: """Build the ``TurnContext`` and its ``TurnRunner``; ``turn_params`` (history, context_prompt, session_id, persist_user_*, …) are stored verbatim. Returns ``(turn_ctx, turn_runner, cleanup_adapter)``.""" - from gateway.run_turn_runner import TurnRunner + from gateway.run import TurnRunner # Discord voice "verbal ack" on the FIRST tool call (discord.voice_fx.enabled): resolve the # guild whose voice connection is bound to this text channel (mirrors DiscordAdapter.play_tts). _voice_ack_guild: List[Optional[int]] = [None] @@ -3392,8 +3390,7 @@ class GatewayTurnMixin: response: Any, result: Any, stream_task: Any, ) -> Any: """Run the queued / interrupting follow-up as the next turn (recursive ``_run_agent``).""" - from gateway.platforms.base import merge_pending_message_event - from gateway.run import _preserve_queued_followup_history_offset + from gateway.run import _preserve_queued_followup_history_offset, merge_pending_message_event source, session_id, session_key, run_generation = ( turn_ctx.source, turn_ctx.session_id, turn_ctx.session_key, turn_ctx.run_generation, ) diff --git a/gateway/run_turn_runner.py b/gateway/run_turn_runner.py index c84f85650b..587cc527f3 100644 --- a/gateway/run_turn_runner.py +++ b/gateway/run_turn_runner.py @@ -29,7 +29,7 @@ from hermes_cli.config import cfg_get from utils import is_truthy_value if TYPE_CHECKING: # string annotations only; never imported at runtime (cycle) - from gateway.run import GatewayRunner # noqa: F401 + from gateway.run import GatewayRunner, TurnRunner # noqa: F401 # Log-record parity with the origin module. logger = logging.getLogger("gateway.run") diff --git a/gateway/session.py b/gateway/session.py index 857f0dd57b..925e8ac5e8 100644 --- a/gateway/session.py +++ b/gateway/session.py @@ -13,11 +13,15 @@ from dataclasses import dataclass, field, fields from typing import Dict, List, Optional, Any from .config import Platform, GatewayConfig, HomeChannel +from .config import SessionResetPolicy # noqa: F401 — re-exported via gateway/__init__.py from .whatsapp_identity import canonical_whatsapp_identifier -from gateway.session_persistence import SessionPersistenceMixin, _DB_UNPINNED +from .whatsapp_identity import normalize_whatsapp_identifier # noqa: F401 — re-exported +from gateway.session_persistence import SessionPersistenceMixin, _DB_UNPINNED # noqa: F401 from gateway.session_recovery import SessionRecoveryMixin -from gateway.session_lifecycle import SessionLifecycleMixin, _iso, _new_session_id, _now, _parse_iso -from gateway.session_transcript import SessionTranscriptMixin +from gateway.session_lifecycle import ( # noqa: F401 — _now & co. re-exported for callers/tests + SessionLifecycleMixin, _iso, _new_session_id, _now, _parse_iso, auto_continue_freshness_window, +) +from gateway.session_transcript import SessionTranscriptMixin, TranscriptReadError # noqa: F401 logger = logging.getLogger(__name__) diff --git a/gateway/session_persistence.py b/gateway/session_persistence.py index 3a2b829e4e..197c0fad6e 100644 --- a/gateway/session_persistence.py +++ b/gateway/session_persistence.py @@ -341,7 +341,7 @@ class SessionPersistenceMixin: def _stale_entry_verdict(self, key: str, entry, row): """For a routing entry whose row has ended: ``"prune"``, a replacement entry (repoint), or None (keep as-is).""" - from gateway.session_lifecycle import _now + from gateway.session import _now recovered_entry = None if entry.origin is not None: try: diff --git a/gateway/shutdown_forensics.py b/gateway/shutdown_forensics.py index 3ef96d7779..7b634a99c3 100644 --- a/gateway/shutdown_forensics.py +++ b/gateway/shutdown_forensics.py @@ -267,3 +267,6 @@ def parse_systemd_duration_to_us(raw: str) -> Optional[int]: return None return total_us if total_us > 0 else None + +# Backward-compat private alias (pre-promotion name). +_parse_systemd_duration_to_us = parse_systemd_duration_to_us diff --git a/gateway/slash_commands.py b/gateway/slash_commands.py index 8300f34ae6..ea0ba86699 100644 --- a/gateway/slash_commands.py +++ b/gateway/slash_commands.py @@ -22,12 +22,13 @@ from typing import Optional, Union from agent.i18n import t from gateway.config import HomeChannel, Platform, PlatformConfig, persist_home_channel from gateway.platforms.base import EphemeralReply, MessageEvent -from gateway.session import AsyncSessionStore -from gateway.session_transcript import TranscriptReadError +from gateway.session import AsyncSessionStore, TranscriptReadError from gateway.slash_commands_goals import GatewayGoalCommandsMixin -from gateway.slash_commands_model import GatewayModelCommandsMixin +from gateway.slash_commands_model import ( # noqa: F401 — _model_switch_skew_guard re-exported for tests + GatewayModelCommandsMixin, + _model_switch_skew_guard) from gateway.slash_commands_session import GatewaySessionCommandsMixin -from gateway.slash_commands_status import GatewayStatusCommandsMixin +from gateway.slash_commands_status import HISTORY_UNREADABLE, GatewayStatusCommandsMixin # noqa: F401 — re-exported from hermes_cli.config import atomic_config_write, cfg_get from utils import atomic_json_write, is_truthy_value diff --git a/gateway/slash_commands_session.py b/gateway/slash_commands_session.py index 9d60d660e6..f6f3105415 100644 --- a/gateway/slash_commands_session.py +++ b/gateway/slash_commands_session.py @@ -18,8 +18,7 @@ from agent.i18n import t from agent.turn_context import extract_api_content_sidecar from gateway.config import Platform from gateway.platforms.base import EphemeralReply, MessageEvent, MessageType -from gateway.session import SessionSource, build_session_key, is_shared_multi_user_session -from gateway.session_transcript import TranscriptReadError +from gateway.session import SessionSource, TranscriptReadError, build_session_key, is_shared_multi_user_session from gateway.slash_commands_status import HISTORY_UNREADABLE logger = logging.getLogger("gateway.run") # log-record parity with gateway/run.py diff --git a/gateway/slash_commands_status.py b/gateway/slash_commands_status.py index 81a9eeebdc..c467971c52 100644 --- a/gateway/slash_commands_status.py +++ b/gateway/slash_commands_status.py @@ -15,7 +15,7 @@ from agent.account_usage import fetch_account_usage, render_account_usage_lines from agent.i18n import t from gateway.config import Platform from gateway.platforms.base import MessageEvent -from gateway.session_transcript import TranscriptReadError +from gateway.session import TranscriptReadError # Log-record parity with gateway/run.py and the origin module. logger = logging.getLogger("gateway.run") diff --git a/gateway/stream_consumer.py b/gateway/stream_consumer.py index 603f1735b9..872f002068 100644 --- a/gateway/stream_consumer.py +++ b/gateway/stream_consumer.py @@ -30,7 +30,9 @@ from gateway.config import ( from gateway.response_filters import ( is_intentional_silence_response as _is_intentional_silence_response, is_partial_silence_marker as _is_partial_silence_marker) -from gateway.stream_consumer_fences import ensure_closed_code_fences +from gateway.stream_consumer_fences import ( # noqa: F401 (re-exported) + ensure_closed_code_fences, + escape_code_fences_for_display) from gateway.stream_consumer_transport import StreamTransportMixin from gateway.stream_consumer_fallback import StreamFallbackMixin from gateway.stream_consumer_think import StreamThinkFilterMixin diff --git a/gateway/turn_lease.py b/gateway/turn_lease.py index 1f65614e66..687665b312 100644 --- a/gateway/turn_lease.py +++ b/gateway/turn_lease.py @@ -76,6 +76,9 @@ class SessionTurnLeaseRegistry: self._leases: Dict[str, _SessionLease] = {} self._max_entries = max(1, int(max_entries)) + def __len__(self) -> int: + return len(self._leases) + def _get_or_create(self, session_id: str) -> _SessionLease: if (lease := self._leases.get(session_id)) is None: self._evict_idle() diff --git a/hermes_cli/doctor.py b/hermes_cli/doctor.py index 785e84a39b..6057b2b034 100644 --- a/hermes_cli/doctor.py +++ b/hermes_cli/doctor.py @@ -1,23 +1,15 @@ """``hermes doctor`` — diagnose (and with --fix, repair) a Hermes install. ``run_doctor`` walks ``DOCTOR_CHECKS`` in order; each check prints its own rows and returns a ``Finding``. -Check bodies live in the ``doctor_*`` siblings and are re-exported here so ``hermes_cli.doctor.`` -stays the stable import/monkeypatch surface. +Check bodies live in the ``doctor_*`` siblings. """ -# stdlib modules stay bound here: tests patch doctor.shutil.which / .subprocess.run / .importlib.util.find_spec / .Path.home / .sys.platform / .os.listdir. import os import sys -import subprocess # noqa: F401 -import shutil # noqa: F401 -import importlib.util # noqa: F401 -from pathlib import Path # noqa: F401 -from hermes_cli.config import ( # noqa: F401 (detect_install_method: tests patch doctor.detect_install_method) - detect_install_method, get_env_path, get_hermes_home, get_project_root, -) +from hermes_cli.config import get_env_path, get_hermes_home, get_project_root from hermes_cli.env_loader import load_hermes_dotenv -from hermes_constants import display_hermes_home, is_termux as _is_termux # noqa: F401 (tests call doctor._is_termux) +from hermes_constants import display_hermes_home PROJECT_ROOT = get_project_root() HERMES_HOME = get_hermes_home() @@ -28,20 +20,39 @@ _env_path = get_env_path() load_hermes_dotenv(hermes_home=_env_path.parent, project_env=PROJECT_ROOT / ".env") from hermes_cli.colors import Colors, color -from hermes_cli.doctor_report import ( # noqa: F401 (re-exported for doctor_live and tests) - Finding, _fail_and_issue, _section, check_bool, check_fail, check_info, check_ok, check_warn, doctor_check, - warn_on_error, -) -from hermes_cli.doctor_connectivity import ( # noqa: F401 (re-exported; tests import from hermes_cli.doctor) - _build_apikey_providers_list, _has_healthy_oauth_fallback_for_apikey_provider, build_probes, run_probes, -) -from hermes_cli.doctor_tools import _safe_which # noqa: F401 -from hermes_cli.sizefmt import format_bytes as _human_bytes # noqa: F401 (tests import doctor._human_bytes) +from hermes_cli.doctor_report import Finding, _section, check_bool, check_info, doctor_check, warn_on_error +from hermes_cli.doctor_connectivity import _has_healthy_oauth_fallback_for_apikey_provider, build_probes, run_probes +from hermes_cli.doctor_tools import _safe_which -# Every public/private name of the check modules is re-exported: ``hermes_cli.doctor.`` is the stable -# import + monkeypatch surface for tests (e.g. doctor._check_config_file, doctor._render_state_db_stats). -for _sub in ("doctor_config", "doctor_platform", "doctor_tools", "doctor_state"): - globals().update({k: v for k, v in vars(importlib.import_module(f"hermes_cli.{_sub}")).items() if k[:2] != "__"}) +from hermes_cli.doctor_config import ( + _check_config_drift, + _check_config_file, + _check_env_file, + _check_mcp_security, + _check_xai_retirement, +) +from hermes_cli.doctor_platform import ( + _check_certificates, + _check_command_installation, + _check_gateway_supervision, + _check_python_environment, + _check_required_packages, + _check_security_advisories, +) +from hermes_cli.doctor_tools import ( + _check_git_and_rg, + _check_node_and_browser, + _check_npm_audit, + _check_terminal_backend, + _check_tool_availability, +) +from hermes_cli.doctor_state import ( + _check_directory_structure, + _check_memory_provider, + _check_profiles, + _check_skills_hub, + _check_state_db, +) _PROVIDER_ENV_HINTS = ( "DEEPINFRA_API_KEY", "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", diff --git a/hermes_cli/doctor_live.py b/hermes_cli/doctor_live.py index 90307f6b68..d42bf54cab 100644 --- a/hermes_cli/doctor_live.py +++ b/hermes_cli/doctor_live.py @@ -10,7 +10,8 @@ import os from dataclasses import dataclass from typing import Callable, List, Optional -from hermes_cli.doctor import _section, check_fail, check_info, check_ok, check_warn +from hermes_cli.doctor import _section, check_info +from hermes_cli.doctor_report import check_fail, check_ok, check_warn DEFAULT_PROBE_TIMEOUT = 10.0 @@ -71,7 +72,7 @@ def _browser_available() -> bool: # probes above. Mirror the rung hermes_cli.doctor uses so this probe can't diverge from it, including # the Termux carve-out (bare npx is too fragile to advertise as ready there). try: - from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel, _requires_real_termux_browser_install + from tools.browser_tool_install import _find_agent_browser, _is_npx_agent_browser_sentinel, _requires_real_termux_browser_install browser_cmd = _find_agent_browser(validate=False) except Exception: return False diff --git a/hermes_cli/doctor_tools.py b/hermes_cli/doctor_tools.py index 4a9c250aeb..a31108945d 100644 --- a/hermes_cli/doctor_tools.py +++ b/hermes_cli/doctor_tools.py @@ -90,7 +90,7 @@ def _doctor_web_capability_rows() -> list[tuple[str, str, str]]: def _apply_doctor_tool_availability_overrides(available: list[str], unavailable: list[dict]) -> tuple[list[str], list[dict]]: """Adjust runtime-gated tool availability for doctor diagnostics.""" - from hermes_cli.doctor import _honcho_is_configured_for_doctor + from hermes_cli.doctor_state import _honcho_is_configured_for_doctor updated_available, updated_unavailable = list(available), [] for item in unavailable: if _is_kanban_worker_env_gate(item): @@ -117,7 +117,6 @@ def _enabled_cli_toolsets_for_doctor() -> set[str] | None: def _missing_api_key_toolsets_for_summary(unavailable: list[dict]) -> list[dict]: """Filter unavailable API-key toolsets to those enabled for the CLI.""" - from hermes_cli.doctor import _enabled_cli_toolsets_for_doctor api_key_unavailable = [item for item in unavailable if item.get("missing_vars") or item.get("env_vars")] enabled_toolsets = _enabled_cli_toolsets_for_doctor() return api_key_unavailable if enabled_toolsets is None else [i for i in api_key_unavailable if str(i.get("name") or "") in enabled_toolsets] @@ -254,7 +253,7 @@ def _check_agent_browser(should_fix: bool) -> bool: try: # agent-browser is no longer a root package.json dependency (#43564) — it resolves lazily via npx # (or a global/Hermes-managed install) at first use. - from tools.browser_tool import _find_agent_browser, _is_npx_agent_browser_sentinel + from tools.browser_tool_install import _find_agent_browser, _is_npx_agent_browser_sentinel resolved = _find_agent_browser(validate=False) except Exception: resolved = None @@ -262,7 +261,7 @@ def _check_agent_browser(should_fix: bool) -> bool: check_ok("agent-browser", "(resolves via npx on first use)") if should_fix: # Can't tell whether npx's cache is warm — fire the same warm-up `hermes update` does. - from tools.browser_tool import warm_agent_browser_npx_cache + from tools.browser_tool_install import warm_agent_browser_npx_cache check_info(" Warmed npx cache for agent-browser" if warm_agent_browser_npx_cache() else " Could not warm npx cache (offline or npx unavailable)") return True @@ -296,8 +295,9 @@ def _check_chromium() -> None: """ from hermes_cli.doctor import PROJECT_ROOT try: - from tools.browser_tool import (_chromium_installed, _is_camofox_mode, _get_cloud_provider, - _get_cdp_override_raw, _using_lightpanda_engine) + from tools.browser_tool import _is_camofox_mode, _get_cloud_provider, _get_cdp_override_raw + from tools.browser_tool_install import _chromium_installed + from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine except Exception: return if _is_camofox_mode() or bool(_get_cdp_override_raw()) or _get_cloud_provider() is not None or _using_lightpanda_engine(): @@ -311,7 +311,7 @@ def _check_chromium() -> None: def _check_lightpanda() -> None: """Lightpanda engine (browser.engine / AGENT_BROWSER_ENGINE); independent of Node since Browser Use mode spawns ``lightpanda serve`` itself.""" try: - from tools.browser_tool import _using_lightpanda_engine, lightpanda_engine_status + from tools.browser_tool_lightpanda_fallback import _using_lightpanda_engine, lightpanda_engine_status from tools.browser_lightpanda import LIGHTPANDA_INSTALL_HINT, find_lightpanda_binary except Exception: return @@ -421,7 +421,7 @@ def _check_npm_audit(should_fix: bool, f: Finding) -> None: @doctor_check("Could not check tool availability", "({e})") def _check_tool_availability(should_fix: bool, f: Finding) -> None: - from hermes_cli.doctor import PROJECT_ROOT, _doctor_web_capability_rows + from hermes_cli.doctor import PROJECT_ROOT sys.path.insert(0, str(PROJECT_ROOT)) from model_tools import check_tool_availability, TOOLSET_REQUIREMENTS available, unavailable = _apply_doctor_tool_availability_overrides(*check_tool_availability()) diff --git a/hermes_cli/gateway.py b/hermes_cli/gateway.py index d7d0f690fb..02a98184ab 100644 --- a/hermes_cli/gateway.py +++ b/hermes_cli/gateway.py @@ -4500,7 +4500,7 @@ def _respawn_storm_backoff() -> None: ) # Tell the startup watchdog the backoff sleep is intentional, not a parked deadlock. try: - from hermes_startup_watchdog import kick_startup_watchdog + from gateway.startup_watchdog import kick_startup_watchdog kick_startup_watchdog(extra_s=_storm.backoff_s) except Exception: pass diff --git a/hermes_cli/status.py b/hermes_cli/status.py index 1bbfd6de33..a16f9520ff 100644 --- a/hermes_cli/status.py +++ b/hermes_cli/status.py @@ -5,7 +5,6 @@ import os import sys import time import importlib.util -import subprocess # noqa: F401 — re-exported for tests that monkeypatch status.subprocess to guard against regressions from pathlib import Path from types import SimpleNamespace @@ -13,7 +12,7 @@ PROJECT_ROOT = Path(__file__).parent.parent.resolve() from hermes_cli.auth import AuthError, resolve_provider from hermes_cli.colors import Colors, color -from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config, redact_key # noqa: F401 (redact_key: status_auth resolves it here) +from hermes_cli.config import get_env_path, get_env_value, get_hermes_home, load_config from hermes_cli.models import provider_label from hermes_cli.runtime_provider import resolve_requested_provider from hermes_cli.vercel_auth import describe_vercel_auth diff --git a/hermes_cli/status_auth.py b/hermes_cli/status_auth.py index 84b2baadf9..58c5a84d48 100644 --- a/hermes_cli/status_auth.py +++ b/hermes_cli/status_auth.py @@ -9,6 +9,7 @@ from hermes_cli.nous_account import ( format_nous_portal_entitlement_message, get_nous_portal_account_info) from hermes_cli.nous_subscription import get_nous_subscription_features from tools.tool_backend_helpers import managed_nous_tools_enabled +from hermes_cli import config def _format_iso_timestamp(value) -> str: @@ -92,7 +93,7 @@ def _render_api_keys(ctx): # Anthropic uses the dedicated lookup (it also resolves OAuth tokens). for name, env_ref in (*_API_KEYS.items(), ("Anthropic", get_anthropic_key)): value = env_ref() if callable(env_ref) else _status._first_env_value(env_ref) - _status._row(name, bool(value), _status.redact_key(value)) + _status._row(name, bool(value), config.redact_key(value)) def _render_auth_providers(ctx): @@ -175,7 +176,7 @@ def _render_apikey_providers(ctx): # LM Studio reachability: probe only when it is the active provider so users with foreign # configs see no noise. Auth rejection vs. a silent empty list is the common support case. if _status._effective_provider_label() == "LM Studio": - from hermes_cli.models import probe_lmstudio_models + from hermes_cli.models_local import probe_lmstudio_models model_cfg = ctx.config.get("model") base = ((model_cfg.get("base_url") if isinstance(model_cfg, dict) else None) or _status.get_env_value("LM_BASE_URL") or "http://127.0.0.1:1234/v1") diff --git a/model_tools.py b/model_tools.py index 59916ae5d1..52a18efa7c 100644 --- a/model_tools.py +++ b/model_tools.py @@ -21,10 +21,7 @@ from typing import Dict, Any, List, Optional, Tuple from tools.registry import CHECK_FN_CACHE_BYPASS, check_fn_cache_scope, discover_builtin_tools, registry, tool_error from tools.registry import _MAX_TOOL_ERROR_CHARS as _TOOL_ERROR_MAX_LEN from toolsets import resolve_toolset, validate_toolset -from tools.arg_coercion import ( # noqa: F401 # re-exported: tests and tools/ import these from model_tools - _coerce_boolean, _coerce_json, _coerce_number, _coerce_value, _normalize_json_strings_for_schema, - _schema_accepts_kind, _schema_allows_null, coerce_tool_args, -) +from tools.arg_coercion import coerce_tool_args logger = logging.getLogger(__name__) @@ -862,7 +859,7 @@ def handle_function_call( # Any non-read/search tool resets the consecutive-read-loop counter. if function_name not in _READ_SEARCH_TOOLS: try: - from tools.file_tools import notify_other_tool_call + from tools.file_tools_read_tracking import notify_other_tool_call notify_other_tool_call(task_id or "default") except Exception: pass # file_tools may not be loaded yet diff --git a/tests/agent/test_file_safety_credentials.py b/tests/agent/test_file_safety_credentials.py index b1ddfe5e40..2c648f6f39 100644 --- a/tests/agent/test_file_safety_credentials.py +++ b/tests/agent/test_file_safety_credentials.py @@ -105,7 +105,8 @@ def test_search_tool_filters_credential_results(fake_home, tmp_path, monkeypatch """Directory searches omit credential and MCP-token result entries.""" import json - from tools.file_operations import SearchMatch, SearchResult + from tools.file_operations import SearchResult + from tools.file_operations_common import SearchMatch import tools.file_tools as ft import tools.terminal_tool as terminal_tool diff --git a/tests/gateway/platforms/test_yuanbao_state_cleanup.py b/tests/gateway/platforms/test_yuanbao_state_cleanup.py index 3e038d5b39..d2dc166f2c 100644 --- a/tests/gateway/platforms/test_yuanbao_state_cleanup.py +++ b/tests/gateway/platforms/test_yuanbao_state_cleanup.py @@ -25,16 +25,12 @@ from gateway.platforms.yuanbao import MessageSender, YuanbaoAdapter # Helpers # --------------------------------------------------------------------------- -class _SlowNotifierStub: - async def start(self, chat_id): # noqa: ANN001 - pass - - def cancel(self, chat_id): # noqa: ANN001 - pass - - class _OutboundStub: - slow_notifier = _SlowNotifierStub() + async def start_slow_notifier(self, chat_id): # noqa: ANN001 + pass + + def cancel_slow_notifier(self, chat_id): # noqa: ANN001 + pass def _bare_adapter(): diff --git a/tests/gateway/test_42039_duplicate_user_message.py b/tests/gateway/test_42039_duplicate_user_message.py index b00940d00d..3ddc30d90e 100644 --- a/tests/gateway/test_42039_duplicate_user_message.py +++ b/tests/gateway/test_42039_duplicate_user_message.py @@ -24,8 +24,7 @@ import pytest import gateway.run as gateway_run from gateway.config import GatewayConfig, Platform from gateway.platforms.base import MessageEvent -from gateway.session import SessionEntry, SessionSource -from gateway.session_transcript import TranscriptReadError +from gateway.session import SessionEntry, SessionSource, TranscriptReadError def _bootstrap(monkeypatch, tmp_path): diff --git a/tests/gateway/test_api_server_run_idempotency.py b/tests/gateway/test_api_server_run_idempotency.py index 53275d828a..cdb7459258 100644 --- a/tests/gateway/test_api_server_run_idempotency.py +++ b/tests/gateway/test_api_server_run_idempotency.py @@ -6,10 +6,17 @@ import pytest from gateway.config import Platform, PlatformConfig from gateway.platforms import api_server +from gateway.platforms.api_server_run_idempotency import ( + RunIdempotencyStore as ExtractedRunIdempotencyStore, +) + + +def test_run_idempotency_store_remains_reexported_from_api_server(): + assert api_server.RunIdempotencyStore is ExtractedRunIdempotencyStore @pytest.mark.asyncio -async def test_api_server_constructor_uses_module_run_store_binding(monkeypatch): +async def test_api_server_constructor_uses_legacy_run_store_monkeypatch(monkeypatch): store = MagicMock() store_factory = MagicMock(return_value=store) monkeypatch.setattr(api_server, "RunIdempotencyStore", store_factory) diff --git a/tests/gateway/test_api_server_runs.py b/tests/gateway/test_api_server_runs.py index eb3c7a55d9..052eee868f 100644 --- a/tests/gateway/test_api_server_runs.py +++ b/tests/gateway/test_api_server_runs.py @@ -959,7 +959,7 @@ class TestRunsProviderAuthFailure: def _use_idempotency_db(adapter, path): - from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore + from gateway.platforms.api_server import RunIdempotencyStore adapter._run_idempotency_store.close() adapter._run_idempotency_store = RunIdempotencyStore(str(path)) @@ -1114,7 +1114,7 @@ class TestRunIdempotency: assert calls == 1 def test_restart_durability_and_terminal_semantics(self, tmp_path): - from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore + from gateway.platforms.api_server import RunIdempotencyStore path = tmp_path / "idem.db" for terminal in ("completed", "failed", "cancelled"): @@ -1141,7 +1141,7 @@ class TestRunIdempotency: restarted.close() def test_tenant_isolation_and_retention(self, tmp_path): - from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore + from gateway.platforms.api_server import RunIdempotencyStore store = RunIdempotencyStore(str(tmp_path / "idem.db")) assert ( @@ -1157,7 +1157,7 @@ class TestRunIdempotency: def test_retention_never_releases_an_active_idempotency_reservation( self, tmp_path ): - from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore + from gateway.platforms.api_server import RunIdempotencyStore store = RunIdempotencyStore(str(tmp_path / "idem.db")) with patch("gateway.platforms.api_server.time.time", return_value=100): @@ -1375,7 +1375,7 @@ class TestRunIdempotency: async def test_dead_owner_nonterminal_status_becomes_interrupted( self, tmp_path ): - from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore + from gateway.platforms.api_server import RunIdempotencyStore path = tmp_path / "idem.db" scope = hashlib.sha256( diff --git a/tests/gateway/test_busy_session_ack.py b/tests/gateway/test_busy_session_ack.py index fc7beb9aa1..c9b5442fc5 100644 --- a/tests/gateway/test_busy_session_ack.py +++ b/tests/gateway/test_busy_session_ack.py @@ -208,7 +208,7 @@ class TestBusySessionAck: agent.steer = MagicMock(return_value=True) runner._running_agents[sk] = agent - with patch("gateway.platforms.base.merge_pending_message_event") as mock_merge: + with patch("gateway.run.merge_pending_message_event") as mock_merge: await runner._handle_active_session_busy_message(event, sk) # VERIFY: Agent was steered, NOT interrupted diff --git a/tests/gateway/test_compression_interrupt_demotion_56391.py b/tests/gateway/test_compression_interrupt_demotion_56391.py index 90bc29d9d5..ed4c54c45c 100644 --- a/tests/gateway/test_compression_interrupt_demotion_56391.py +++ b/tests/gateway/test_compression_interrupt_demotion_56391.py @@ -144,7 +144,7 @@ class TestBusyHandlerDemotesInterruptForCompression: runner.adapters[event.source.platform] = adapter runner._session_db._db.get_compression_lock_holder.return_value = "compressing" - with patch("gateway.platforms.base.merge_pending_message_event"): + with patch("gateway.run.merge_pending_message_event"): await runner._handle_active_session_busy_message(event, sk) adapter._send_with_retry.assert_called_once() diff --git a/tests/gateway/test_custom_provider_request_overrides.py b/tests/gateway/test_custom_provider_request_overrides.py index be7d7a27f9..4d9d172a3c 100644 --- a/tests/gateway/test_custom_provider_request_overrides.py +++ b/tests/gateway/test_custom_provider_request_overrides.py @@ -172,6 +172,7 @@ def test_turn_route_merges_fast_mode_with_provider_request_overrides(): @pytest.mark.asyncio async def test_run_agent_preserves_provider_request_overrides_on_gateway_path(monkeypatch): monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") monkeypatch.setattr( @@ -228,6 +229,7 @@ async def test_reused_agent_turn_merges_request_overrides_not_overwrite(monkeypa fast-mode key while the provider extra_body survives. """ monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) + monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) monkeypatch.setattr(gateway_run, "_load_gateway_runtime_config", lambda: {}) monkeypatch.setattr(gateway_run, "_resolve_gateway_model", lambda config=None: "gpt-5.4") monkeypatch.setattr( diff --git a/tests/gateway/test_escape_reasoning_fences.py b/tests/gateway/test_escape_reasoning_fences.py index 783cbd7fbe..f83cabd18b 100644 --- a/tests/gateway/test_escape_reasoning_fences.py +++ b/tests/gateway/test_escape_reasoning_fences.py @@ -6,7 +6,7 @@ B1: Escape triple-backtick markers inside reasoning text before wrapping """ import pytest -from gateway.stream_consumer_fences import escape_code_fences_for_display +from gateway.stream_consumer import escape_code_fences_for_display class TestEscapeCodeFencesForDisplay: diff --git a/tests/gateway/test_gateway_shutdown.py b/tests/gateway/test_gateway_shutdown.py index 8e2bd905c5..dcb0883016 100644 --- a/tests/gateway/test_gateway_shutdown.py +++ b/tests/gateway/test_gateway_shutdown.py @@ -7,7 +7,7 @@ import pytest import gateway.run as gateway_run from gateway.config import HomeChannel, Platform from gateway.platforms.base import MessageEvent -from gateway.restart import DEFAULT_GATEWAY_POST_INTERRUPT_GRACE_TIMEOUT, GATEWAY_SERVICE_RESTART_EXIT_CODE +from gateway.restart import GATEWAY_SERVICE_RESTART_EXIT_CODE from gateway.session import build_session_key from tests.gateway.restart_test_helpers import make_restart_runner, make_restart_source @@ -217,7 +217,7 @@ def test_post_interrupt_grace_tolerates_duck_typed_runner(): assert ( gateway_run.GatewayRunner._post_interrupt_grace_timeout(runner) - == DEFAULT_GATEWAY_POST_INTERRUPT_GRACE_TIMEOUT + == gateway_run.DEFAULT_GATEWAY_POST_INTERRUPT_GRACE_TIMEOUT ) @pytest.mark.asyncio diff --git a/tests/gateway/test_handoff_secondary_profile_adapter.py b/tests/gateway/test_handoff_secondary_profile_adapter.py index c5ec824184..b65e75bfd6 100644 --- a/tests/gateway/test_handoff_secondary_profile_adapter.py +++ b/tests/gateway/test_handoff_secondary_profile_adapter.py @@ -124,7 +124,7 @@ async def test_secondary_profile_handoff_uses_its_own_adapter(monkeypatch): used = {} monkeypatch.setattr( - "gateway.delivery.resolve_delivery_transport", _spy_transport_factory(used), + "gateway.run.resolve_delivery_transport", _spy_transport_factory(used), ) # The watcher would already be inside _profile_runtime_scope here, so a # fresh load resolves the secondary's config. @@ -155,7 +155,7 @@ async def test_default_profile_handoff_keeps_primary_adapter(monkeypatch): used = {} monkeypatch.setattr( - "gateway.delivery.resolve_delivery_transport", _spy_transport_factory(used), + "gateway.run.resolve_delivery_transport", _spy_transport_factory(used), ) await runner._process_handoff( @@ -181,7 +181,7 @@ async def test_secondary_profile_config_load_failure_fails_closed(monkeypatch): raise RuntimeError("config.yaml exploded") monkeypatch.setattr( - "gateway.delivery.resolve_delivery_transport", _spy_transport_factory(used), + "gateway.run.resolve_delivery_transport", _spy_transport_factory(used), ) monkeypatch.setattr("gateway.run.load_gateway_config", _boom) @@ -206,7 +206,7 @@ async def test_secondary_profile_without_live_adapters_fails_loudly(monkeypatch) runner._profile_adapters = {} monkeypatch.setattr( - "gateway.delivery.resolve_delivery_transport", _spy_transport_factory({}), + "gateway.run.resolve_delivery_transport", _spy_transport_factory({}), ) with pytest.raises(RuntimeError, match="no live adapters"): diff --git a/tests/gateway/test_kanban_auto_decompose_live.py b/tests/gateway/test_kanban_auto_decompose_live.py index 955f91f127..b7e5d15c29 100644 --- a/tests/gateway/test_kanban_auto_decompose_live.py +++ b/tests/gateway/test_kanban_auto_decompose_live.py @@ -11,7 +11,7 @@ from __future__ import annotations import pytest -from gateway.kanban_watchers_common import _resolve_auto_decompose_settings +from gateway.kanban_watchers import _resolve_auto_decompose_settings def test_enabled_by_default_when_key_absent(): diff --git a/tests/gateway/test_kanban_notifier.py b/tests/gateway/test_kanban_notifier.py index 8333929d73..78add1d2ba 100644 --- a/tests/gateway/test_kanban_notifier.py +++ b/tests/gateway/test_kanban_notifier.py @@ -4,7 +4,7 @@ from pathlib import Path from gateway.config import Platform -from gateway.kanban_watchers_common import ( +from gateway.kanban_watchers import ( _acquire_singleton_lock, _release_singleton_lock, ) diff --git a/tests/gateway/test_kanban_wake_scope.py b/tests/gateway/test_kanban_wake_scope.py index 8c277451d4..93a7c35451 100644 --- a/tests/gateway/test_kanban_wake_scope.py +++ b/tests/gateway/test_kanban_wake_scope.py @@ -12,7 +12,7 @@ from dataclasses import replace from unittest.mock import AsyncMock, MagicMock from gateway.config import Platform, PlatformConfig -from gateway.kanban_watchers_notifier import _wake_scope_id +from gateway.kanban_watchers import _wake_scope_id from gateway.run import GatewayRunner from gateway.session import build_session_key from hermes_cli import kanban_db as kb diff --git a/tests/gateway/test_loop_liveness_watchdog.py b/tests/gateway/test_loop_liveness_watchdog.py index 4d6ecc9385..d763fbc461 100644 --- a/tests/gateway/test_loop_liveness_watchdog.py +++ b/tests/gateway/test_loop_liveness_watchdog.py @@ -292,10 +292,10 @@ def test_gateway_runner_liveness_guards_start_and_stop(): with ( patch( - "gateway.shutdown_watchdog._arm_loop_floor_timer", return_value=floor_timer + "gateway.run._arm_loop_floor_timer", return_value=floor_timer ) as arm_floor, patch( - "gateway.shutdown_watchdog.start_loop_liveness_watchdog", return_value=watchdog + "gateway.run.start_loop_liveness_watchdog", return_value=watchdog ) as start_watchdog, ): runner._start_loop_liveness_guards(loop) diff --git a/tests/gateway/test_platform_reconnect.py b/tests/gateway/test_platform_reconnect.py index 2dee8ec3d0..e923fac809 100644 --- a/tests/gateway/test_platform_reconnect.py +++ b/tests/gateway/test_platform_reconnect.py @@ -534,7 +534,7 @@ class TestSpawnSupervised: delegated_child_context, is_delegated_child_context, ) - from gateway.kanban_watchers_common import _to_thread_process_service + from gateway.kanban_watchers import _to_thread_process_service from hermes_cli.kanban_db import _assert_not_delegated_child_mutation with delegated_child_context(): diff --git a/tests/gateway/test_reasoning_command.py b/tests/gateway/test_reasoning_command.py index 128e9c301b..3b497a9c53 100644 --- a/tests/gateway/test_reasoning_command.py +++ b/tests/gateway/test_reasoning_command.py @@ -149,6 +149,7 @@ class TestReasoningCommand: monkeypatch.setattr(gateway_run, "_hermes_home", hermes_home) monkeypatch.setattr(gateway_run, "_env_path", hermes_home / ".env") + monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) monkeypatch.setattr( gateway_run, "_resolve_runtime_agent_kwargs", diff --git a/tests/gateway/test_restart_notification.py b/tests/gateway/test_restart_notification.py index b1efc54f18..aaca6ea025 100644 --- a/tests/gateway/test_restart_notification.py +++ b/tests/gateway/test_restart_notification.py @@ -77,6 +77,7 @@ async def test_restart_command_uses_atomic_json_writes_for_marker_files(tmp_path # run.py); it uses that module's top-level atomic_json_write import. import gateway.slash_commands as gateway_slash monkeypatch.setattr(gateway_slash, "atomic_json_write", _fake_atomic_json_write) + monkeypatch.setattr(gateway_run, "atomic_json_write", _fake_atomic_json_write) runner, _adapter = make_restart_runner() runner.request_restart = MagicMock(return_value=True) diff --git a/tests/gateway/test_session_continuity_82616.py b/tests/gateway/test_session_continuity_82616.py index aeb478fcb6..7f9a498dc6 100644 --- a/tests/gateway/test_session_continuity_82616.py +++ b/tests/gateway/test_session_continuity_82616.py @@ -202,8 +202,7 @@ class TestPeerResolutionRecency: class TestLoadTranscriptReroutes: def test_load_transcript_raises_when_message_read_fails(self, tmp_path, monkeypatch): - from gateway.session import SessionStore - from gateway.session_transcript import TranscriptReadError + from gateway.session import SessionStore, TranscriptReadError from gateway.config import GatewayConfig diff --git a/tests/gateway/test_session_db_recovery.py b/tests/gateway/test_session_db_recovery.py index 0b13dfed3e..10b3f05ae8 100644 --- a/tests/gateway/test_session_db_recovery.py +++ b/tests/gateway/test_session_db_recovery.py @@ -109,8 +109,7 @@ def test_runtime_health_is_sanitized_and_recovers() -> None: def test_session_store_and_runner_reopen_after_failed_construction(monkeypatch, tmp_path) -> None: import hermes_state from gateway.run import GatewayRunner, _SESSION_DB_UNPINNED - from gateway.session import SessionStore - from gateway.session_persistence import _DB_UNPINNED + from gateway.session import SessionStore, _DB_UNPINNED db_path = tmp_path / "state.db" clock = _Clock() @@ -261,8 +260,7 @@ def test_close_all_preserves_inflight_failure() -> None: def test_recovered_db_rows_survive_fallback_structural_save(monkeypatch, tmp_path) -> None: import hermes_state from gateway.config import GatewayConfig, Platform - from gateway.session import SessionEntry, SessionSource, SessionStore - from gateway.session_lifecycle import _now + from gateway.session import SessionEntry, SessionSource, SessionStore, _now db_path = tmp_path / "state.db" sessions_dir = tmp_path / "sessions" diff --git a/tests/gateway/test_session_title_rename_lane.py b/tests/gateway/test_session_title_rename_lane.py index 5a99805d43..a821b67ca7 100644 --- a/tests/gateway/test_session_title_rename_lane.py +++ b/tests/gateway/test_session_title_rename_lane.py @@ -14,8 +14,7 @@ import types import pytest from gateway.config import Platform -from gateway.run import GatewayRunner -from gateway.run_turn_runner import TurnRunner +from gateway.run import GatewayRunner, TurnRunner def _attach(lane): diff --git a/tests/gateway/test_shutdown_forensics.py b/tests/gateway/test_shutdown_forensics.py index 9b37d53031..2681b9d84d 100644 --- a/tests/gateway/test_shutdown_forensics.py +++ b/tests/gateway/test_shutdown_forensics.py @@ -117,15 +117,15 @@ class TestSpawnAsyncDiagnostic: # --------------------------------------------------------------------------- -# parse_systemd_duration_to_us +# _parse_systemd_duration_to_us # --------------------------------------------------------------------------- class TestParseSystemdDuration: def test_seconds(self): - assert sf.parse_systemd_duration_to_us("90s") == 90 * 1_000_000 + assert sf._parse_systemd_duration_to_us("90s") == 90 * 1_000_000 def test_minutes(self): - assert sf.parse_systemd_duration_to_us("3min") == 180 * 1_000_000 + assert sf._parse_systemd_duration_to_us("3min") == 180 * 1_000_000 # --------------------------------------------------------------------------- diff --git a/tests/gateway/test_signal_format.py b/tests/gateway/test_signal_format.py index 7a9d346777..0b8805e2e0 100644 --- a/tests/gateway/test_signal_format.py +++ b/tests/gateway/test_signal_format.py @@ -1,4 +1,4 @@ -"""Tests for Signal markdown_to_signal() formatting. +"""Tests for Signal _markdown_to_signal() formatting. Covers the markdown-to-bodyRanges conversion pipeline: bold, italic, strikethrough, monospace, code blocks, headings, and — critically — the @@ -17,8 +17,13 @@ from gateway.platforms.signal_format import markdown_to_signal # --------------------------------------------------------------------------- def _m2s(text: str): - """Shorthand: return (plain_text, styles).""" - return markdown_to_signal(text) + """Shorthand: call the static method and return (plain_text, styles).""" + return SignalAdapter._markdown_to_signal(text) + + +def test_shared_helper_matches_signal_adapter_wrapper(): + text = "🙂 **bold** and `code`" + assert markdown_to_signal(text) == SignalAdapter._markdown_to_signal(text) def _style_types(styles: list[str]) -> list[str]: diff --git a/tests/gateway/test_startup_watchdog.py b/tests/gateway/test_startup_watchdog.py index 7b0f261a98..0b7296976d 100644 --- a/tests/gateway/test_startup_watchdog.py +++ b/tests/gateway/test_startup_watchdog.py @@ -2,7 +2,8 @@ The watchdog covers the pre-event-loop window: armed at process entry (before the gateway package imports — the implementation is the stdlib-only -top-level module ``hermes_startup_watchdog``), disarmed once the gateway's asyncio loop is confirmed +top-level module ``hermes_startup_watchdog``; ``gateway.startup_watchdog`` +is a re-export shim), disarmed once the gateway's asyncio loop is confirmed live. If neither happens within the deadline — and the process shows no CPU progress, so slow-but-alive schema migrations are exempt — it must dump diagnostics, record a lifecycle exit, and hard-exit with the service-restart @@ -83,6 +84,13 @@ class TestContracts: assert SERVICE_RESTART_EXIT_CODE == GATEWAY_SERVICE_RESTART_EXIT_CODE + def test_gateway_shim_reexports_same_objects(self): + import gateway.startup_watchdog as shim + + assert shim.arm_startup_watchdog is arm_startup_watchdog + assert shim.disarm_startup_watchdog is disarm_startup_watchdog + assert shim.kick_startup_watchdog is kick_startup_watchdog + def test_implementation_module_is_stdlib_only(self): """Import-lightness is a correctness property (arm-before-imports, no import-lock dependence at fire time): the implementation module diff --git a/tests/gateway/test_streaming_tts_gateway_regression.py b/tests/gateway/test_streaming_tts_gateway_regression.py index 4979f773c3..21c81e93db 100644 --- a/tests/gateway/test_streaming_tts_gateway_regression.py +++ b/tests/gateway/test_streaming_tts_gateway_regression.py @@ -97,6 +97,7 @@ def _setup_monkeypatches(monkeypatch, tmp_path): (tmp_path / "config.yaml").write_text("agent:\n model: test-model\n", encoding="utf-8") monkeypatch.setattr(gateway_run, "_hermes_home", tmp_path) monkeypatch.setattr(gateway_run, "_env_path", tmp_path / ".env") + monkeypatch.setattr(gateway_run, "load_dotenv", lambda *args, **kwargs: None) monkeypatch.setattr(gateway_run, "_load_gateway_config", lambda: {}) monkeypatch.setattr( gateway_run, diff --git a/tests/gateway/test_subagent_failure_notice.py b/tests/gateway/test_subagent_failure_notice.py index a713f93c0a..d5115bb7e5 100644 --- a/tests/gateway/test_subagent_failure_notice.py +++ b/tests/gateway/test_subagent_failure_notice.py @@ -112,8 +112,7 @@ def _make_runner_and_captured(monkeypatch, run_still_current=True): progress_queue=None, _loop_for_step=None, ) - from gateway.run_turn_runner import TurnRunner - return TurnRunner(_StubGatewayRunner(), ctx), captured + return run_mod.TurnRunner(_StubGatewayRunner(), ctx), captured class TestGatewayFailureNotice: diff --git a/tests/gateway/test_subagent_protection_30170.py b/tests/gateway/test_subagent_protection_30170.py index 8aceb90a24..3849326870 100644 --- a/tests/gateway/test_subagent_protection_30170.py +++ b/tests/gateway/test_subagent_protection_30170.py @@ -187,7 +187,7 @@ class TestBusyHandlerDemotesInterruptForSubagents: runner._running_agents[sk] = parent runner.adapters[event.source.platform] = adapter - with patch("gateway.platforms.base.merge_pending_message_event"): + with patch("gateway.run.merge_pending_message_event"): await runner._handle_active_session_busy_message(event, sk) parent.interrupt.assert_called_once_with("please stop") @@ -208,7 +208,7 @@ class TestBusyHandlerDemotesInterruptForSubagents: runner._running_agents[sk] = parent runner.adapters[event.source.platform] = adapter - with patch("gateway.platforms.base.merge_pending_message_event"): + with patch("gateway.run.merge_pending_message_event"): await runner._handle_active_session_busy_message(event, sk) parent.interrupt.assert_not_called() @@ -236,7 +236,7 @@ class TestBusyHandlerDemotesInterruptForSubagents: runner._running_agents[sk] = parent runner.adapters[event.source.platform] = adapter - with patch("gateway.platforms.base.merge_pending_message_event"): + with patch("gateway.run.merge_pending_message_event"): await runner._handle_active_session_busy_message(event, sk) parent.steer.assert_called_once_with("course-correct") diff --git a/tests/gateway/test_transcript_read_failure_100788.py b/tests/gateway/test_transcript_read_failure_100788.py index d4dd90404f..b4dd6c3708 100644 --- a/tests/gateway/test_transcript_read_failure_100788.py +++ b/tests/gateway/test_transcript_read_failure_100788.py @@ -26,8 +26,7 @@ import sqlite3 import pytest from gateway.config import GatewayConfig -from gateway.session import SessionStore -from gateway.session_transcript import TranscriptReadError +from gateway.session import SessionStore, TranscriptReadError @pytest.fixture @@ -84,7 +83,7 @@ class TestLoadTranscriptReadFailure: class TestSlashCommandsOnUnreadableTranscript: def test_history_unreadable_text_is_explicit(self): - from gateway.slash_commands_status import HISTORY_UNREADABLE + from gateway.slash_commands import HISTORY_UNREADABLE assert "unreadable" in HISTORY_UNREADABLE assert "not a new conversation" in HISTORY_UNREADABLE diff --git a/tests/gateway/test_turn_context.py b/tests/gateway/test_turn_context.py index 33db480e8b..48bfbf8eb8 100644 --- a/tests/gateway/test_turn_context.py +++ b/tests/gateway/test_turn_context.py @@ -21,7 +21,7 @@ from gateway.turn_context import TurnContext def _make_runner(ctx): - from gateway.run_turn_runner import TurnRunner + from gateway.run import TurnRunner class _StubGatewayRunner: def _adapter_for_source(self, source): @@ -51,7 +51,7 @@ class TestTurnContext: class TestTurnRunner: def test_methods_exist_and_bind(self): - from gateway.run_turn_runner import TurnRunner + from gateway.run import TurnRunner ctx = TurnContext() runner = _make_runner(ctx) @@ -137,7 +137,7 @@ class TestTurnRunner: _hooks_ref=SimpleNamespace(loaded_hooks=False), ) - from gateway.run_turn_runner import TurnRunner + from gateway.run import TurnRunner result = TurnRunner(gateway_runner, ctx).run_sync() diff --git a/tests/gateway/test_turn_lease.py b/tests/gateway/test_turn_lease.py index 2f8372cbe0..aa6bc6a183 100644 --- a/tests/gateway/test_turn_lease.py +++ b/tests/gateway/test_turn_lease.py @@ -494,3 +494,19 @@ def test_runner_release_turn_lease_is_token_scoped_and_bare_safe(): _run(scenario()) +def test_registry_len_reports_tracked_sessions(): + """``len(registry)`` is public API (plugins/diagnostics size the registry + with it): 0 when empty, one per tracked session_id, and it follows eviction.""" + async def scenario(): + registry = SessionTurnLeaseRegistry(max_entries=8) + assert len(registry) == 0 + a = await registry.acquire("s1", owner_key="k1", generation=1, timeout=1) + assert len(registry) == 1 + b = await registry.acquire("s2", owner_key="k2", generation=1, timeout=1) + assert len(registry) == 2 + registry.release(a) + registry.release(b) + # Released (idle) entries stay tracked until eviction, matching _leases. + assert len(registry) == len(registry._leases) == 2 + + _run(scenario()) diff --git a/tests/hermes_cli/test_certifi_repair.py b/tests/hermes_cli/test_certifi_repair.py index 85c22abd7f..6ca002a926 100644 --- a/tests/hermes_cli/test_certifi_repair.py +++ b/tests/hermes_cli/test_certifi_repair.py @@ -23,6 +23,9 @@ from pathlib import Path import pytest import hermes_cli._early_recovery as er +import subprocess +from hermes_cli import doctor_platform +from hermes_cli import main_install_repair def _fake_certifi(monkeypatch, bundle_path: Path): @@ -94,7 +97,7 @@ class TestUpdateProbeScriptChecksBundle: monkeypatch.setattr( main_mod, "_resolve_install_target_python", lambda *a, **k: sys.executable ) - main_mod._detect_broken_lazy_refresh_imports(["pip"]) + main_install_repair._detect_broken_lazy_refresh_imports(["pip"]) script = captured["script"] # Execute the probe script with a fake certifi installed. @@ -134,7 +137,7 @@ class TestDoctorCertificates: monkeypatch.setenv("SSL_CERT_FILE", str(tmp_path / "missing.pem")) issues = [] - doctor_mod.check_certificates(should_fix=False, issues=issues) + doctor_platform.check_certificates(should_fix=False, issues=issues) out = capsys.readouterr().out assert "broken" in out.lower() assert issues, "a broken bundle must be funneled into the action list" @@ -166,10 +169,10 @@ class TestDoctorCertificates: monkeypatch.setattr( "agent.ssl_guard.verify_ca_bundle_with_fallback", fake_verify ) - monkeypatch.setattr(doctor_mod.subprocess, "run", fake_run) + monkeypatch.setattr(subprocess, "run", fake_run) issues = [] - doctor_mod.check_certificates(should_fix=True, issues=issues) + doctor_platform.check_certificates(should_fix=True, issues=issues) out = capsys.readouterr().out assert calls["pip"], "--fix must run a pip force-reinstall of certifi" @@ -186,8 +189,8 @@ class TestDoctorCertificates: def _fail_run(*a, **k): raise AssertionError("healthy bundle must not trigger a reinstall") - monkeypatch.setattr(doctor_mod.subprocess, "run", _fail_run) - doctor_mod.check_certificates(should_fix=True, issues=[]) + monkeypatch.setattr(subprocess, "run", _fail_run) + doctor_platform.check_certificates(should_fix=True, issues=[]) out = capsys.readouterr().out assert "valid" in out.lower() diff --git a/tests/hermes_cli/test_doctor.py b/tests/hermes_cli/test_doctor.py index 70c7f0efe3..10fd7c5ef9 100644 --- a/tests/hermes_cli/test_doctor.py +++ b/tests/hermes_cli/test_doctor.py @@ -1,5 +1,6 @@ """Tests for hermes_cli.doctor.""" +import importlib.util import os import subprocess import sys @@ -12,36 +13,43 @@ from types import SimpleNamespace import pytest import hermes_cli.doctor as doctor +import hermes_constants +from hermes_cli import config as config_mod import hermes_cli.gateway as gateway_cli from hermes_cli import doctor as doctor_mod -from hermes_cli.doctor import _has_provider_env_config +from hermes_cli.doctor_config import _has_provider_env_config +import shutil +from hermes_cli import doctor_tools +from hermes_cli import doctor_state +from hermes_cli import doctor_platform +from hermes_cli import doctor_config class TestDoctorPlatformHints: def test_termux_package_hint(self, monkeypatch): monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") - assert doctor._is_termux() is True - assert doctor._python_install_cmd() == "python -m pip install" - assert doctor._system_package_install_cmd("ripgrep") == "pkg install ripgrep" + assert hermes_constants.is_termux() is True + assert doctor_platform._python_install_cmd() == "python -m pip install" + assert doctor_platform._system_package_install_cmd("ripgrep") == "pkg install ripgrep" def test_sqlite_upgrade_hint_recreates_docker_containers(self, monkeypatch): - monkeypatch.setattr(doctor, "detect_install_method", lambda _root: "docker") + monkeypatch.setattr(config_mod, "detect_install_method", lambda _root: "docker") - hint = doctor._sqlite_upgrade_hint() + hint = doctor_platform._sqlite_upgrade_hint() assert "docker pull nousresearch/hermes-agent:latest" in hint assert "recreate all Hermes containers" in hint assert "hermes update" not in hint def test_sqlite_upgrade_hint_keeps_git_runtime_repair(self): - hint = doctor._sqlite_upgrade_hint("git") + hint = doctor_platform._sqlite_upgrade_hint("git") assert "run `hermes update`" in hint def test_sqlite_upgrade_hint_uses_pkg_for_apt_managed_install(self): - hint = doctor._sqlite_upgrade_hint("apt") + hint = doctor_platform._sqlite_upgrade_hint("apt") assert "run `pkg upgrade hermes-agent`" in hint assert "hermes update" not in hint @@ -50,7 +58,7 @@ class TestDoctorPlatformHints: from hermes_cli.config import recommended_update_command_for_method guidance = recommended_update_command_for_method("nix") - hint = doctor._sqlite_upgrade_hint("nix") + hint = doctor_platform._sqlite_upgrade_hint("nix") assert guidance in hint assert f"run `{guidance}`" not in hint @@ -74,9 +82,9 @@ class TestDoctorToolAvailabilitySummary: {"name": "rl", "missing_vars": ["TINKER_API_KEY"]}, {"name": "web", "missing_vars": ["EXA_API_KEY"]}, ] - monkeypatch.setattr(doctor, "_enabled_cli_toolsets_for_doctor", lambda: {"web"}) + monkeypatch.setattr(doctor_tools, "_enabled_cli_toolsets_for_doctor", lambda: {"web"}) - filtered = doctor._missing_api_key_toolsets_for_summary(unavailable) + filtered = doctor_tools._missing_api_key_toolsets_for_summary(unavailable) assert [item["name"] for item in filtered] == ["web"] @@ -98,7 +106,7 @@ class TestDoctorToolAvailabilitySummary: lambda: unavailable, ) - rows = doctor._doctor_web_capability_rows() + rows = doctor_tools._doctor_web_capability_rows() assert rows assert all(status == "warn" for status, _, _ in rows) assert any("firecrawl selected; provider not configured" in detail for _, _, detail in rows) @@ -120,7 +128,7 @@ class TestDoctorToolAvailabilitySummary: lambda: ready, ) - rows = doctor._doctor_web_capability_rows() + rows = doctor_tools._doctor_web_capability_rows() assert rows == [ ("ok", "web search", "(ddgs)"), ("ok", "web extract", "(ddgs)"), @@ -205,10 +213,10 @@ class TestDoctorToolAvailabilityOverrides: def test_marks_kanban_available_only_when_missing_worker_env_gate(self, monkeypatch): - monkeypatch.setattr(doctor, "_honcho_is_configured_for_doctor", lambda: False) + monkeypatch.setattr(doctor_state, "_honcho_is_configured_for_doctor", lambda: False) monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) - available, unavailable = doctor._apply_doctor_tool_availability_overrides( + available, unavailable = doctor_tools._apply_doctor_tool_availability_overrides( [], [{"name": "kanban", "env_vars": [], "tools": ["kanban_show"]}], ) @@ -220,7 +228,7 @@ class TestDoctorToolAvailabilityOverrides: monkeypatch.setenv("HERMES_KANBAN_TASK", "probe") kanban_entry = {"name": "kanban", "env_vars": [], "tools": ["kanban_show"]} - available, unavailable = doctor._apply_doctor_tool_availability_overrides( + available, unavailable = doctor_tools._apply_doctor_tool_availability_overrides( [], [kanban_entry], ) @@ -240,7 +248,7 @@ class TestHonchoDoctorConfigDetection: lambda: fake_config, ) - assert doctor._honcho_is_configured_for_doctor() + assert doctor_state._honcho_is_configured_for_doctor() @@ -256,7 +264,7 @@ def test_doctor_reports_vercel_backend_diagnostics(monkeypatch, tmp_path): monkeypatch.setenv("VERCEL_TOKEN", "super-secret-value") monkeypatch.delenv("VERCEL_PROJECT_ID", raising=False) monkeypatch.setenv("VERCEL_TEAM_ID", "team") - monkeypatch.setattr(doctor_mod.importlib.util, "find_spec", lambda name: object() if name == "vercel" else None) + monkeypatch.setattr(importlib.util, "find_spec", lambda name: object() if name == "vercel" else None) fake_model_tools = types.SimpleNamespace( check_tool_availability=lambda *a, **kw: ([], []), @@ -384,14 +392,14 @@ def test_run_doctor_termux_treats_docker_and_browser_warnings_as_expected(monkey monkeypatch.setenv("TERMUX_VERSION", "0.118.3") monkeypatch.setenv("PREFIX", "/data/data/com.termux/files/usr") - real_which = doctor_mod.shutil.which + real_which = shutil.which def fake_which(cmd): if cmd in {"docker", "node", "npm"}: return None return real_which(cmd) - monkeypatch.setattr(doctor_mod.shutil, "which", fake_which) + monkeypatch.setattr(shutil, "which", fake_which) out = helper._run_doctor_and_capture(monkeypatch, tmp_path, provider="") @@ -747,7 +755,7 @@ def test_run_doctor_termux_does_not_mark_browser_available_without_agent_browser monkeypatch.setattr(doctor_mod, "HERMES_HOME", home) monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) monkeypatch.setattr(doctor_mod, "_DHH", str(home)) - monkeypatch.setattr(doctor_mod.shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None) + monkeypatch.setattr(shutil, "which", lambda cmd: "/data/data/com.termux/files/usr/bin/node" if cmd in {"node", "npm"} else None) fake_model_tools = types.SimpleNamespace( check_tool_availability=lambda *a, **kw: (["terminal"], [{"name": "browser", "env_vars": [], "tools": ["browser_navigate"]}]), @@ -794,7 +802,7 @@ def _doctor_env_for_agent_browser(monkeypatch, tmp_path): monkeypatch.setattr(doctor_mod, "PROJECT_ROOT", project) monkeypatch.setattr(doctor_mod, "_DHH", str(home)) monkeypatch.setattr( - doctor_mod.shutil, + shutil, "which", lambda cmd: "/usr/bin/node" if cmd in {"node", "npm"} else None, ) @@ -825,7 +833,7 @@ def test_run_doctor_reports_agent_browser_resolves_via_npx(monkeypatch, tmp_path monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "npx agent-browser") warm_calls = [] monkeypatch.setattr( - bt, "warm_agent_browser_npx_cache", lambda *a, **kw: warm_calls.append(1) or True + "tools.browser_tool_install.warm_agent_browser_npx_cache", lambda *a, **kw: warm_calls.append(1) or True ) buf = io.StringIO() @@ -851,7 +859,7 @@ def test_run_doctor_fix_warms_npx_cache_when_agent_browser_resolves_via_npx( monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "npx agent-browser") warm_calls = [] monkeypatch.setattr( - bt, "warm_agent_browser_npx_cache", lambda *a, **kw: warm_calls.append(1) or True + "tools.browser_tool_install.warm_agent_browser_npx_cache", lambda *a, **kw: warm_calls.append(1) or True ) buf = io.StringIO() @@ -872,7 +880,7 @@ def test_run_doctor_fix_reports_when_npx_warmup_fails(monkeypatch, tmp_path): import tools.browser_tool as bt monkeypatch.setattr(bt, "_find_agent_browser", lambda **_kw: "npx agent-browser") - monkeypatch.setattr(bt, "warm_agent_browser_npx_cache", lambda *a, **kw: False) + monkeypatch.setattr("tools.browser_tool_install.warm_agent_browser_npx_cache", lambda *a, **kw: False) buf = io.StringIO() with contextlib.redirect_stdout(buf): @@ -1371,9 +1379,9 @@ class TestDoctorCodexCliHintPlacement: monkeypatch.setattr(_auth_mod, "get_minimax_oauth_auth_status", lambda: {"logged_in": False}) monkeypatch.setattr(_auth_mod, "get_xai_oauth_auth_status", lambda: {"logged_in": False}) - real_which = doctor_mod.shutil.which + real_which = shutil.which monkeypatch.setattr( - doctor_mod.shutil, + shutil, "which", lambda cmd: ("/usr/local/bin/codex" if codex_cli_present else None) if cmd == "codex" else real_which(cmd), ) @@ -1491,22 +1499,22 @@ class TestDoctorDeprecatedConfigAndEnv: def test_collect_deprecated_env_vars_ignores_empty(self): - assert doctor_mod.collect_deprecated_env_vars({"TERMINAL_CWD": " "}) == [] - assert doctor_mod.collect_deprecated_env_vars({}) == [] - assert doctor_mod.collect_deprecated_env_vars(None) == [] + assert doctor_config.collect_deprecated_env_vars({"TERMINAL_CWD": " "}) == [] + assert doctor_config.collect_deprecated_env_vars({}) == [] + assert doctor_config.collect_deprecated_env_vars(None) == [] def test_hermes_tool_progress_warning_says_unsupported_since_floor(self): """HERMES_TOOL_PROGRESS lost its last consumer (the retired v3→4 migration) when the v12 support floor landed — doctor must say the variable is ignored rather than merely 'deprecated but read'.""" findings = dict( - doctor_mod.collect_deprecated_env_vars({"HERMES_TOOL_PROGRESS": "true"}) + doctor_config.collect_deprecated_env_vars({"HERMES_TOOL_PROGRESS": "true"}) ) assert "ignored/unsupported since config floor v12" in findings["HERMES_TOOL_PROGRESS"] # The MODE variant is still read by the gateway fallback → keeps the # plain deprecation wording. mode = dict( - doctor_mod.collect_deprecated_env_vars({"HERMES_TOOL_PROGRESS_MODE": "all"}) + doctor_config.collect_deprecated_env_vars({"HERMES_TOOL_PROGRESS_MODE": "all"}) ) assert mode["HERMES_TOOL_PROGRESS_MODE"] == "display.tool_progress in config.yaml" @@ -1547,7 +1555,7 @@ class TestDoctorDeprecatedConfigAndEnv: def test_report_does_not_count_as_blocking_issue(self, monkeypatch, tmp_path, capsys): """report_deprecated_config_and_env is warn-only — no issues list mutation.""" - findings = doctor_mod.report_deprecated_config_and_env( + findings = doctor_config.report_deprecated_config_and_env( {"delegation": {"max_async_children": 2}}, {"HERMES_TOOL_PROGRESS_MODE": "verbose"}, ) @@ -1565,34 +1573,34 @@ class TestMacOSTCCGrants: """Non-macOS: the check must produce no output even with a bundle present.""" monkeypatch.setattr(doctor_mod.sys, "platform", "linux") monkeypatch.setattr( - doctor_mod, + doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app", ) - doctor_mod.check_macos_tcc_grants() + doctor_platform.check_macos_tcc_grants() assert capsys.readouterr().out == "" def test_silent_when_no_desktop_bundle(self, monkeypatch, capsys): """No locally-built desktop bundle: nothing to check, no output.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") - monkeypatch.setattr(doctor_mod, "_desktop_app_bundle", lambda: None) - doctor_mod.check_macos_tcc_grants() + monkeypatch.setattr(doctor_platform, "_desktop_app_bundle", lambda: None) + doctor_platform.check_macos_tcc_grants() assert capsys.readouterr().out == "" def test_warns_on_cdhash_pinned_dr(self, monkeypatch, capsys, tmp_path): """Pre-#73681 builds have a cdhash-pinned DR → warn that grants reset.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") monkeypatch.setattr( - doctor_mod, + doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app", ) monkeypatch.setattr( - doctor_mod, + doctor_platform, "_macos_desktop_dr", lambda app: 'designated => identifier "com.nousresearch.hermes" and cdhash H"97e692f3890f781fa0ad5ad6cb9d769cfaf42628"', ) - doctor_mod.check_macos_tcc_grants() + doctor_platform.check_macos_tcc_grants() out = capsys.readouterr().out assert "TCC grants will reset after every update" in out assert "cdhash-pinned" in out @@ -1602,16 +1610,16 @@ class TestMacOSTCCGrants: """Post-#73681 identifier-only DR → stable + stale-grant repair info.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") monkeypatch.setattr( - doctor_mod, + doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app", ) monkeypatch.setattr( - doctor_mod, + doctor_platform, "_macos_desktop_dr", lambda app: 'designated => identifier "com.nousresearch.hermes"', ) - doctor_mod.check_macos_tcc_grants() + doctor_platform.check_macos_tcc_grants() out = capsys.readouterr().out assert "TCC signing identity is stable" in out assert "identifier-pinned" in out @@ -1628,16 +1636,16 @@ class TestMacOSTCCGrants: hint, still prints the stale-grant repair info.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") monkeypatch.setattr( - doctor_mod, + doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app", ) monkeypatch.setattr( - doctor_mod, + doctor_platform, "_macos_desktop_dr", lambda app: 'designated => identifier "com.nousresearch.hermes" and certificate root = H"aabbcc"', ) - doctor_mod.check_macos_tcc_grants() + doctor_platform.check_macos_tcc_grants() out = capsys.readouterr().out assert "TCC signing identity is stable" in out assert "certificate-anchored" in out @@ -1648,12 +1656,12 @@ class TestMacOSTCCGrants: """codesign failure → warn, never crash.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") monkeypatch.setattr( - doctor_mod, + doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app", ) - monkeypatch.setattr(doctor_mod, "_macos_desktop_dr", lambda app: None) - doctor_mod.check_macos_tcc_grants() + monkeypatch.setattr(doctor_platform, "_macos_desktop_dr", lambda app: None) + doctor_platform.check_macos_tcc_grants() out = capsys.readouterr().out assert "could not read code-signing requirement" in out @@ -1661,12 +1669,12 @@ class TestMacOSTCCGrants: """Empty DR output must not false-positive as a stable identity.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") monkeypatch.setattr( - doctor_mod, + doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app", ) - monkeypatch.setattr(doctor_mod, "_macos_desktop_dr", lambda app: "") - doctor_mod.check_macos_tcc_grants() + monkeypatch.setattr(doctor_platform, "_macos_desktop_dr", lambda app: "") + doctor_platform.check_macos_tcc_grants() out = capsys.readouterr().out assert "could not read code-signing requirement" in out assert "stable" not in out @@ -1675,7 +1683,7 @@ class TestMacOSTCCGrants: """A hanging codesign must degrade to the unreadable-DR warning, never crash.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") monkeypatch.setattr( - doctor_mod, + doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app", ) @@ -1683,8 +1691,8 @@ class TestMacOSTCCGrants: def _timeout(*args, **kwargs): raise subprocess.TimeoutExpired(cmd=["codesign"], timeout=15) - monkeypatch.setattr(doctor_mod.subprocess, "run", _timeout) - doctor_mod.check_macos_tcc_grants() + monkeypatch.setattr(subprocess, "run", _timeout) + doctor_platform.check_macos_tcc_grants() out = capsys.readouterr().out assert "could not read code-signing requirement" in out assert "stable" not in out @@ -1693,12 +1701,12 @@ class TestMacOSTCCGrants: """No codesign binary → same graceful unreadable-DR warning.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") monkeypatch.setattr( - doctor_mod, + doctor_platform, "_desktop_app_bundle", lambda: tmp_path / "Hermes.app", ) - monkeypatch.setattr(doctor_mod.shutil, "which", lambda _name: None) - doctor_mod.check_macos_tcc_grants() + monkeypatch.setattr(shutil, "which", lambda _name: None) + doctor_platform.check_macos_tcc_grants() out = capsys.readouterr().out assert "could not read code-signing requirement" in out assert "stable" not in out @@ -1708,9 +1716,9 @@ def test_run_doctor_reports_shadowed_lightpanda_engine(monkeypatch, tmp_path): helper = TestDoctorMemoryProviderSection() import tools.browser_tool as bt - monkeypatch.setattr(bt, "_using_lightpanda_engine", lambda: True) + monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True) monkeypatch.setattr( - bt, "lightpanda_engine_status", + "tools.browser_tool_lightpanda_fallback.lightpanda_engine_status", lambda: (False, "cloud provider Browserbase is selected"), ) out = helper._run_doctor_and_capture(monkeypatch, tmp_path) @@ -1722,8 +1730,8 @@ def test_run_doctor_reports_lightpanda_ok(monkeypatch, tmp_path): helper = TestDoctorMemoryProviderSection() import tools.browser_tool as bt - monkeypatch.setattr(bt, "_using_lightpanda_engine", lambda: True) - monkeypatch.setattr(bt, "lightpanda_engine_status", lambda: (True, "Browser Use mode")) + monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True) + monkeypatch.setattr("tools.browser_tool_lightpanda_fallback.lightpanda_engine_status", lambda: (True, "Browser Use mode")) monkeypatch.setattr("tools.browser_lightpanda.find_lightpanda_binary", lambda: "/opt/lightpanda") out = helper._run_doctor_and_capture(monkeypatch, tmp_path) assert "Lightpanda" in out @@ -1734,8 +1742,8 @@ def test_run_doctor_warns_when_lightpanda_binary_missing(monkeypatch, tmp_path): helper = TestDoctorMemoryProviderSection() import tools.browser_tool as bt - monkeypatch.setattr(bt, "_using_lightpanda_engine", lambda: True) - monkeypatch.setattr(bt, "lightpanda_engine_status", lambda: (True, "Browser Use mode")) + monkeypatch.setattr("tools.browser_tool_lightpanda_fallback._using_lightpanda_engine", lambda: True) + monkeypatch.setattr("tools.browser_tool_lightpanda_fallback.lightpanda_engine_status", lambda: (True, "Browser Use mode")) monkeypatch.setattr("tools.browser_lightpanda.find_lightpanda_binary", lambda: None) out = helper._run_doctor_and_capture(monkeypatch, tmp_path) assert "Lightpanda selected but binary not found" in out diff --git a/tests/hermes_cli/test_doctor_dedicated_provider_skip.py b/tests/hermes_cli/test_doctor_dedicated_provider_skip.py index 73a2d66cc2..7882d6acd6 100644 --- a/tests/hermes_cli/test_doctor_dedicated_provider_skip.py +++ b/tests/hermes_cli/test_doctor_dedicated_provider_skip.py @@ -12,6 +12,7 @@ See: NousResearch/hermes-agent#22346 """ from __future__ import annotations +from hermes_cli import doctor_connectivity def test_build_apikey_providers_list_skips_dedicated_check_providers(): @@ -19,7 +20,7 @@ def test_build_apikey_providers_list_skips_dedicated_check_providers(): # Force a rebuild — the module caches the list on first call. doctor._APIKEY_PROVIDERS_CACHE = None - entries = doctor._build_apikey_providers_list() + entries = doctor_connectivity._build_apikey_providers_list() # Tuple shape: (display_name, env_vars, default_url, base_env, supports_health_check) names = {entry[0].lower() for entry in entries} diff --git a/tests/hermes_cli/test_doctor_journal_modes.py b/tests/hermes_cli/test_doctor_journal_modes.py index 55828de631..703d583b32 100644 --- a/tests/hermes_cli/test_doctor_journal_modes.py +++ b/tests/hermes_cli/test_doctor_journal_modes.py @@ -20,6 +20,7 @@ from hermes_cli.sqlite_safe_read import ( track_connection, untrack_connection, ) +from hermes_cli import doctor_platform VULNERABLE = (3, 50, 4) FIXED_VERSIONS = [(3, 51, 3), (3, 52, 0), (3, 50, 7), (3, 44, 6)] @@ -71,7 +72,7 @@ class TestReadJournalMode: db = tmp_path / "state.db" _make_db(db, journal_mode="WAL") - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) assert mode == "wal" assert error is None @@ -80,7 +81,7 @@ class TestReadJournalMode: db = tmp_path / "state.db" _make_db(db) - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) assert mode == "rollback" assert error is None @@ -90,14 +91,14 @@ class TestReadJournalMode: _make_db(db, journal_mode="WAL") assert _sidecars(tmp_path) == [] - assert doctor._read_journal_mode(db) == ("wal", None) + assert doctor_platform._read_journal_mode(db) == ("wal", None) assert _sidecars(tmp_path) == [] def test_missing_file_reports_error_and_does_not_create_it(self, tmp_path): db = tmp_path / "missing.db" - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) assert mode is None assert error @@ -107,7 +108,7 @@ class TestReadJournalMode: db = tmp_path / "state.db" db.touch() - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) assert mode is None assert error == "file is empty" @@ -116,7 +117,7 @@ class TestReadJournalMode: db = tmp_path / "state.db" db.write_bytes(b"SQLite f") - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) assert mode is None assert "not a database" in error @@ -125,7 +126,7 @@ class TestReadJournalMode: db = tmp_path / "state.db" db.write_bytes(b"this is not a sqlite database" * 4) - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) assert mode is None assert "not a database" in error @@ -137,7 +138,7 @@ class TestReadJournalMode: try: holder.execute("BEGIN EXCLUSIVE") - assert doctor._read_journal_mode(db) == ("rollback", None) + assert doctor_platform._read_journal_mode(db) == ("rollback", None) finally: holder.close() @@ -148,7 +149,7 @@ class TestReadJournalMode: _make_db(db, journal_mode="WAL") os.chmod(tmp_path, 0o555) try: - assert doctor._read_journal_mode(db) == ("wal", None) + assert doctor_platform._read_journal_mode(db) == ("wal", None) finally: os.chmod(tmp_path, 0o755) assert _sidecars(tmp_path) == [] @@ -161,8 +162,8 @@ class TestReadJournalMode: wal_bytes = wal_db.read_bytes() rollback_bytes = rollback_db.read_bytes() - assert doctor._read_journal_mode(wal_db) == ("wal", None) - assert doctor._read_journal_mode(rollback_db) == ("rollback", None) + assert doctor_platform._read_journal_mode(wal_db) == ("wal", None) + assert doctor_platform._read_journal_mode(rollback_db) == ("rollback", None) assert wal_db.read_bytes() == wal_bytes assert rollback_db.read_bytes() == rollback_bytes @@ -190,7 +191,7 @@ class TestLiveConnectionSafety: try: assert has_live_connection(db) - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) assert mode is None assert error == "database is open in this process" @@ -208,7 +209,7 @@ class TestLiveConnectionSafety: try: assert has_live_connection(db) - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) assert mode is None assert error == "database is open in this process" @@ -220,11 +221,11 @@ class TestLiveConnectionSafety: _make_db(db, journal_mode="WAL") conn = connect_tracked(db) - assert doctor._read_journal_mode(db)[0] is None + assert doctor_platform._read_journal_mode(db)[0] is None conn.close() assert not has_live_connection(db) - assert doctor._read_journal_mode(db) == ("wal", None) + assert doctor_platform._read_journal_mode(db) == ("wal", None) def test_refusal_creates_no_new_sidecars(self, tmp_path, clean_registry): db = tmp_path / "state.db" @@ -234,7 +235,7 @@ class TestLiveConnectionSafety: try: before = _sidecars(tmp_path) - doctor._read_journal_mode(db) + doctor_platform._read_journal_mode(db) assert _sidecars(tmp_path) == before finally: @@ -248,7 +249,7 @@ class TestLiveConnectionSafety: conn = connect_tracked(db) try: - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) finally: conn.close() @@ -271,14 +272,14 @@ class TestLiveConnectionSafety: try: holder.execute("BEGIN EXCLUSIVE") - assert doctor._read_journal_mode(db) == ("rollback", None) + assert doctor_platform._read_journal_mode(db) == ("rollback", None) finally: holder.close() class TestUnreadableReason: def test_missing_file_keeps_the_os_error_text(self, tmp_path): - reason = doctor._unreadable_reason(tmp_path / "gone.db") + reason = doctor_platform._unreadable_reason(tmp_path / "gone.db") assert "No such file or directory" in reason @@ -295,7 +296,7 @@ class TestUnreadableReason: _make_db(db) os.chmod(db, 0o000) try: - mode, error = doctor._read_journal_mode(db) + mode, error = doctor_platform._read_journal_mode(db) finally: os.chmod(db, 0o644) @@ -316,14 +317,14 @@ class TestUnreadableReason: monkeypatch.setattr("builtins.open", _fail) - assert doctor._unreadable_reason(db) == "file could not be read" + assert doctor_platform._unreadable_reason(db) == "file could not be read" class TestReportDatabaseJournalModes: def test_vulnerable_runtime_wal_db_is_exposed(self, tmp_path, capsys): _make_db(tmp_path / "state.db", journal_mode="WAL") - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) out = capsys.readouterr().out assert "state.db is in WAL mode" in out @@ -332,7 +333,7 @@ class TestReportDatabaseJournalModes: def test_vulnerable_runtime_rollback_db_is_listed_not_exposed(self, tmp_path, capsys): _make_db(tmp_path / "state.db") - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) out = capsys.readouterr().out assert "state.db: rollback journal mode" in out @@ -342,7 +343,7 @@ class TestReportDatabaseJournalModes: def test_fixed_runtime_wal_db_is_not_exposed(self, tmp_path, capsys, version): _make_db(tmp_path / "state.db", journal_mode="WAL") - doctor._report_database_journal_modes(tmp_path, version) + doctor_platform._report_database_journal_modes(tmp_path, version) out = capsys.readouterr().out assert "state.db: WAL journal mode" in out @@ -357,7 +358,7 @@ class TestReportDatabaseJournalModes: board.mkdir(parents=True) _make_db(board / "kanban.db", journal_mode="WAL") - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) out = capsys.readouterr().out assert "state.db is in WAL mode" in out @@ -366,7 +367,7 @@ class TestReportDatabaseJournalModes: assert "kanban/boards/myboard/kanban.db is in WAL mode" in out def test_missing_databases_are_skipped(self, tmp_path, capsys): - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) out = capsys.readouterr().out assert "state.db" not in out @@ -379,7 +380,7 @@ class TestReportDatabaseJournalModes: try: holder.execute("BEGIN EXCLUSIVE") - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) finally: holder.close() @@ -393,7 +394,7 @@ class TestReportDatabaseJournalModes: _make_db(db) os.chmod(db, 0o000) try: - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) finally: os.chmod(db, 0o644) @@ -404,7 +405,7 @@ class TestReportDatabaseJournalModes: def test_corrupt_database_does_not_crash(self, tmp_path, capsys): (tmp_path / "state.db").write_bytes(b"garbage bytes, not sqlite" * 8) - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) out = capsys.readouterr().out assert "state.db: journal mode could not be read" in out @@ -412,7 +413,7 @@ class TestReportDatabaseJournalModes: def test_read_error_is_informational_on_fixed_runtime(self, tmp_path, capsys): (tmp_path / "state.db").write_bytes(b"garbage bytes, not sqlite" * 8) - doctor._report_database_journal_modes(tmp_path, (3, 51, 3)) + doctor_platform._report_database_journal_modes(tmp_path, (3, 51, 3)) out = capsys.readouterr().out assert "state.db: journal mode could not be read" in out @@ -424,7 +425,7 @@ class TestReportDatabaseJournalModes: _make_db(db, journal_mode="WAL") db_bytes = db.read_bytes() - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) assert _sidecars(tmp_path) == [] assert db.read_bytes() == db_bytes @@ -434,7 +435,7 @@ class TestSizeAndRepairHint: def test_exposed_databases_report_size_and_repair_hint(self, tmp_path, capsys): db = tmp_path / "state.db" _make_db(db, journal_mode="WAL") - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) out = capsys.readouterr().out # _format_size picks the unit (a fresh test DB is KB-scale). assert re.search(r"\(\d[\d.]* [KMGT]?B\)", out) @@ -442,13 +443,13 @@ class TestSizeAndRepairHint: def test_no_repair_hint_when_nothing_is_exposed(self, tmp_path, capsys): _make_db(tmp_path / "state.db", journal_mode="DELETE") - doctor._report_database_journal_modes(tmp_path, VULNERABLE) + doctor_platform._report_database_journal_modes(tmp_path, VULNERABLE) assert "To clear the exposure:" not in capsys.readouterr().out def test_no_repair_hint_on_a_fixed_runtime(self, tmp_path, capsys): _make_db(tmp_path / "state.db", journal_mode="WAL") - doctor._report_database_journal_modes(tmp_path, FIXED_VERSIONS[0]) + doctor_platform._report_database_journal_modes(tmp_path, FIXED_VERSIONS[0]) assert "To clear the exposure:" not in capsys.readouterr().out def test_size_failure_does_not_crash(self, tmp_path, capsys): - assert doctor._format_db_size(tmp_path / "gone.db") == "size unknown" + assert doctor_platform._format_db_size(tmp_path / "gone.db") == "size unknown" diff --git a/tests/hermes_cli/test_macos_fda_guidance.py b/tests/hermes_cli/test_macos_fda_guidance.py index 4f0c2aef04..98d797156f 100644 --- a/tests/hermes_cli/test_macos_fda_guidance.py +++ b/tests/hermes_cli/test_macos_fda_guidance.py @@ -11,7 +11,9 @@ import io import contextlib import hermes_cli.doctor as doctor_mod +import pathlib from hermes_cli.setup import _print_macos_fda_tip +from hermes_cli import doctor_platform def _capture(fn): @@ -24,15 +26,15 @@ def _capture(fn): class TestDoctorFdaCheck: def test_silent_on_non_macos(self, monkeypatch): monkeypatch.setattr(doctor_mod.sys, "platform", "linux") - out = _capture(doctor_mod.check_macos_full_disk_access) + out = _capture(doctor_platform.check_macos_full_disk_access) assert out == "" def test_granted_reports_ok(self, monkeypatch, tmp_path): monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") tcc = tmp_path / "Library" / "Application Support" / "com.apple.TCC" tcc.mkdir(parents=True) - monkeypatch.setattr(doctor_mod.Path, "home", classmethod(lambda cls: tmp_path)) - out = _capture(doctor_mod.check_macos_full_disk_access) + monkeypatch.setattr(pathlib.Path, "home", classmethod(lambda cls: tmp_path)) + out = _capture(doctor_platform.check_macos_full_disk_access) assert "Full Disk Access granted" in out assert "Privacy_AllFiles" not in out @@ -40,13 +42,13 @@ class TestDoctorFdaCheck: monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") tcc = tmp_path / "Library" / "Application Support" / "com.apple.TCC" tcc.mkdir(parents=True) - monkeypatch.setattr(doctor_mod.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(pathlib.Path, "home", classmethod(lambda cls: tmp_path)) def _eperm(path): raise PermissionError(13, "Operation not permitted", str(path)) monkeypatch.setattr(doctor_mod.os, "listdir", _eperm) - out = _capture(doctor_mod.check_macos_full_disk_access) + out = _capture(doctor_platform.check_macos_full_disk_access) assert "Full Disk Access" in out assert "Privacy_AllFiles" in out assert "System Settings" in out @@ -54,10 +56,10 @@ class TestDoctorFdaCheck: def test_indeterminate_probe_is_silent(self, monkeypatch, tmp_path): """Missing TCC dir (weird install) must not nag.""" monkeypatch.setattr(doctor_mod.sys, "platform", "darwin") - monkeypatch.setattr(doctor_mod.Path, "home", classmethod(lambda cls: tmp_path)) + monkeypatch.setattr(pathlib.Path, "home", classmethod(lambda cls: tmp_path)) # tmp_path has no Library/Application Support/com.apple.TCC → # FileNotFoundError (an OSError that is not PermissionError). - out = _capture(doctor_mod.check_macos_full_disk_access) + out = _capture(doctor_platform.check_macos_full_disk_access) assert out == "" diff --git a/tests/hermes_cli/test_macos_tcc_anchor.py b/tests/hermes_cli/test_macos_tcc_anchor.py index f07eff9f4c..eed1e5ff65 100644 --- a/tests/hermes_cli/test_macos_tcc_anchor.py +++ b/tests/hermes_cli/test_macos_tcc_anchor.py @@ -25,6 +25,7 @@ import pytest import hermes_cli.doctor as doctor import hermes_cli.macos_tcc_anchor as tcc from hermes_constants import venv_python_path +from hermes_cli import doctor_platform def _darwin(monkeypatch): @@ -528,7 +529,7 @@ class TestDoctorCheck: monkeypatch.setattr( tcc, "tcc_anchor_state", lambda *a, **k: ("missing", "/x/.venv/bin/python") ) - doctor.check_macos_tcc_anchor(should_fix=False) + doctor_platform.check_macos_tcc_anchor(should_fix=False) out = capsys.readouterr().out assert "macOS TCC anchor missing" in out @@ -539,7 +540,7 @@ class TestDoctorCheck: monkeypatch.setattr( tcc, "ensure_tcc_anchor", lambda *a, **k: Path("/x/.venv/bin/python") ) - doctor.check_macos_tcc_anchor(should_fix=True) + doctor_platform.check_macos_tcc_anchor(should_fix=True) out = capsys.readouterr().out assert "macOS TCC anchor installed" in out @@ -547,7 +548,7 @@ class TestDoctorCheck: monkeypatch.setattr( tcc, "tcc_anchor_state", lambda *a, **k: ("active", "/x/.venv/bin/python") ) - doctor.check_macos_tcc_anchor(should_fix=False) + doctor_platform.check_macos_tcc_anchor(should_fix=False) out = capsys.readouterr().out assert "macOS TCC anchor active" in out @@ -555,7 +556,7 @@ class TestDoctorCheck: monkeypatch.setattr( tcc, "tcc_anchor_state", lambda *a, **k: ("skip", "not macOS") ) - doctor.check_macos_tcc_anchor(should_fix=False) + doctor_platform.check_macos_tcc_anchor(should_fix=False) assert capsys.readouterr().out == "" def test_never_crashes_on_exception(self, monkeypatch, capsys): @@ -563,7 +564,7 @@ class TestDoctorCheck: raise RuntimeError("tccd down") monkeypatch.setattr(tcc, "tcc_anchor_state", boom) - doctor.check_macos_tcc_anchor(should_fix=False) + doctor_platform.check_macos_tcc_anchor(should_fix=False) out = capsys.readouterr().out assert "macOS TCC anchor check failed" in out diff --git a/tests/hermes_cli/test_managed_scope_surfacing.py b/tests/hermes_cli/test_managed_scope_surfacing.py index aa410a3e23..30f0729e77 100644 --- a/tests/hermes_cli/test_managed_scope_surfacing.py +++ b/tests/hermes_cli/test_managed_scope_surfacing.py @@ -1,5 +1,6 @@ """Surfacing tests — managed scope shown in `config show` and `hermes doctor`.""" import pytest +from hermes_cli import doctor_config @pytest.fixture @@ -52,5 +53,5 @@ def test_doctor_silent_with_no_managed_scope(tmp_path, monkeypatch, capsys): from hermes_cli import managed_scope, doctor managed_scope.invalidate_managed_cache() - doctor.managed_scope_check() + doctor_config.managed_scope_check() assert capsys.readouterr().out.strip() == "" diff --git a/tests/hermes_cli/test_mcp_config.py b/tests/hermes_cli/test_mcp_config.py index 776677d0ac..a49d341cfe 100644 --- a/tests/hermes_cli/test_mcp_config.py +++ b/tests/hermes_cli/test_mcp_config.py @@ -399,11 +399,11 @@ class TestContextVarInterpolation: def test_workspace_folder_falls_back_to_cwd(self, monkeypatch): import os - import tools.file_tools as file_tools + import tools.file_tools_paths as file_tools_paths from tools.mcp_tool import _workspace_folder monkeypatch.setattr( - file_tools, "_authoritative_workspace_root", lambda task_id="default": None + file_tools_paths, "_authoritative_workspace_root", lambda task_id="default": None ) assert _workspace_folder() == os.getcwd() diff --git a/tests/hermes_cli/test_relay_plugin_cutover.py b/tests/hermes_cli/test_relay_plugin_cutover.py index 5d518b74a3..24eb773949 100644 --- a/tests/hermes_cli/test_relay_plugin_cutover.py +++ b/tests/hermes_cli/test_relay_plugin_cutover.py @@ -9,7 +9,7 @@ import pytest import yaml from hermes_cli.config import migrate_config -from hermes_cli.doctor import collect_relay_plugin_cutover_findings +from hermes_cli.doctor_config import collect_relay_plugin_cutover_findings from hermes_cli.relay_plugin_cutover import RELAY_PLUGINS_CONFIG_ENV diff --git a/tests/hermes_cli/test_sizefmt.py b/tests/hermes_cli/test_sizefmt.py index 17fe0d0e7f..bc2e4a92c0 100644 --- a/tests/hermes_cli/test_sizefmt.py +++ b/tests/hermes_cli/test_sizefmt.py @@ -53,7 +53,7 @@ def test_migrated_sites_render_through_the_shared_helper(): an implementation detail — only the rendering is pinned).""" from hermes_cli.backup import _format_size as backup from hermes_cli.checkpoints import _fmt_bytes as checkpoints - from hermes_cli.doctor import _human_bytes as doctor + from hermes_cli.sizefmt import format_bytes as doctor for n in (0, 512, 2048, 1234567, 1024**3, 1024**4): expected = format_bytes(n) diff --git a/tests/hermes_cli/test_status.py b/tests/hermes_cli/test_status.py index 37d0c0bb24..5ae09ca96b 100644 --- a/tests/hermes_cli/test_status.py +++ b/tests/hermes_cli/test_status.py @@ -1,6 +1,7 @@ from types import SimpleNamespace from hermes_cli.status import show_status +import subprocess def test_show_status_all_does_not_print_keenable_key_value(monkeypatch, capsys, tmp_path): @@ -48,7 +49,7 @@ def test_show_status_termux_gateway_section_skips_systemctl(monkeypatch, capsys, def _unexpected_systemctl(*args, **kwargs): raise AssertionError("systemctl should not be called in the Termux status view") - monkeypatch.setattr(status_mod.subprocess, "run", _unexpected_systemctl) + monkeypatch.setattr(subprocess, "run", _unexpected_systemctl) status_mod.show_status(SimpleNamespace(all=False, deep=False)) diff --git a/tests/hermes_cli/test_status_model_provider.py b/tests/hermes_cli/test_status_model_provider.py index 1c410c61cf..ebed771cc4 100644 --- a/tests/hermes_cli/test_status_model_provider.py +++ b/tests/hermes_cli/test_status_model_provider.py @@ -4,6 +4,7 @@ from types import SimpleNamespace from hermes_cli.nous_account import NousPaidServiceAccessInfo, NousPortalAccountInfo from hermes_cli.nous_subscription import NousFeatureState, NousSubscriptionFeatures +import subprocess def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_url=""): @@ -24,7 +25,7 @@ def _patch_common_status_deps(monkeypatch, status_mod, tmp_path, *, openai_base_ ) monkeypatch.setattr(auth_mod, "get_codex_auth_status", lambda: {}, raising=False) monkeypatch.setattr( - status_mod.subprocess, + subprocess, "run", lambda *args, **kwargs: SimpleNamespace(stdout="inactive\n", returncode=3), ) diff --git a/tests/test_code_skew.py b/tests/test_code_skew.py index 3ae3bbb6fb..3f9539cfbc 100644 --- a/tests/test_code_skew.py +++ b/tests/test_code_skew.py @@ -53,13 +53,13 @@ class TestShort: class TestModelSwitchSkewGuard: def test_guard_returns_none_without_skew(self, monkeypatch): - from gateway import slash_commands_model as slash_commands + from gateway import slash_commands monkeypatch.setattr(code_skew, "detect_code_skew", lambda: None) assert slash_commands._model_switch_skew_guard() is None def test_guard_message_names_revs_and_restart(self, monkeypatch): - from gateway import slash_commands_model as slash_commands + from gateway import slash_commands monkeypatch.setattr(code_skew, "detect_code_skew", lambda: ("abc1234567", "def4567890")) msg = slash_commands._model_switch_skew_guard() diff --git a/tests/test_estop.py b/tests/test_estop.py index 392e6fc869..85abbfaaab 100644 --- a/tests/test_estop.py +++ b/tests/test_estop.py @@ -164,7 +164,7 @@ def test_cron_tick_resumes_after_disengage(hermes_home, monkeypatch): def test_kanban_dispatch_blocked_when_engaged(hermes_home): - from gateway.kanban_watchers_common import _kanban_dispatch_allowed + from gateway.kanban_watchers import _kanban_dispatch_allowed assert _kanban_dispatch_allowed() is True estop.engage(reason="test") diff --git a/tests/test_model_tools.py b/tests/test_model_tools.py index fb84b69e4c..acbf794c08 100644 --- a/tests/test_model_tools.py +++ b/tests/test_model_tools.py @@ -394,19 +394,19 @@ class TestCoerceNumberInfNan: float('nan') are not JSON-compliant under strict serialization.""" def test_inf_returns_original_string(self): - from model_tools import _coerce_number + from tools.arg_coercion import _coerce_number assert _coerce_number("inf") == "inf" def test_nan_returns_original_string(self): - from model_tools import _coerce_number + from tools.arg_coercion import _coerce_number assert _coerce_number("nan") == "nan" def test_normal_numbers_still_coerce(self): """Guard against over-correction — real numbers still coerce.""" - from model_tools import _coerce_number + from tools.arg_coercion import _coerce_number assert _coerce_number("42") == 42 assert _coerce_number("3.14") == 3.14 assert _coerce_number("1e3") == 1000 diff --git a/tests/test_state_db_stats.py b/tests/test_state_db_stats.py index 8e51cc73ac..75f7b61ae4 100644 --- a/tests/test_state_db_stats.py +++ b/tests/test_state_db_stats.py @@ -1,15 +1,16 @@ """Tests for state.db health/stats collection (hermes doctor section). Covers: -- ``hermes_state.collect_state_db_stats``: read-only, best-effort stats +- ``hermes_state_dbfile.collect_state_db_stats``: read-only, best-effort stats (page_count, freelist, WAL size, journal mode, row counts, FTS presence, pending v23 FTS-rebuild bookkeeping). -- ``hermes_state.count_db_holders``: /proc-based best-effort probe for how +- ``hermes_state_dbfile.count_db_holders``: /proc-based best-effort probe for how many processes hold the DB file open (Linux only; None elsewhere/on error). -- ``hermes_cli.doctor._render_state_db_stats``: formatting/threshold helper +- ``hermes_cli.doctor_state._render_state_db_stats``: formatting/threshold helper the doctor state.db section prints from. """ +import hermes_state_dbfile import json import os import sqlite3 @@ -18,7 +19,9 @@ from pathlib import Path import pytest -from hermes_state import SessionDB, collect_state_db_stats, count_db_holders +from hermes_state import SessionDB +from hermes_state_dbfile import collect_state_db_stats, count_db_holders +import hermes_cli.doctor_state @pytest.fixture() @@ -95,7 +98,7 @@ def test_collect_and_render_stale_fts_holder_deferral(populated_db): assert stats["fts_rebuild_deferral"]["attempts"] == 4 assert stats["fts_rebuild_deferral"]["holder_pids"] == [4242] - from hermes_cli.doctor import _render_state_db_stats + from hermes_cli.doctor_state import _render_state_db_stats rendered = _render_state_db_stats(stats) warnings = [ @@ -181,7 +184,7 @@ def _base_stats(**overrides): def test_render_healthy_stats_no_warnings(): - from hermes_cli.doctor import _render_state_db_stats + from hermes_cli.doctor_state import _render_state_db_stats lines = _render_state_db_stats(_base_stats(), holders=2) kinds = [k for k, *_ in lines] @@ -194,10 +197,7 @@ def test_render_healthy_stats_no_warnings(): def test_render_warns_on_large_db(): - from hermes_cli.doctor import ( - STATE_DB_SIZE_WARN_BYTES, - _render_state_db_stats, - ) + from hermes_cli.doctor_state import STATE_DB_SIZE_WARN_BYTES, _render_state_db_stats big = STATE_DB_SIZE_WARN_BYTES + 1 lines = _render_state_db_stats( @@ -213,7 +213,7 @@ def test_render_warns_on_large_db(): def test_render_large_db_with_pending_rebuild_suggests_optimize(): - from hermes_cli.doctor import STATE_DB_SIZE_WARN_BYTES, _render_state_db_stats + from hermes_cli.doctor_state import STATE_DB_SIZE_WARN_BYTES, _render_state_db_stats big = STATE_DB_SIZE_WARN_BYTES + 1 lines = _render_state_db_stats( @@ -225,7 +225,7 @@ def test_render_large_db_with_pending_rebuild_suggests_optimize(): def test_render_large_db_legacy_trigram_suggests_optimize(): - from hermes_cli.doctor import STATE_DB_SIZE_WARN_BYTES, _render_state_db_stats + from hermes_cli.doctor_state import STATE_DB_SIZE_WARN_BYTES, _render_state_db_stats big = STATE_DB_SIZE_WARN_BYTES + 1 lines = _render_state_db_stats( @@ -237,7 +237,7 @@ def test_render_large_db_legacy_trigram_suggests_optimize(): def test_render_large_db_v1_trigram_suggests_optimize(): - from hermes_cli.doctor import STATE_DB_SIZE_WARN_BYTES, _render_state_db_stats + from hermes_cli.doctor_state import STATE_DB_SIZE_WARN_BYTES, _render_state_db_stats lines = _render_state_db_stats( _base_stats( @@ -254,7 +254,7 @@ def test_render_does_not_duplicate_legacy_wal_warning(): """A large WAL must NOT warn here: doctor's pre-existing WAL check (50 MB threshold, with a --fix checkpoint) already covers it, and a second warning at a higher threshold would duplicate the output.""" - from hermes_cli.doctor import _render_state_db_stats + from hermes_cli.doctor_state import _render_state_db_stats lines = _render_state_db_stats( _base_stats(wal_size_bytes=256 * 1024 * 1024 + 1), holders=None @@ -265,7 +265,7 @@ def test_render_does_not_duplicate_legacy_wal_warning(): def test_render_handles_all_none_stats(): - from hermes_cli.doctor import _render_state_db_stats + from hermes_cli.doctor_state import _render_state_db_stats empty = {k: None for k in _base_stats()} empty["fts_tables"] = None diff --git a/tests/test_yuanbao_integration.py b/tests/test_yuanbao_integration.py index 7382dcd552..b68cf1f208 100644 --- a/tests/test_yuanbao_integration.py +++ b/tests/test_yuanbao_integration.py @@ -276,35 +276,35 @@ class TestP0ChatLockEviction: def test_eviction_skips_locked(self): """When eviction is needed, locked entries are skipped.""" adapter = YuanbaoAdapter(make_config()) - from gateway.platforms.yuanbao import MessageSender + from gateway.platforms.yuanbao import OutboundManager # Fill to capacity with unlocked locks - for i in range(MessageSender.CHAT_DICT_MAX_SIZE): - adapter._outbound.sender._chat_locks[f"chat_{i}"] = asyncio.Lock() + for i in range(OutboundManager.CHAT_DICT_MAX_SIZE): + adapter._outbound._chat_locks[f"chat_{i}"] = asyncio.Lock() # Lock the oldest entry - oldest_key = next(iter(adapter._outbound.sender._chat_locks)) - oldest_lock = adapter._outbound.sender._chat_locks[oldest_key] + oldest_key = next(iter(adapter._outbound._chat_locks)) + oldest_lock = adapter._outbound._chat_locks[oldest_key] # Simulate a held lock by acquiring it in a non-async way (set _locked) # asyncio.Lock is not held until actually acquired; so we test the # method logic by acquiring the first lock manually. # For a sync test, we check that get_chat_lock doesn't crash. - new_lock = adapter._outbound.sender.get_chat_lock("new_chat") - assert "new_chat" in adapter._outbound.sender._chat_locks + new_lock = adapter._outbound.get_chat_lock("new_chat") + assert "new_chat" in adapter._outbound._chat_locks assert isinstance(new_lock, asyncio.Lock) # The oldest unlocked entry should have been evicted - assert len(adapter._outbound.sender._chat_locks) == MessageSender.CHAT_DICT_MAX_SIZE + assert len(adapter._outbound._chat_locks) == OutboundManager.CHAT_DICT_MAX_SIZE def test_move_to_end_on_access(self): """Accessing an existing key moves it to the end (MRU).""" adapter = YuanbaoAdapter(make_config()) - adapter._outbound.sender._chat_locks["a"] = asyncio.Lock() - adapter._outbound.sender._chat_locks["b"] = asyncio.Lock() - adapter._outbound.sender._chat_locks["c"] = asyncio.Lock() + adapter._outbound._chat_locks["a"] = asyncio.Lock() + adapter._outbound._chat_locks["b"] = asyncio.Lock() + adapter._outbound._chat_locks["c"] = asyncio.Lock() # Access "a" — should move to end - adapter._outbound.sender.get_chat_lock("a") - keys = list(adapter._outbound.sender._chat_locks.keys()) + adapter._outbound.get_chat_lock("a") + keys = list(adapter._outbound._chat_locks.keys()) assert keys[-1] == "a" assert keys[0] == "b" diff --git a/tests/test_yuanbao_markdown.py b/tests/test_yuanbao_markdown.py index 1d038e960a..936583b1ae 100644 --- a/tests/test_yuanbao_markdown.py +++ b/tests/test_yuanbao_markdown.py @@ -16,7 +16,6 @@ import unittest # Ensure project root is on the path sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) -from gateway.platforms import helpers as _mdchunk from gateway.platforms.yuanbao import MarkdownProcessor @@ -24,10 +23,10 @@ from gateway.platforms.yuanbao import MarkdownProcessor class TestHasUnclosedFence(unittest.TestCase): def test_unclosed_fence(self): - self.assertTrue(_mdchunk.text_has_unclosed_fence("```python\ncode")) + self.assertTrue(MarkdownProcessor.has_unclosed_fence("```python\ncode")) def test_closed_fence(self): - self.assertFalse(_mdchunk.text_has_unclosed_fence("```python\ncode\n```")) + self.assertFalse(MarkdownProcessor.has_unclosed_fence("```python\ncode\n```")) @@ -36,25 +35,25 @@ class TestHasUnclosedFence(unittest.TestCase): def test_inline_backtick_ignored(self): text = "`inline code` is fine" - self.assertFalse(_mdchunk.text_has_unclosed_fence(text)) + self.assertFalse(MarkdownProcessor.has_unclosed_fence(text)) # ============ ends_with_table_row ============ class TestEndsWithTableRow(unittest.TestCase): def test_simple_table_row(self): - self.assertTrue(_mdchunk.text_ends_with_table_row("| col1 | col2 |")) + self.assertTrue(MarkdownProcessor.ends_with_table_row("| col1 | col2 |")) def test_table_row_in_middle(self): text = "| col1 | col2 |\nsome other text" - self.assertFalse(_mdchunk.text_ends_with_table_row(text)) + self.assertFalse(MarkdownProcessor.ends_with_table_row(text)) def test_table_separator_row(self): - self.assertTrue(_mdchunk.text_ends_with_table_row("| --- | --- |")) + self.assertTrue(MarkdownProcessor.ends_with_table_row("| --- | --- |")) @@ -63,13 +62,13 @@ class TestEndsWithTableRow(unittest.TestCase): class TestSplitAtParagraphBoundary(unittest.TestCase): def test_split_at_empty_line(self): text = "paragraph one\n\nparagraph two\n\nparagraph three\nextra" - head, tail = _mdchunk.split_at_paragraph_boundary(text, 30) + head, tail = MarkdownProcessor.split_at_paragraph_boundary(text, 30) self.assertLessEqual(len(head), 30) self.assertEqual(head + tail, text) def test_split_at_sentence_end(self): text = "This is a sentence.\nNext line.\nAnother line." - head, tail = _mdchunk.split_at_paragraph_boundary(text, 25) + head, tail = MarkdownProcessor.split_at_paragraph_boundary(text, 25) self.assertLessEqual(len(head), 25) self.assertEqual(head + tail, text) @@ -77,7 +76,7 @@ class TestSplitAtParagraphBoundary(unittest.TestCase): def test_chinese_sentence_boundary(self): text = "这是第一句话。\n这是第二句话。\n这是第三句话。" - head, tail = _mdchunk.split_at_paragraph_boundary(text, 15) + head, tail = MarkdownProcessor.split_at_paragraph_boundary(text, 15) self.assertLessEqual(len(head), 15) self.assertEqual(head + tail, text) @@ -107,7 +106,7 @@ class TestChunkMarkdownText(unittest.TestCase): text = "Some intro text.\n\n" + table + "\n\nSome outro text." result = MarkdownProcessor.chunk_markdown_text(text, 3000) for chunk in result: - self.assertFalse(_mdchunk.text_has_unclosed_fence(chunk)) + self.assertFalse(MarkdownProcessor.has_unclosed_fence(chunk)) def test_multiple_paragraphs(self): @@ -166,7 +165,7 @@ def test_large_fence_kept_whole(): # 代码块应在同一个 chunk 中(允许超出 max_chars) fence_chunks = [c for c in chunks if "```python" in c] for c in fence_chunks: - assert not _mdchunk.text_has_unclosed_fence(c) + assert not MarkdownProcessor.has_unclosed_fence(c) diff --git a/tests/test_yuanbao_reconnect_set_active.py b/tests/test_yuanbao_reconnect_set_active.py index 9bb8a02040..5483dfc5ed 100644 --- a/tests/test_yuanbao_reconnect_set_active.py +++ b/tests/test_yuanbao_reconnect_set_active.py @@ -1,7 +1,7 @@ """test_yuanbao_reconnect_set_active.py - Verify _do_reconnect restores the active singleton. Regression test for #58363: after a WS disconnect/reconnect cycle, -``YuanbaoAdapter.get_active()`` must return the live adapter (not ``None``). +``get_active_adapter()`` must return the live adapter (not ``None``). The original ``_do_reconnect()`` succeeded but never called ``YuanbaoAdapter.set_active()``, leaving the singleton permanently ``None`` until a full gateway restart. @@ -20,6 +20,7 @@ import pytest from gateway.platforms.yuanbao import ( YuanbaoAdapter, ConnectionManager, + get_active_adapter, ) @@ -64,7 +65,7 @@ async def test_do_reconnect_calls_set_active_on_success(): ): # Clear any existing active instance YuanbaoAdapter.set_active(None) - assert YuanbaoAdapter.get_active() is None + assert get_active_adapter() is None # Run reconnect result = await cm._do_reconnect() @@ -73,7 +74,7 @@ async def test_do_reconnect_calls_set_active_on_success(): assert result is True # After successful reconnect, get_active() must return the adapter - assert YuanbaoAdapter.get_active() is adapter + assert get_active_adapter() is adapter @pytest.mark.asyncio @@ -93,7 +94,7 @@ async def test_do_reconnect_does_not_set_active_on_failure(): ): # Clear any existing active instance YuanbaoAdapter.set_active(None) - assert YuanbaoAdapter.get_active() is None + assert get_active_adapter() is None # Run reconnect - should fail result = await cm._do_reconnect() @@ -102,4 +103,4 @@ async def test_do_reconnect_does_not_set_active_on_failure(): assert result is False # get_active() should still be None - assert YuanbaoAdapter.get_active() is None + assert get_active_adapter() is None diff --git a/tests/tools/test_file_operations.py b/tests/tools/test_file_operations.py index b1c939dcc9..94bb981d22 100644 --- a/tests/tools/test_file_operations.py +++ b/tests/tools/test_file_operations.py @@ -8,14 +8,13 @@ from unittest.mock import MagicMock from tests.tools.file_ops_fakes import READ_SENTINEL_RE, compound_read_output from tools.environments.local import _find_bash, _msys_to_windows_path, LocalEnvironment +from agent.file_safety import is_write_denied as _is_write_denied +from tools.file_operations_common import LintResult, SearchMatch from tools.file_operations import ( - _is_write_denied, ReadResult, WriteResult, PatchResult, SearchResult, - SearchMatch, - LintResult, ShellFileOperations, normalize_read_pagination, normalize_search_pagination, diff --git a/tests/tools/test_file_operations_edge_cases.py b/tests/tools/test_file_operations_edge_cases.py index 103c172884..075508c9b8 100644 --- a/tests/tools/test_file_operations_edge_cases.py +++ b/tests/tools/test_file_operations_edge_cases.py @@ -9,7 +9,8 @@ import pytest from unittest.mock import MagicMock, patch from tests.tools.file_ops_fakes import READ_SENTINEL_RE, compound_read_output -from tools.file_operations import ShellFileOperations, _parse_search_context_line +from tools.file_operations import ShellFileOperations +from tools.file_operations_search import _parse_search_context_line # ========================================================================= diff --git a/tests/tools/test_file_read_guards.py b/tests/tools/test_file_read_guards.py index 52321a637f..ea7f8e56eb 100644 --- a/tests/tools/test_file_read_guards.py +++ b/tests/tools/test_file_read_guards.py @@ -17,13 +17,15 @@ from unittest.mock import patch, MagicMock from tools.file_tools import ( read_file_tool, write_file_tool, - reset_file_dedup, _is_blocked_device, - _invalidate_dedup_for_path, _READ_DEDUP_STATUS_MESSAGE, _DEFAULT_MAX_READ_CHARS, _read_tracker, +) +from tools.file_tools_read_tracking import ( + _invalidate_dedup_for_path, notify_other_tool_call, + reset_file_dedup, ) diff --git a/tests/tools/test_file_tools.py b/tests/tools/test_file_tools.py index 6f033990c5..171f18fee0 100644 --- a/tests/tools/test_file_tools.py +++ b/tests/tools/test_file_tools.py @@ -718,8 +718,8 @@ class TestDedupInvalidationTaskResolution: # The task resolves the relative path into the workspace; the default # task (the old buggy resolution) would resolve into proc. - correct = str(ft._resolve_path("data.txt", task_id)) - buggy = str(ft._resolve_path("data.txt")) + correct = str(ft._resolve_path_for_task("data.txt", task_id)) + buggy = str(ft._resolve_path_for_task("data.txt")) assert correct != buggy, "test precondition: cwds must diverge" # Populate the dedup cache via a real read. @@ -728,7 +728,8 @@ class TestDedupInvalidationTaskResolution: assert correct in keys, keys # Invalidate as write_file_tool does; the entry must be gone. - ft._invalidate_dedup_for_path("data.txt", task_id) + from tools.file_tools_read_tracking import _invalidate_dedup_for_path + _invalidate_dedup_for_path("data.txt", task_id) remaining = [k[0] for k in ft._read_tracker.get(task_id, {}).get("dedup", {})] assert correct not in remaining, remaining @@ -916,8 +917,8 @@ class TestNotFoundCache: _check_not_found_cache, _record_not_found, _read_tracker, - _NOT_FOUND_TTL_SECONDS, ) + from tools.file_tools_read_tracking import _NOT_FOUND_TTL_SECONDS import tools.file_tools as ft tid = "neg-cache-ttl-6" @@ -998,8 +999,8 @@ class TestNotFoundCache: _check_not_found_cache, _record_not_found, _read_tracker, - notify_other_tool_call, ) + from tools.file_tools_read_tracking import notify_other_tool_call tid = "neg-cache-notify" _read_tracker.pop(tid, None) diff --git a/tests/tools/test_file_tools_cwd_resolution.py b/tests/tools/test_file_tools_cwd_resolution.py index 28f7ac5f5d..006cbecd8a 100644 --- a/tests/tools/test_file_tools_cwd_resolution.py +++ b/tests/tools/test_file_tools_cwd_resolution.py @@ -21,6 +21,7 @@ from pathlib import Path, PurePosixPath import pytest import tools.file_tools as ft +import tools.file_tools_paths as ftp import tools.terminal_tool as terminal_tool @@ -112,7 +113,7 @@ def test_container_absolute_input_path_does_not_follow_host_symlink(tmp_path, mo def test_container_path_normalization_uses_posix_path_syntax(): - resolved = ft._normalize_without_host_deref("/workspace/projects/foo/../bar") + resolved = ftp._normalize_without_host_deref("/workspace/projects/foo/../bar") assert resolved == PurePosixPath("/workspace/projects/bar") assert str(resolved) == "/workspace/projects/bar" diff --git a/tests/tools/test_file_write_safety.py b/tests/tools/test_file_write_safety.py index 5dd358e314..32fae33054 100644 --- a/tests/tools/test_file_write_safety.py +++ b/tests/tools/test_file_write_safety.py @@ -8,7 +8,7 @@ from pathlib import Path import pytest -from tools.file_operations import _is_write_denied +from agent.file_safety import is_write_denied as _is_write_denied class TestStaticDenyList: diff --git a/tests/tools/test_macos_protected_search.py b/tests/tools/test_macos_protected_search.py index 20609b6c3b..31392355d0 100644 --- a/tests/tools/test_macos_protected_search.py +++ b/tests/tools/test_macos_protected_search.py @@ -5,7 +5,8 @@ from pathlib import Path import tools.file_operations as file_operations from tools.environments.local import LocalEnvironment -from tools.file_operations import ShellFileOperations, _macos_protected_search_exclusions +from tools.file_operations import ShellFileOperations +from tools.file_operations_search import _macos_protected_search_exclusions class RecordingEnvironment: diff --git a/tests/tools/test_read_loop_detection.py b/tests/tools/test_read_loop_detection.py index 8143d8ea97..22ea380bb6 100644 --- a/tests/tools/test_read_loop_detection.py +++ b/tests/tools/test_read_loop_detection.py @@ -22,9 +22,9 @@ from unittest.mock import patch, MagicMock from tools.file_tools import ( read_file_tool, search_tool, - notify_other_tool_call, _read_tracker, ) +from tools.file_tools_read_tracking import notify_other_tool_call class _FakeReadResult: diff --git a/tests/tools/test_resolve_path.py b/tests/tools/test_resolve_path.py index b35d74e73b..93873be297 100644 --- a/tests/tools/test_resolve_path.py +++ b/tests/tools/test_resolve_path.py @@ -1,4 +1,4 @@ -"""Tests for _resolve_path() — TERMINAL_CWD-aware path resolution in file_tools.""" +"""Tests for _resolve_path_for_task() — TERMINAL_CWD-aware path resolution in file_tools_paths.""" import os from pathlib import Path @@ -6,14 +6,14 @@ from types import SimpleNamespace class TestResolvePath: - """Verify _resolve_path respects TERMINAL_CWD for worktree isolation.""" + """Verify _resolve_path_for_task respects TERMINAL_CWD for worktree isolation.""" def test_relative_path_uses_terminal_cwd(self, monkeypatch, tmp_path): """Relative paths resolve against TERMINAL_CWD, not process CWD.""" monkeypatch.setenv("TERMINAL_CWD", str(tmp_path)) - from tools.file_tools import _resolve_path + from tools.file_tools_paths import _resolve_path_for_task - result = _resolve_path("foo/bar.py") + result = _resolve_path_for_task("foo/bar.py") assert result == (tmp_path / "foo" / "bar.py") @@ -25,14 +25,14 @@ class TestResolvePath: live_dir.mkdir() monkeypatch.setenv("TERMINAL_CWD", str(start_dir)) - from tools import file_tools, terminal_tool + from tools import file_tools_paths, terminal_tool task_id = "live-cwd" # The session's completed `cd` recorded the new directory. terminal_tool.record_session_cwd(task_id, str(live_dir)) try: - result = file_tools._resolve_path("nested/file.txt", task_id=task_id) + result = file_tools_paths._resolve_path_for_task("nested/file.txt", task_id=task_id) finally: terminal_tool.clear_session_cwd(task_id) diff --git a/tests/tools/test_search_budget_truncation.py b/tests/tools/test_search_budget_truncation.py index 9e58c48ba1..b018b0d6ae 100644 --- a/tests/tools/test_search_budget_truncation.py +++ b/tests/tools/test_search_budget_truncation.py @@ -5,7 +5,8 @@ import pytest import tools.file_operations as file_operations from tools.environments.local import LocalEnvironment -from tools.file_operations import ExecuteResult, ShellFileOperations, _search_stdout_and_limit +from tools.file_operations import ExecuteResult, ShellFileOperations +from tools.file_operations_search import _search_stdout_and_limit TIMEOUT = "[Command timed out after 60s]" diff --git a/tests/tools/test_search_error_guard.py b/tests/tools/test_search_error_guard.py index 0f01dcfb38..e8042f5db6 100644 --- a/tests/tools/test_search_error_guard.py +++ b/tests/tools/test_search_error_guard.py @@ -26,11 +26,8 @@ import shutil import pytest -from tools.file_operations import ( - ShellFileOperations, - _pattern_has_regex_newline, - _split_tool_diagnostics, -) +from tools.file_operations import ShellFileOperations +from tools.file_operations_search import _pattern_has_regex_newline, _split_tool_diagnostics from tools.environments.local import LocalEnvironment diff --git a/tests/tools/test_search_files_cpu_windows.py b/tests/tools/test_search_files_cpu_windows.py index 652f7255c9..8839e72a53 100644 --- a/tests/tools/test_search_files_cpu_windows.py +++ b/tests/tools/test_search_files_cpu_windows.py @@ -7,12 +7,11 @@ import types import pytest from tools.environments.local import LocalEnvironment -from tools.file_operations import ( +from tools.file_operations import SearchResult, ShellFileOperations +from tools.file_operations_search import ( _ACTIVE_FILENAME_SEARCH_ROOTS, _FILENAME_SEARCH_ADMISSION, _normalized_filename_search_root, - SearchResult, - ShellFileOperations, ) from tools.interrupt import set_interrupt @@ -215,7 +214,7 @@ def test_interrupt_published_after_final_sample_prevents_filename_dispatch(monke return interrupted monkeypatch.setattr( - "tools.file_operations.tool_interrupt.is_interrupted", + "tools.file_operations_search.tool_interrupt.is_interrupted", pause_after_clear_sample, ) diff --git a/tests/tools/test_terminal_scope_multiplex.py b/tests/tools/test_terminal_scope_multiplex.py index fb48010b63..4f07bb37fa 100644 --- a/tests/tools/test_terminal_scope_multiplex.py +++ b/tests/tools/test_terminal_scope_multiplex.py @@ -94,7 +94,7 @@ def test_routed_turn_reads_every_terminal_consumer_from_profile( import tools.terminal_tool as tt from agent import runtime_cwd from gateway.platforms import base as gbase - from tools import browser_tool, env_probe, file_tools + from tools import browser_tool, env_probe, file_tools_paths b_cwd = tmp_path / "b-work" b_cwd.mkdir() @@ -114,7 +114,7 @@ def test_routed_turn_reads_every_terminal_consumer_from_profile( assert not any( "alpha-shared" in c for c in gbase._docker_sandbox_dir_candidates("agent:bee:x") ) - assert file_tools._configured_terminal_cwd() == str(b_cwd) + assert file_tools_paths._configured_terminal_cwd() == str(b_cwd) assert runtime_cwd.resolve_agent_cwd() == b_cwd assert browser_tool._is_local_backend() is True # env_probe bails out with "" for remote backends; a local profile diff --git a/tests/tools/test_write_deny.py b/tests/tools/test_write_deny.py index 200418e6e1..c71a509692 100644 --- a/tests/tools/test_write_deny.py +++ b/tests/tools/test_write_deny.py @@ -5,7 +5,7 @@ import os from pathlib import Path from unittest.mock import patch -from tools.file_operations import _is_write_denied +from agent.file_safety import is_write_denied as _is_write_denied class TestWriteDenyExactPaths: diff --git a/tests/tui_gateway/test_hosted_room_two_gateway_scoped.py b/tests/tui_gateway/test_hosted_room_two_gateway_scoped.py index 72d4750725..a48058c920 100644 --- a/tests/tui_gateway/test_hosted_room_two_gateway_scoped.py +++ b/tests/tui_gateway/test_hosted_room_two_gateway_scoped.py @@ -72,7 +72,7 @@ async def test_in_process_scoped_transport_contract_finishes_headlessly( PlatformConfig(enabled=True, extra={"key": "target-peer-key-1234567890"}) ) target._run_idempotency_store.close() - from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore + from gateway.platforms.api_server import RunIdempotencyStore target._run_idempotency_store = RunIdempotencyStore( str(tmp_path / "target-runs.db") diff --git a/tools/code_execution_env.py b/tools/code_execution_env.py index daaee3ae64..7c28a859be 100644 --- a/tools/code_execution_env.py +++ b/tools/code_execution_env.py @@ -251,7 +251,7 @@ def _resolve_child_cwd(mode: str, staging_dir: str, task_id: str = "") -> str: if recorded and os.path.isdir(recorded): return recorded try: - from tools.file_tools import _registered_task_cwd_override + from tools.file_tools_paths import _registered_task_cwd_override session_cwd = _registered_task_cwd_override(task_id) except Exception: session_cwd = None diff --git a/tools/file_operations.py b/tools/file_operations.py index 490e31835f..b057cd2911 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -2,16 +2,16 @@ """File operations (read, write, patch, search) over any terminal backend. Every operation is a shell command run through the backend's ``execute()``, so one -implementation serves every environment (local, docker, ssh, modal, ...). Companions, -re-exported here: ``file_operations_common`` (result dataclasses, text helpers), -``file_operations_lint`` (LintMixin), ``file_operations_search`` (SearchMixin). +implementation serves every environment (local, docker, ssh, modal, ...). Companions: +``file_operations_common`` (result dataclasses, text helpers), ``file_operations_lint`` +(LintMixin), ``file_operations_search`` (SearchMixin). """ import base64 import binascii import os import re -import sys # noqa: F401 (tests monkeypatch tools.file_operations.sys.platform) +import sys import difflib import hashlib import json @@ -23,19 +23,13 @@ from typing import Optional, Dict from pathlib import Path from tools.binary_extensions import BINARY_EXTENSIONS -from agent.file_safety import get_write_denied_error, is_write_denied as _is_write_denied # noqa: F401 -from tools.file_operations_common import ( # noqa: F401 (re-exported) - ExecuteResult, LintResult, PatchResult, ReadResult, SearchMatch, SearchResult, WriteResult, +from agent.file_safety import get_write_denied_error +from tools.file_operations_common import ( + ExecuteResult, PatchResult, ReadResult, SearchResult, WriteResult, _UTF8_BOM, _detect_line_ending, _has_bom, _normalize_line_endings, _strip_bom, _strip_terminal_fence_leaks, normalize_read_pagination, normalize_search_pagination) from tools.file_operations_lint import LINTERS_INPROC, LintMixin, _FAIL_CLOSED_INPROC_EXTS -from tools.file_operations_search import ( # noqa: F401 (re-exported) - SearchMixin, _ACTIVE_FILENAME_SEARCH_ROOTS, _FILENAME_SEARCH_ADMISSION, - _acquire_filename_search_roots, _filename_search_root_keys, - _macos_protected_search_exclusions, _normalized_filename_search_root, - _parse_search_context_line, _pattern_has_regex_newline, - _release_filename_search_roots, _search_stdout_and_limit, _split_tool_diagnostics) -from tools import interrupt as tool_interrupt # noqa: F401 (tests patch it via this module) +from tools.file_operations_search import SearchMixin logger = logging.getLogger(__name__) diff --git a/tools/file_operations_search.py b/tools/file_operations_search.py index ff7502777e..7c5e355a16 100644 --- a/tools/file_operations_search.py +++ b/tools/file_operations_search.py @@ -1,7 +1,7 @@ """Content/file search tier for ``tools.file_operations``. ``ShellFileOperations`` inherits ``SearchMixin``; module-level helpers are pure -(no I/O) and re-imported into ``tools.file_operations`` for back-compat. +(no I/O). """ import os diff --git a/tools/file_tools.py b/tools/file_tools.py index 4674d3ddec..4fa930c5b8 100644 --- a/tools/file_tools.py +++ b/tools/file_tools.py @@ -1,10 +1,9 @@ #!/usr/bin/env python3 """File Tools Module - LLM agent file manipulation tools. -Companions (every name re-imported here so ``tools.file_tools.X`` keeps working -for callers and test patches): ``file_tools_paths`` (task-aware resolution), -``file_tools_write_guards`` (write-side guards), ``file_tools_read_tracking`` -(per-task dedup / loop-detection / staleness state). +Companions: ``file_tools_paths`` (task-aware resolution), ``file_tools_write_guards`` +(write-side guards), ``file_tools_read_tracking`` (per-task dedup / loop-detection / +staleness state). """ import base64 @@ -25,27 +24,17 @@ from tools.file_operations import ( ShellFileOperations, normalize_read_pagination, normalize_search_pagination) from tools import file_state from agent.redact import redact_sensitive_text -from tools.file_tools_paths import ( # noqa: F401 (re-exported) - _CONTAINER_PATH_BACKENDS_FALLBACK, _TERMINAL_CWD_SENTINELS, _authoritative_workspace_root, - _configured_terminal_cwd, _expand_tilde, _normalize_without_host_deref, - _path_resolution_warning, _registered_task_cwd_override, _resolve_base_dir, _resolve_path, - _resolve_path_for_task, _sentinel_free_abs_cwd, _terminal_env_type_for_task, - _uses_container_paths) -from tools.file_tools_write_guards import ( # noqa: F401 (re-exported) - _PROTECTED_INSTRUCTION_BASENAMES, _READ_DEDUP_STATUS_MESSAGE, _SENSITIVE_EXACT_PATHS, - _SENSITIVE_PATH_PREFIXES, _check_approval_required_write, _check_binary_document_write, +from tools.file_tools_paths import ( + _expand_tilde, _path_resolution_warning, _resolve_base_dir, _resolve_path_for_task) +from tools.file_tools_write_guards import ( + _READ_DEDUP_STATUS_MESSAGE, _check_approval_required_write, _check_binary_document_write, _check_cross_profile_path, _check_protected_instruction_write, _check_sensitive_path, - _get_container_mirror_prefix_for_task, _get_hermes_config_resolved, _get_real_hermes_home, - _is_internal_file_status_text, _is_internal_file_tool_content, - _looks_like_read_file_line_numbered_content, _protected_instruction_config, - _protected_instruction_reason, _request_protected_instruction_approval) -from tools.file_tools_read_tracking import ( # noqa: F401 (re-exported) - _DEDUP_CAP, _NOT_FOUND_CAP, _NOT_FOUND_TTL_SECONDS, _READ_HISTORY_CAP, _READ_TIMESTAMPS_CAP, + _is_internal_file_tool_content) +from tools.file_tools_read_tracking import ( _bump_consecutive, _cap_read_tracker_data, _check_file_staleness, _check_not_found_cache, - _invalidate_dedup_for_path, _mark_verification_stale, _patch_failure_lock, - _patch_failure_tracker, _read_tracker, _read_tracker_lock, _record_not_found, - _record_patch_failure, _reset_patch_failures, _task_data, _update_read_timestamp, - notify_other_tool_call, reset_file_dedup) + _mark_verification_stale, _patch_failure_lock, _patch_failure_tracker, _read_tracker, + _read_tracker_lock, _record_not_found, _record_patch_failure, _reset_patch_failures, + _task_data, _update_read_timestamp) logger = logging.getLogger(__name__) @@ -265,8 +254,9 @@ _file_ops_cache: dict = {} def _create_terminal_env_for_file_ops(raw_task_id: str, task_id: str): """Build the terminal environment for *task_id* via the shared ``_create_configured_env``, so a file tool that runs before any terminal command still gets the configured backend.""" + from tools.terminal_tool_config import _CONTAINER_BACKENDS from tools.terminal_tool import ( - _CONTAINER_BACKENDS, _create_configured_env, _get_env_config, _is_unusable_container_cwd, + _create_configured_env, _get_env_config, _is_unusable_container_cwd, _resolve_task_host_cwd, _select_image, get_session_cwd, resolve_task_overrides) config = _get_env_config() diff --git a/tools/file_tools_paths.py b/tools/file_tools_paths.py index d40325a308..e1cde50427 100644 --- a/tools/file_tools_paths.py +++ b/tools/file_tools_paths.py @@ -177,9 +177,6 @@ def _resolve_path_for_task(filepath: str, task_id: str = "default") -> Path | Pu lambda: _resolve_base_dir(task_id, container_paths=container_paths), container_paths) -# Back-compat alias (imported by agent.context_references and tests). -_resolve_path = _resolve_path_for_task - def _path_resolution_warning(filepath: str, resolved: Path, task_id: str = "default") -> str | None: """Warn when a RELATIVE path resolved OUTSIDE the task's workspace root (the diff --git a/tools/mcp_tool_config.py b/tools/mcp_tool_config.py index 57407eadea..756aba02ad 100644 --- a/tools/mcp_tool_config.py +++ b/tools/mcp_tool_config.py @@ -73,7 +73,7 @@ def _workspace_folder() -> str: """Absolute workspace root for ``${workspaceFolder}``: the session's authoritative root (terminal cwd / task override / $TERMINAL_CWD), else cwd.""" try: - from tools.file_tools import _authoritative_workspace_root + from tools.file_tools_paths import _authoritative_workspace_root root = _authoritative_workspace_root() except Exception: root = None diff --git a/tools/patch_parser.py b/tools/patch_parser.py index 86fc16ae98..994e3c1c56 100644 --- a/tools/patch_parser.py +++ b/tools/patch_parser.py @@ -254,7 +254,7 @@ def apply_v4a_operations(operations: List[PatchOperation], file_ops: Any) -> 'Pa """Two-phase: validate everything, then apply (atomic on validation failure). A phase-2 failure (validate/apply race) carries a ``git diff`` note since state may be inconsistent. ``file_ops`` needs read_file_raw/write_file/delete_file/move_file.""" - from tools.file_operations import PatchResult # avoid circular import + from tools.file_operations_common import PatchResult # avoid circular import def _bullets(errs: List[str]) -> str: return "\n".join(f" • {e}" for e in errs) diff --git a/tools/send_message_senders.py b/tools/send_message_senders.py index 166eec23c0..79290d6acd 100644 --- a/tools/send_message_senders.py +++ b/tools/send_message_senders.py @@ -628,13 +628,13 @@ async def _send_qqbot(pconfig, chat_id, message): async def _send_yuanbao(chat_id, message, media_files=None): """Send via the running Yuanbao adapter's persistent WebSocket (no throwaway client possible).""" try: - from gateway.platforms.yuanbao import YuanbaoAdapter + from gateway.platforms.yuanbao import get_active_adapter, send_yuanbao_direct except ImportError: return _error("Yuanbao adapter module not available.") - adapter = YuanbaoAdapter.get_active() + adapter = get_active_adapter() if adapter is None: return _error("Yuanbao adapter is not running. Start the gateway with yuanbao platform enabled first.") try: - return await adapter._outbound.sender.send_direct(chat_id, message, media_files) + return await send_yuanbao_direct(adapter, chat_id, message, media_files=media_files) except Exception as e: return _error(f"Yuanbao send failed: {e}") diff --git a/tools/yuanbao_tools.py b/tools/yuanbao_tools.py index b7aec9813b..53c4873fdf 100644 --- a/tools/yuanbao_tools.py +++ b/tools/yuanbao_tools.py @@ -3,7 +3,7 @@ get_group_info / query_group_members / search_sticker / send_sticker / send_dm. Sticker flow mirrors chatbot-web's sticker-search/sticker-send: the LLM should search_sticker for a sticker_id (or pass the Chinese name), then send_sticker — never bare Unicode emoji. -The active adapter singleton lives in ``gateway.platforms.yuanbao.YuanbaoAdapter.get_active``. +The active adapter singleton lives in ``gateway.platforms.yuanbao.get_active_adapter``. """ from __future__ import annotations @@ -56,8 +56,8 @@ def _yb_tool(label: str): def _get_active_adapter(): """Lazy import to avoid ImportError when gateway.platforms.yuanbao is unavailable.""" with suppress(ImportError): - from gateway.platforms.yuanbao import YuanbaoAdapter - return YuanbaoAdapter.get_active() + from gateway.platforms.yuanbao import get_active_adapter + return get_active_adapter() return None diff --git a/tui_gateway/methods_groups.py b/tui_gateway/methods_groups.py index 3344e5cfb3..44a5c9b12b 100644 --- a/tui_gateway/methods_groups.py +++ b/tui_gateway/methods_groups.py @@ -148,7 +148,7 @@ def _room_link_run_storage_durable() -> bool: if store is None: # This process does not construct the API adapter that owns the store; open the # same shared SQLite store lazily so negotiation reflects the real replay boundary. - from gateway.platforms.api_server_run_idempotency import RunIdempotencyStore + from gateway.platforms.api_server import RunIdempotencyStore with _run_store_lock: store = getattr(_bound_server, "_run_idempotency_store", None) if store is None: