simplify(compat): doctor/status — drop 13 re-exports + the doctor_* globals() facade (97 names), repoint 6 callers / 13 tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
+2
-1
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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__)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
+2
-3
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
+5
-8
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
+7
-3
@@ -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__)
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
+35
-24
@@ -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.<name>``
|
||||
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.<name>`` 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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
+2
-5
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"):
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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]:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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())
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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 == ""
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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() == ""
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
+1
-1
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
# =========================================================================
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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]"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user