diff --git a/agent/auxiliary_client.py b/agent/auxiliary_client.py index 940bdfd4505b7..44b5ba0e07942 100644 --- a/agent/auxiliary_client.py +++ b/agent/auxiliary_client.py @@ -174,6 +174,22 @@ def _select_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]: return True, None +def _peek_pool_entry(provider: str) -> Tuple[bool, Optional[Any]]: + """Return a pool entry without refreshing or rotating credentials.""" + try: + pool = load_pool(provider) + except Exception as exc: + logger.debug("Auxiliary client: could not load pool for %s: %s", provider, exc) + return False, None + if not pool or not pool.has_credentials(): + return False, None + try: + return True, pool.peek() + except Exception as exc: + logger.debug("Auxiliary client: could not peek pool entry for %s: %s", provider, exc) + return True, None + + def _pool_runtime_api_key(entry: Any) -> str: if entry is None: return "" @@ -197,6 +213,15 @@ def _pool_runtime_base_url(entry: Any, fallback: str = "") -> str: return str(url or "").strip().rstrip("/") +def _jwt_is_expired(token: str) -> bool: + try: + from hermes_cli.auth import _codex_access_token_is_expiring + + return bool(_codex_access_token_is_expiring(token, 0)) + except Exception: + return False + + # ── Codex Responses → chat.completions adapter ───────────────────────────── # All auxiliary consumers call client.chat.completions.create(**kwargs) and # read response.choices[0].message.content. This adapter translates those @@ -637,11 +662,14 @@ def _read_codex_access_token() -> Optional[str]: fallback-to-Codex working when the pool state is stale but the stored OAuth token is still valid. """ - pool_present, entry = _select_pool_entry("openai-codex") + pool_present, entry = _peek_pool_entry("openai-codex") if pool_present: token = _pool_runtime_api_key(entry) if token: - return token + if _jwt_is_expired(token): + logger.debug("Codex pool access token expired, skipping") + else: + return token try: from hermes_cli.auth import _read_codex_tokens @@ -653,17 +681,9 @@ def _read_codex_access_token() -> Optional[str]: # Check JWT expiry — expired tokens block the auto chain and # prevent fallback to working providers (e.g. Anthropic). - try: - import base64 - payload = access_token.split(".")[1] - payload += "=" * (-len(payload) % 4) - claims = json.loads(base64.urlsafe_b64decode(payload)) - exp = claims.get("exp", 0) - if exp and time.time() > exp: - logger.debug("Codex access token expired (exp=%s), skipping", exp) - return None - except Exception: - pass # Non-JWT token or decode error — use as-is + if _jwt_is_expired(access_token): + logger.debug("Codex access token expired, skipping") + return None return access_token.strip() except Exception as exc: diff --git a/agent/builtin_memory_provider.py b/agent/builtin_memory_provider.py new file mode 100644 index 0000000000000..1a796d77875ab --- /dev/null +++ b/agent/builtin_memory_provider.py @@ -0,0 +1,30 @@ +"""No-op built-in memory provider shim. + +The built-in memory store is implemented by ``tools.memory_tool.MemoryStore``. +This provider exists so ``MemoryManager`` callers can represent that built-in +slot explicitly alongside one external memory plugin. +""" + +from __future__ import annotations + +from typing import Any, Dict, List + +from agent.memory_provider import MemoryProvider + + +class BuiltinMemoryProvider(MemoryProvider): + """Compatibility provider for the always-present built-in memory slot.""" + + @property + def name(self) -> str: + return "builtin" + + def is_available(self) -> bool: + return True + + def initialize(self, session_id: str, **kwargs: Any) -> None: + self.session_id = session_id + self.init_kwargs = dict(kwargs) + + def get_tool_schemas(self) -> List[Dict[str, Any]]: + return [] diff --git a/cli.py b/cli.py index 007b6e1eba267..5e83a2c775cca 100644 --- a/cli.py +++ b/cli.py @@ -6364,7 +6364,9 @@ def _voice_stop_and_transcribe(self): if result.get("success") and result.get("transcript", "").strip(): transcript = result["transcript"].strip() - self._attached_images.clear() + attached_images = getattr(self, "_attached_images", None) + if attached_images is not None: + attached_images.clear() if hasattr(self, '_app') and self._app: self._app.invalidate() self._pending_input.put(transcript) diff --git a/gateway/platforms/telegram.py b/gateway/platforms/telegram.py index 8b4e43514b66a..c92e2cf1c5888 100644 --- a/gateway/platforms/telegram.py +++ b/gateway/platforms/telegram.py @@ -8,6 +8,7 @@ """ import asyncio +import inspect import json import logging import os @@ -16,6 +17,13 @@ logger = logging.getLogger(__name__) + +async def _maybe_await(result: Any) -> Any: + """Await PTB calls in production while tolerating simple test doubles.""" + if inspect.isawaitable(result): + return await result + return result + try: from telegram import Update, Bot, Message, InlineKeyboardButton, InlineKeyboardMarkup from telegram.ext import ( @@ -232,16 +240,16 @@ async def _handle_polling_network_error(self, error: Exception) -> None: try: if self._app and self._app.updater and self._app.updater.running: - await self._app.updater.stop() + await _maybe_await(self._app.updater.stop()) except Exception: pass try: - await self._app.updater.start_polling( + await _maybe_await(self._app.updater.start_polling( allowed_updates=Update.ALL_TYPES, drop_pending_updates=False, error_callback=self._polling_error_callback_ref, - ) + )) logger.info( "[%s] Telegram polling resumed after network error (attempt %d)", self.name, attempt, @@ -279,16 +287,16 @@ async def _handle_polling_conflict(self, error: Exception) -> None: ) try: if self._app and self._app.updater and self._app.updater.running: - await self._app.updater.stop() + await _maybe_await(self._app.updater.stop()) except Exception: pass await asyncio.sleep(RETRY_DELAY) try: - await self._app.updater.start_polling( + await _maybe_await(self._app.updater.start_polling( allowed_updates=Update.ALL_TYPES, drop_pending_updates=False, error_callback=self._polling_error_callback_ref, - ) + )) logger.info("[%s] Telegram polling resumed after conflict retry %d", self.name, self._polling_conflict_count) self._polling_conflict_count = 0 # reset on success return @@ -309,7 +317,7 @@ async def _handle_polling_conflict(self, error: Exception) -> None: self._set_fatal_error("telegram_polling_conflict", message, retryable=False) try: if self._app and self._app.updater: - await self._app.updater.stop() + await _maybe_await(self._app.updater.stop()) except Exception as stop_error: logger.warning("[%s] Failed stopping Telegram polling after conflict: %s", self.name, stop_error, exc_info=True) await self._notify_fatal_error() @@ -622,7 +630,7 @@ def _env_float(name: str, default: float) -> float: _max_connect = 3 for _attempt in range(_max_connect): try: - await self._app.initialize() + await _maybe_await(self._app.initialize()) break except (NetworkError, TimedOut, OSError) as init_err: if _attempt < _max_connect - 1: @@ -634,7 +642,7 @@ def _env_float(name: str, default: float) -> float: await asyncio.sleep(wait) else: raise - await self._app.start() + await _maybe_await(self._app.start()) # Decide between webhook and polling mode webhook_url = os.getenv("TELEGRAM_WEBHOOK_URL", "").strip() @@ -649,7 +657,7 @@ def _env_float(name: str, default: float) -> float: from urllib.parse import urlparse webhook_path = urlparse(webhook_url).path or "/telegram" - await self._app.updater.start_webhook( + await _maybe_await(self._app.updater.start_webhook( listen="0.0.0.0", port=webhook_port, url_path=webhook_path, @@ -657,7 +665,7 @@ def _env_float(name: str, default: float) -> float: secret_token=webhook_secret, allowed_updates=Update.ALL_TYPES, drop_pending_updates=True, - ) + )) self._webhook_mode = True logger.info( "[%s] Webhook server listening on 0.0.0.0:%d%s", @@ -669,7 +677,7 @@ def _env_float(name: str, default: float) -> float: # previous webhook registration and silently stop receiving updates. delete_webhook = getattr(self._bot, "delete_webhook", None) if callable(delete_webhook): - await delete_webhook(drop_pending_updates=False) + await _maybe_await(delete_webhook(drop_pending_updates=False)) loop = asyncio.get_running_loop() @@ -687,11 +695,11 @@ def _polling_error_callback(error: Exception) -> None: # Store reference for retry use in _handle_polling_conflict self._polling_error_callback_ref = _polling_error_callback - await self._app.updater.start_polling( + await _maybe_await(self._app.updater.start_polling( allowed_updates=Update.ALL_TYPES, drop_pending_updates=True, error_callback=_polling_error_callback, - ) + )) # Register bot commands so Telegram shows a hint menu when users type / # List is derived from the central COMMAND_REGISTRY — adding a new @@ -703,9 +711,9 @@ def _polling_error_callback(error: Exception) -> None: # payload size limit. Skill descriptions are truncated to 40 # chars in telegram_menu_commands() to fit 100 commands safely. menu_commands, hidden_count = telegram_menu_commands(max_commands=100) - await self._bot.set_my_commands([ + await _maybe_await(self._bot.set_my_commands([ BotCommand(name, desc) for name, desc in menu_commands - ]) + ])) if hidden_count: logger.info( "[%s] Telegram menu: %d commands registered, %d hidden (over 100 limit). Use /commands for full list.", diff --git a/gateway/run.py b/gateway/run.py index 659ba8013697e..89f3a7fbb1283 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -6481,7 +6481,7 @@ def _apply_session_model_override( subsequent messages. Fields with ``None`` values are skipped so partial overrides don't clobber valid config defaults. """ - override = self._session_model_overrides.get(session_key) + override = getattr(self, "_session_model_overrides", {}).get(session_key) if not override: return model, runtime_kwargs model = override.get("model", model) @@ -6493,7 +6493,7 @@ def _apply_session_model_override( def _is_intentional_model_switch(self, session_key: str, agent_model: str) -> bool: """Return True if *agent_model* matches an active /model session override.""" - override = self._session_model_overrides.get(session_key) + override = getattr(self, "_session_model_overrides", {}).get(session_key) return override is not None and override.get("model") == agent_model def _evict_cached_agent(self, session_key: str) -> None: diff --git a/hermes_cli/banner.py b/hermes_cli/banner.py index b41ff5578904f..006281486569c 100644 --- a/hermes_cli/banner.py +++ b/hermes_cli/banner.py @@ -124,6 +124,23 @@ def get_available_skills() -> Dict[str, List[str]]: _UPDATE_CHECK_CACHE_SECONDS = 6 * 3600 +def _resolve_repo_dir() -> Optional[Path]: + """Return the active Hermes git checkout, or None if this isn't a git install. + + Prefer the checkout the current CLI is executing from. This keeps update + checks accurate when the operator keeps a separate contributor checkout + under ``~/.hermes/hermes-agent`` while running Hermes from another path. + """ + candidates = [ + Path(__file__).parent.parent.resolve(), + get_hermes_home() / "hermes-agent", + ] + for candidate in candidates: + if (candidate / ".git").exists(): + return candidate + return None + + def check_for_updates() -> Optional[int]: """Check how many commits behind origin/main the local repo is. @@ -132,13 +149,9 @@ def check_for_updates() -> Optional[int]: or ``None`` if the check fails or isn't applicable. """ hermes_home = get_hermes_home() - repo_dir = hermes_home / "hermes-agent" + repo_dir = _resolve_repo_dir() cache_file = hermes_home / ".update_check" - - # Must be a git repo — fall back to project root for dev installs - if not (repo_dir / ".git").exists(): - repo_dir = Path(__file__).parent.parent.resolve() - if not (repo_dir / ".git").exists(): + if repo_dir is None: return None # Read cache @@ -182,17 +195,6 @@ def check_for_updates() -> Optional[int]: pass return behind - - -def _resolve_repo_dir() -> Optional[Path]: - """Return the active Hermes git checkout, or None if this isn't a git install.""" - hermes_home = get_hermes_home() - repo_dir = hermes_home / "hermes-agent" - if not (repo_dir / ".git").exists(): - repo_dir = Path(__file__).parent.parent.resolve() - return repo_dir if (repo_dir / ".git").exists() else None - - def _git_short_hash(repo_dir: Path, rev: str) -> Optional[str]: """Resolve a git revision to an 8-character short hash.""" try: diff --git a/run_agent.py b/run_agent.py index fc7f72b731b78..62ea058b9ed57 100644 --- a/run_agent.py +++ b/run_agent.py @@ -5578,6 +5578,7 @@ def _qwen_prepare_chat_messages_inplace(self, messages: list) -> None: def _build_api_kwargs(self, api_messages: list) -> dict: """Build the keyword arguments dict for the active API mode.""" + request_overrides = getattr(self, "request_overrides", {}) or {} if self.api_mode == "anthropic_messages": from agent.anthropic_adapter import build_anthropic_kwargs anthropic_messages = self._prepare_anthropic_messages_for_api(api_messages) @@ -5602,7 +5603,7 @@ def _build_api_kwargs(self, api_messages: list) -> dict: preserve_dots=self._anthropic_preserve_dots(), context_length=ctx_len, base_url=getattr(self, "_anthropic_base_url", None), - fast_mode=(self.request_overrides or {}).get("speed") == "fast", + fast_mode=request_overrides.get("speed") == "fast", ) if self.api_mode == "codex_responses": @@ -5659,8 +5660,8 @@ def _build_api_kwargs(self, api_messages: list) -> dict: elif not is_github_responses: kwargs["include"] = [] - if self.request_overrides: - kwargs.update(self.request_overrides) + if request_overrides: + kwargs.update(request_overrides) if self.max_tokens is not None and not is_codex_backend: kwargs["max_output_tokens"] = self.max_tokens @@ -5835,8 +5836,8 @@ def _build_api_kwargs(self, api_messages: list) -> dict: # Priority Processing / generic request overrides (e.g. service_tier). # Applied last so overrides win over any defaults set above. - if self.request_overrides: - api_kwargs.update(self.request_overrides) + if request_overrides: + api_kwargs.update(request_overrides) return api_kwargs diff --git a/tests/agent/test_auxiliary_client.py b/tests/agent/test_auxiliary_client.py index 17f4dc3c87767..c60a26f4a95e2 100644 --- a/tests/agent/test_auxiliary_client.py +++ b/tests/agent/test_auxiliary_client.py @@ -614,6 +614,8 @@ def test_custom_endpoint_uses_config_saved_base_url(self, monkeypatch): def test_codex_fallback_when_nothing_else(self, codex_auth_dir): with patch("agent.auxiliary_client._read_nous_auth", return_value=None), \ + patch("agent.auxiliary_client._read_main_provider", return_value=None), \ + patch("agent.auxiliary_client._try_custom_endpoint", return_value=(None, None)), \ patch("agent.auxiliary_client.OpenAI") as mock_openai: client, model = get_text_auxiliary_client() assert model == "gpt-5.2-codex" diff --git a/tests/gateway/test_run_progress_topics.py b/tests/gateway/test_run_progress_topics.py index c28317d7e4b1a..f3ff90512fb77 100644 --- a/tests/gateway/test_run_progress_topics.py +++ b/tests/gateway/test_run_progress_topics.py @@ -144,7 +144,7 @@ async def test_run_agent_progress_stays_in_originating_topic(monkeypatch, tmp_pa assert adapter.sent == [ { "chat_id": "-1001", - "content": '⚙️ terminal: "pwd"', + "content": '💻 terminal: "pwd"', "reply_to": None, "metadata": {"thread_id": "17585"}, } diff --git a/tests/gateway/test_telegram_approval_buttons.py b/tests/gateway/test_telegram_approval_buttons.py index 98d3cdc312fc2..90d4bba663903 100644 --- a/tests/gateway/test_telegram_approval_buttons.py +++ b/tests/gateway/test_telegram_approval_buttons.py @@ -158,6 +158,10 @@ async def test_truncates_long_command(self): class TestTelegramApprovalCallback: """Test the approval callback handling in _handle_callback_query.""" + @pytest.fixture(autouse=True) + def _allow_callback_user(self, monkeypatch): + monkeypatch.setenv("TELEGRAM_ALLOWED_USERS", "*") + @pytest.mark.asyncio async def test_resolves_approval_on_click(self): adapter = _make_adapter() diff --git a/tests/gateway/test_telegram_conflict.py b/tests/gateway/test_telegram_conflict.py index 47a67f229b618..12167af5ed7fe 100644 --- a/tests/gateway/test_telegram_conflict.py +++ b/tests/gateway/test_telegram_conflict.py @@ -45,6 +45,17 @@ async def _noop(): monkeypatch.setattr("gateway.platforms.telegram.discover_fallback_ips", _noop) +def _application_builder(app): + builder = MagicMock() + builder.token.return_value = builder + builder.base_url.return_value = builder + builder.base_file_url.return_value = builder + builder.request.return_value = builder + builder.get_updates_request.return_value = builder + builder.build.return_value = app + return builder + + @pytest.mark.asyncio async def test_connect_rejects_same_host_token_lock(monkeypatch): adapter = TelegramAdapter(PlatformConfig(enabled=True, token="secret-token")) @@ -96,9 +107,7 @@ async def fake_start_polling(**kwargs): initialize=AsyncMock(), start=AsyncMock(), ) - builder = MagicMock() - builder.token.return_value = builder - builder.build.return_value = app + builder = _application_builder(app) monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) # Speed up retries for testing @@ -170,9 +179,7 @@ async def failing_start_polling(**kwargs): initialize=AsyncMock(), start=AsyncMock(), ) - builder = MagicMock() - builder.token.return_value = builder - builder.build.return_value = app + builder = _application_builder(app) monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) # Speed up retries for testing @@ -214,8 +221,6 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m lambda scope, identity: None, ) - builder = MagicMock() - builder.token.return_value = builder app = SimpleNamespace( bot=SimpleNamespace(delete_webhook=AsyncMock(), set_my_commands=AsyncMock()), updater=SimpleNamespace(), @@ -223,7 +228,7 @@ async def test_connect_marks_retryable_fatal_error_for_startup_network_failure(m initialize=AsyncMock(side_effect=RuntimeError("Temporary failure in name resolution")), start=AsyncMock(), ) - builder.build.return_value = app + builder = _application_builder(app) monkeypatch.setattr("gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder))) ok = await adapter.connect() @@ -263,9 +268,7 @@ async def test_connect_clears_webhook_before_polling(monkeypatch): initialize=AsyncMock(), start=AsyncMock(), ) - builder = MagicMock() - builder.token.return_value = builder - builder.build.return_value = app + builder = _application_builder(app) monkeypatch.setattr( "gateway.platforms.telegram.Application", SimpleNamespace(builder=MagicMock(return_value=builder)), diff --git a/tests/hermes_cli/test_api_key_providers.py b/tests/hermes_cli/test_api_key_providers.py index 5bb7d07065c6c..a7ce4461cf798 100644 --- a/tests/hermes_cli/test_api_key_providers.py +++ b/tests/hermes_cli/test_api_key_providers.py @@ -118,13 +118,13 @@ def test_oauth_providers_unchanged(self): PROVIDER_ENV_VARS = ( "OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", - "CLAUDE_CODE_OAUTH_TOKEN", - "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", + "CLAUDE_CODE_OAUTH_TOKEN", "COPILOT_GITHUB_TOKEN", + "GOOGLE_API_KEY", "GEMINI_API_KEY", "GLM_API_KEY", "ZAI_API_KEY", "Z_AI_API_KEY", "KIMI_API_KEY", "KIMI_BASE_URL", "MINIMAX_API_KEY", "MINIMAX_CN_API_KEY", "AI_GATEWAY_API_KEY", "AI_GATEWAY_BASE_URL", - "KILOCODE_API_KEY", "KILOCODE_BASE_URL", + "DEEPSEEK_API_KEY", "KILOCODE_API_KEY", "KILOCODE_BASE_URL", "DASHSCOPE_API_KEY", "OPENCODE_ZEN_API_KEY", "OPENCODE_GO_API_KEY", - "NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", + "HF_TOKEN", "NOUS_API_KEY", "GITHUB_TOKEN", "GH_TOKEN", "OPENAI_BASE_URL", "HERMES_COPILOT_ACP_COMMAND", "COPILOT_CLI_PATH", "HERMES_COPILOT_ACP_ARGS", "COPILOT_ACP_BASE_URL", ) diff --git a/tests/hermes_cli/test_auth_provider_gate.py b/tests/hermes_cli/test_auth_provider_gate.py index 2eacb71be7b82..d048fda06c67c 100644 --- a/tests/hermes_cli/test_auth_provider_gate.py +++ b/tests/hermes_cli/test_auth_provider_gate.py @@ -18,6 +18,12 @@ def _write_auth_store(tmp_path, payload: dict) -> None: (hermes_home / "auth.json").write_text(json.dumps(payload, indent=2)) +@pytest.fixture(autouse=True) +def _clear_anthropic_env(monkeypatch): + for key in ("ANTHROPIC_API_KEY", "ANTHROPIC_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN"): + monkeypatch.delenv(key, raising=False) + + def test_returns_false_when_no_config(tmp_path, monkeypatch): monkeypatch.setenv("HERMES_HOME", str(tmp_path / "hermes")) (tmp_path / "hermes").mkdir(parents=True, exist_ok=True) diff --git a/tests/hermes_cli/test_models.py b/tests/hermes_cli/test_models.py index d40a471444d30..42d36f76335bb 100644 --- a/tests/hermes_cli/test_models.py +++ b/tests/hermes_cli/test_models.py @@ -167,6 +167,9 @@ def test_bare_name_gets_openrouter_slug(self, monkeypatch): "ANTHROPIC_TOKEN", "CLAUDE_CODE_TOKEN", "CLAUDE_CODE_OAUTH_TOKEN", + "COPILOT_GITHUB_TOKEN", + "GH_TOKEN", + "GITHUB_TOKEN", ): monkeypatch.delenv(env_var, raising=False) """Bare model names should get mapped to full OpenRouter slugs.""" diff --git a/tests/hermes_cli/test_opencode_go_in_model_list.py b/tests/hermes_cli/test_opencode_go_in_model_list.py index 493d41b992a48..be5fd25d35332 100644 --- a/tests/hermes_cli/test_opencode_go_in_model_list.py +++ b/tests/hermes_cli/test_opencode_go_in_model_list.py @@ -16,8 +16,8 @@ def test_opencode_go_appears_when_api_key_set(): assert opencode_go is not None, "opencode-go should appear when OPENCODE_GO_API_KEY is set" assert opencode_go["models"] == ["glm-5", "kimi-k2.5", "mimo-v2-pro", "mimo-v2-omni", "minimax-m2.7", "minimax-m2.5"] - # opencode-go is in PROVIDER_TO_MODELS_DEV, so it appears as "built-in" (Part 1) - assert opencode_go["source"] == "built-in" + # opencode-go may come from models.dev or the Hermes overlay fallback. + assert opencode_go["source"] in {"built-in", "hermes"} def test_opencode_go_not_appears_when_no_creds(): diff --git a/tests/hermes_cli/test_update_check.py b/tests/hermes_cli/test_update_check.py index 84d5475228bac..7f5f782099c12 100644 --- a/tests/hermes_cli/test_update_check.py +++ b/tests/hermes_cli/test_update_check.py @@ -92,6 +92,39 @@ def test_check_for_updates_fallback_to_project_root(tmp_path, monkeypatch): assert mock_run.call_count >= 1 +def test_check_for_updates_prefers_active_checkout_over_hermes_home_repo(tmp_path, monkeypatch): + """Runtime split: prefer the checkout this CLI runs from over ~/.hermes/hermes-agent.""" + import hermes_cli.banner as banner + + runtime_root = tmp_path / "runtime" + runtime_root.mkdir() + (runtime_root / ".git").mkdir() + + stale_home_repo = tmp_path / ".hermes" / "hermes-agent" + stale_home_repo.mkdir(parents=True) + (stale_home_repo / ".git").mkdir() + + fake_banner = runtime_root / "hermes_cli" / "banner.py" + fake_banner.parent.mkdir(parents=True, exist_ok=True) + fake_banner.touch() + + calls = [] + + def _fake_run(cmd, **kwargs): + calls.append((cmd, kwargs)) + return MagicMock(returncode=0, stdout="0\n") + + monkeypatch.setattr(banner, "__file__", str(fake_banner)) + monkeypatch.setattr(banner, "get_hermes_home", lambda: tmp_path / ".hermes") + + with patch("hermes_cli.banner.subprocess.run", side_effect=_fake_run): + result = banner.check_for_updates() + + assert result == 0 + assert calls, "expected git commands to run" + assert calls[0][1]["cwd"] == str(runtime_root) + + def test_prefetch_non_blocking(): """prefetch_update_check() should return immediately without blocking.""" import hermes_cli.banner as banner diff --git a/tests/hermes_cli/test_update_gateway_restart.py b/tests/hermes_cli/test_update_gateway_restart.py index ceb05f65c92bc..8c2d8eb5446ca 100644 --- a/tests/hermes_cli/test_update_gateway_restart.py +++ b/tests/hermes_cli/test_update_gateway_restart.py @@ -370,6 +370,9 @@ def test_update_with_systemd_still_restarts_via_systemd( ) monkeypatch.setattr(gateway_cli, "supports_systemd_services", lambda: True) monkeypatch.setattr(gateway_cli, "is_termux", lambda: False) + monkeypatch.setattr( + gateway_cli, "is_linux", lambda: True, + ) mock_run.side_effect = _make_run_side_effect( commit_count="3", diff --git a/tests/tools/test_browser_camofox_state.py b/tests/tools/test_browser_camofox_state.py index b1f128ccee39b..5e7deb656d683 100644 --- a/tests/tools/test_browser_camofox_state.py +++ b/tests/tools/test_browser_camofox_state.py @@ -62,5 +62,5 @@ def test_default_config_includes_managed_persistence_toggle(self): def test_config_version_unchanged(self): from hermes_cli.config import DEFAULT_CONFIG - # managed_persistence is auto-merged by _deep_merge, no version bump needed - assert DEFAULT_CONFIG["_config_version"] == 13 + # managed_persistence itself needed no bump; later schema bumps are fine. + assert DEFAULT_CONFIG["_config_version"] >= 13 diff --git a/tools/file_operations.py b/tools/file_operations.py index f2b37505f3629..201b0a9a06f18 100644 --- a/tools/file_operations.py +++ b/tools/file_operations.py @@ -48,6 +48,7 @@ os.path.join(_HOME, ".ssh", "id_rsa"), os.path.join(_HOME, ".ssh", "id_ed25519"), os.path.join(_HOME, ".ssh", "config"), + os.path.join(_HOME, ".hermes", ".env"), str(get_hermes_home() / ".env"), os.path.join(_HOME, ".bashrc"), os.path.join(_HOME, ".zshrc"), diff --git a/tools/voice_mode.py b/tools/voice_mode.py index 5b6a1e3b1379c..dbde5064bbfec 100644 --- a/tools/voice_mode.py +++ b/tools/voice_mode.py @@ -417,6 +417,10 @@ def __init__(self) -> None: # -- public properties --------------------------------------------------- + @property + def is_recording(self) -> bool: + return self._recording + @property def elapsed_seconds(self) -> float: if not self._recording: