From f685741481c129455b42dd67418c1f684597e71e Mon Sep 17 00:00:00 2001 From: alireza78a Date: Wed, 11 Mar 2026 18:13:52 +0330 Subject: [PATCH 1/7] fix(agent): use atomic write in _save_session_log to prevent data loss --- run_agent.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/run_agent.py b/run_agent.py index e91376b0377..4bdbf7c31da 100644 --- a/run_agent.py +++ b/run_agent.py @@ -28,6 +28,7 @@ import json import logging logger = logging.getLogger(__name__) import os +import tempfile import random import re import sys @@ -1398,8 +1399,17 @@ class AIAgent: "messages": cleaned, } - with open(self.session_log_file, "w", encoding="utf-8") as f: - json.dump(entry, f, indent=2, ensure_ascii=False, default=str) + tmp_dir = str(self.session_log_file.parent) if hasattr(self.session_log_file, 'parent') else os.path.dirname(str(self.session_log_file)) + fd, tmp_path = tempfile.mkstemp(dir=tmp_dir, suffix='.tmp', prefix='.session_') + try: + with os.fdopen(fd, 'w', encoding='utf-8') as f: + json.dump(entry, f, indent=2, ensure_ascii=False, default=str) + f.flush() + os.fsync(f.fileno()) + os.replace(tmp_path, str(self.session_log_file)) + except: + os.unlink(tmp_path) + raise except Exception as e: if self.verbose_logging: From cbbba87099d2d99a3e24250f7528274e203f77ef Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 02:56:13 -0700 Subject: [PATCH 2/7] fix: reuse shared atomic session log helper --- run_agent.py | 19 +++++++------------ tests/test_atomic_json_write.py | 11 +++++++++++ tests/test_run_agent.py | 18 ++++++++++++++++++ utils.py | 20 +++++++++++++++++--- 4 files changed, 53 insertions(+), 15 deletions(-) diff --git a/run_agent.py b/run_agent.py index 4bdbf7c31da..ba214b715f7 100644 --- a/run_agent.py +++ b/run_agent.py @@ -28,7 +28,6 @@ import json import logging logger = logging.getLogger(__name__) import os -import tempfile import random import re import sys @@ -101,6 +100,7 @@ from agent.trajectory import ( convert_scratchpad_to_think, has_incomplete_scratchpad, save_trajectory as _save_trajectory_to_file, ) +from utils import atomic_json_write HONCHO_TOOL_NAMES = { "honcho_context", @@ -1399,17 +1399,12 @@ class AIAgent: "messages": cleaned, } - tmp_dir = str(self.session_log_file.parent) if hasattr(self.session_log_file, 'parent') else os.path.dirname(str(self.session_log_file)) - fd, tmp_path = tempfile.mkstemp(dir=tmp_dir, suffix='.tmp', prefix='.session_') - try: - with os.fdopen(fd, 'w', encoding='utf-8') as f: - json.dump(entry, f, indent=2, ensure_ascii=False, default=str) - f.flush() - os.fsync(f.fileno()) - os.replace(tmp_path, str(self.session_log_file)) - except: - os.unlink(tmp_path) - raise + atomic_json_write( + self.session_log_file, + entry, + indent=2, + default=str, + ) except Exception as e: if self.verbose_logging: diff --git a/tests/test_atomic_json_write.py b/tests/test_atomic_json_write.py index 681b7d8a89d..cb8b2d6d09b 100644 --- a/tests/test_atomic_json_write.py +++ b/tests/test_atomic_json_write.py @@ -97,6 +97,17 @@ class TestAtomicJsonWrite: text = target.read_text() assert ' "a"' in text # 4-space indent + def test_accepts_json_dump_default_hook(self, tmp_path): + class CustomValue: + def __str__(self): + return "custom-value" + + target = tmp_path / "custom_default.json" + atomic_json_write(target, {"value": CustomValue()}, default=str) + + result = json.loads(target.read_text(encoding="utf-8")) + assert result == {"value": "custom-value"} + def test_unicode_content(self, tmp_path): target = tmp_path / "unicode.json" data = {"emoji": "๐ŸŽ‰", "japanese": "ๆ—ฅๆœฌ่ชž"} diff --git a/tests/test_run_agent.py b/tests/test_run_agent.py index 1fcb191e734..15a0d5fba3a 100644 --- a/tests/test_run_agent.py +++ b/tests/test_run_agent.py @@ -1928,6 +1928,24 @@ class TestSafeWriter: assert inner.getvalue() == "test" +class TestSaveSessionLogAtomicWrite: + def test_uses_shared_atomic_json_helper(self, agent, tmp_path): + agent.session_log_file = tmp_path / "session.json" + messages = [{"role": "user", "content": "hello"}] + + with patch("run_agent.atomic_json_write", create=True) as mock_atomic_write: + agent._save_session_log(messages) + + mock_atomic_write.assert_called_once() + call_args = mock_atomic_write.call_args + assert call_args.args[0] == agent.session_log_file + payload = call_args.args[1] + assert payload["session_id"] == agent.session_id + assert payload["messages"] == messages + assert call_args.kwargs["indent"] == 2 + assert call_args.kwargs["default"] is str + + # =================================================================== # Anthropic adapter integration fixes # =================================================================== diff --git a/utils.py b/utils.py index 1b99d60fe03..762bcb84f5c 100644 --- a/utils.py +++ b/utils.py @@ -9,17 +9,25 @@ from typing import Any, Union import yaml -def atomic_json_write(path: Union[str, Path], data: Any, *, indent: int = 2) -> None: +def atomic_json_write( + path: Union[str, Path], + data: Any, + *, + indent: int = 2, + **dump_kwargs: Any, +) -> None: """Write JSON data to a file atomically. Uses temp file + fsync + os.replace to ensure the target file is never - left in a partially-written state. If the process crashes mid-write, + left in a partially-written state. If the process crashes mid-write, the previous version of the file remains intact. Args: path: Target file path (will be created or overwritten). data: JSON-serializable data to write. indent: JSON indentation (default 2). + **dump_kwargs: Additional keyword args forwarded to json.dump(), such + as default=str for non-native types. """ path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) @@ -31,7 +39,13 @@ def atomic_json_write(path: Union[str, Path], data: Any, *, indent: int = 2) -> ) try: with os.fdopen(fd, "w", encoding="utf-8") as f: - json.dump(data, f, indent=indent, ensure_ascii=False) + json.dump( + data, + f, + indent=indent, + ensure_ascii=False, + **dump_kwargs, + ) f.flush() os.fsync(f.fileno()) os.replace(tmp_path, path) From 0bb7ed1d9594bd2939afea6e64928ad790b69fa1 Mon Sep 17 00:00:00 2001 From: kshitij <82637225+kshitijk4poor@users.noreply.github.com> Date: Sat, 14 Mar 2026 02:56:06 -0700 Subject: [PATCH 3/7] refactor: salvage adapter and CLI cleanup from PR #939 Salvaged from PR #939 by kshitij. - deduplicate Discord slash command dispatch and local file send helpers - deduplicate Slack file uploads while preserving thread metadata - extract shared CLI session relative-time formatting - hoist browser PATH cleanup constants and throttle screenshot pruning - tidy small type and import cleanups --- agent/display.py | 2 +- gateway/platforms/discord.py | 244 +++++++------------------- gateway/platforms/slack.py | 58 +++--- hermes_cli/main.py | 85 +++------ hermes_state.py | 3 - tests/gateway/test_send_image_file.py | 34 +++- tools/browser_tool.py | 20 ++- 7 files changed, 164 insertions(+), 282 deletions(-) diff --git a/agent/display.py b/agent/display.py index 88926cd94a4..72b56318d1a 100644 --- a/agent/display.py +++ b/agent/display.py @@ -68,7 +68,7 @@ def _oneline(text: str) -> str: return " ".join(text.split()) -def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str: +def build_tool_preview(tool_name: str, args: dict, max_len: int = 40) -> str | None: """Build a short preview of a tool call's primary argument for display.""" if not args: return None diff --git a/gateway/platforms/discord.py b/gateway/platforms/discord.py index e05a421e1fc..47760d23674 100644 --- a/gateway/platforms/discord.py +++ b/gateway/platforms/discord.py @@ -265,6 +265,28 @@ class DiscordAdapter(BasePlatformAdapter): logger.error("[%s] Failed to edit Discord message %s: %s", self.name, message_id, e, exc_info=True) return SendResult(success=False, error=str(e)) + async def _send_file_attachment( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + ) -> SendResult: + """Send a local file as a Discord attachment.""" + if not self._client: + return SendResult(success=False, error="Not connected") + + channel = self._client.get_channel(int(chat_id)) + if not channel: + channel = await self._client.fetch_channel(int(chat_id)) + if not channel: + return SendResult(success=False, error=f"Channel {chat_id} not found") + + filename = os.path.basename(file_path) + with open(file_path, "rb") as fh: + file = discord.File(fh, filename=filename) + msg = await channel.send(content=caption if caption else None, file=file) + return SendResult(success=True, message_id=str(msg.id)) + async def send_voice( self, chat_id: str, @@ -274,36 +296,14 @@ class DiscordAdapter(BasePlatformAdapter): metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send audio as a Discord file attachment.""" - if not self._client: - return SendResult(success=False, error="Not connected") - try: - import io - - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) - if not channel: - return SendResult(success=False, error=f"Channel {chat_id} not found") - - if not os.path.exists(audio_path): - return SendResult(success=False, error=f"Audio file not found: {audio_path}") - - # Determine filename from path - filename = os.path.basename(audio_path) - - with open(audio_path, "rb") as f: - file = discord.File(io.BytesIO(f.read()), filename=filename) - msg = await channel.send( - content=caption if caption else None, - file=file, - ) - return SendResult(success=True, message_id=str(msg.id)) - + return await self._send_file_attachment(chat_id, audio_path, caption) + except FileNotFoundError: + return SendResult(success=False, error=f"Audio file not found: {audio_path}") except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to send audio, falling back to base adapter: %s", self.name, e, exc_info=True) - return await super().send_voice(chat_id, audio_path, caption, reply_to) - + return await super().send_voice(chat_id, audio_path, caption, reply_to, metadata=metadata) + async def send_image_file( self, chat_id: str, @@ -313,34 +313,13 @@ class DiscordAdapter(BasePlatformAdapter): metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local image file natively as a Discord file attachment.""" - if not self._client: - return SendResult(success=False, error="Not connected") - try: - import io - - channel = self._client.get_channel(int(chat_id)) - if not channel: - channel = await self._client.fetch_channel(int(chat_id)) - if not channel: - return SendResult(success=False, error=f"Channel {chat_id} not found") - - if not os.path.exists(image_path): - return SendResult(success=False, error=f"Image file not found: {image_path}") - - filename = os.path.basename(image_path) - - with open(image_path, "rb") as f: - file = discord.File(io.BytesIO(f.read()), filename=filename) - msg = await channel.send( - content=caption if caption else None, - file=file, - ) - return SendResult(success=True, message_id=str(msg.id)) - + return await self._send_file_attachment(chat_id, image_path, caption) + except FileNotFoundError: + return SendResult(success=False, error=f"Image file not found: {image_path}") except Exception as e: # pragma: no cover - defensive logging logger.error("[%s] Failed to send local image, falling back to base adapter: %s", self.name, e, exc_info=True) - return await super().send_image_file(chat_id, image_path, caption, reply_to) + return await super().send_image_file(chat_id, image_path, caption, reply_to, metadata=metadata) async def send_image( self, @@ -528,7 +507,22 @@ class DiscordAdapter(BasePlatformAdapter): """ # Discord markdown is fairly standard, no special escaping needed return content - + + async def _run_simple_slash( + self, + interaction: discord.Interaction, + command_text: str, + followup_msg: str = "Done~", + ) -> None: + """Common handler for simple slash commands that dispatch a command string.""" + await interaction.response.defer(ephemeral=True) + event = self._build_slash_event(interaction, command_text) + await self.handle_message(event) + try: + await interaction.followup.send(followup_msg, ephemeral=True) + except Exception as e: + logger.debug("Discord followup failed: %s", e) + def _register_slash_commands(self) -> None: """Register Discord slash commands on the command tree.""" if not self._client: @@ -551,34 +545,16 @@ class DiscordAdapter(BasePlatformAdapter): @tree.command(name="new", description="Start a new conversation") async def slash_new(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/reset") - await self.handle_message(event) - try: - await interaction.followup.send("New conversation started~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/reset", "New conversation started~") @tree.command(name="reset", description="Reset your Hermes session") async def slash_reset(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/reset") - await self.handle_message(event) - try: - await interaction.followup.send("Session reset~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/reset", "Session reset~") @tree.command(name="model", description="Show or change the model") @discord.app_commands.describe(name="Model name (e.g. anthropic/claude-sonnet-4). Leave empty to see current.") async def slash_model(interaction: discord.Interaction, name: str = ""): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, f"/model {name}".strip()) - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, f"/model {name}".strip()) @tree.command(name="reasoning", description="Show or change reasoning effort") @discord.app_commands.describe(effort="Reasoning effort: xhigh, high, medium, low, minimal, or none.") @@ -594,156 +570,66 @@ class DiscordAdapter(BasePlatformAdapter): @tree.command(name="personality", description="Set a personality") @discord.app_commands.describe(name="Personality name. Leave empty to list available.") async def slash_personality(interaction: discord.Interaction, name: str = ""): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, f"/personality {name}".strip()) - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, f"/personality {name}".strip()) @tree.command(name="retry", description="Retry your last message") async def slash_retry(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/retry") - await self.handle_message(event) - try: - await interaction.followup.send("Retrying~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/retry", "Retrying~") @tree.command(name="undo", description="Remove the last exchange") async def slash_undo(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/undo") - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/undo") @tree.command(name="status", description="Show Hermes session status") async def slash_status(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/status") - await self.handle_message(event) - try: - await interaction.followup.send("Status sent~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/status", "Status sent~") @tree.command(name="sethome", description="Set this chat as the home channel") async def slash_sethome(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/sethome") - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/sethome") @tree.command(name="stop", description="Stop the running Hermes agent") async def slash_stop(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/stop") - await self.handle_message(event) - try: - await interaction.followup.send("Stop requested~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/stop", "Stop requested~") @tree.command(name="compress", description="Compress conversation context") async def slash_compress(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/compress") - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/compress") @tree.command(name="title", description="Set or show the session title") @discord.app_commands.describe(name="Session title. Leave empty to show current.") async def slash_title(interaction: discord.Interaction, name: str = ""): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, f"/title {name}".strip()) - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, f"/title {name}".strip()) @tree.command(name="resume", description="Resume a previously-named session") @discord.app_commands.describe(name="Session name to resume. Leave empty to list sessions.") async def slash_resume(interaction: discord.Interaction, name: str = ""): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, f"/resume {name}".strip()) - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, f"/resume {name}".strip()) @tree.command(name="usage", description="Show token usage for this session") async def slash_usage(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/usage") - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/usage") @tree.command(name="provider", description="Show available providers") async def slash_provider(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/provider") - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/provider") @tree.command(name="help", description="Show available commands") async def slash_help(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/help") - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/help") @tree.command(name="insights", description="Show usage insights and analytics") @discord.app_commands.describe(days="Number of days to analyze (default: 7)") async def slash_insights(interaction: discord.Interaction, days: int = 7): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, f"/insights {days}") - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, f"/insights {days}") @tree.command(name="reload-mcp", description="Reload MCP servers from config") async def slash_reload_mcp(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/reload-mcp") - await self.handle_message(event) - try: - await interaction.followup.send("Done~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/reload-mcp") @tree.command(name="update", description="Update Hermes Agent to the latest version") async def slash_update(interaction: discord.Interaction): - await interaction.response.defer(ephemeral=True) - event = self._build_slash_event(interaction, "/update") - await self.handle_message(event) - try: - await interaction.followup.send("Update initiated~", ephemeral=True) - except Exception as e: - logger.debug("Discord followup failed: %s", e) + await self._run_simple_slash(interaction, "/update", "Update initiated~") @tree.command(name="thread", description="Create a new thread and start a Hermes session in it") @discord.app_commands.describe( diff --git a/gateway/platforms/slack.py b/gateway/platforms/slack.py index aa2da2bf8e2..d75685bfbce 100644 --- a/gateway/platforms/slack.py +++ b/gateway/platforms/slack.py @@ -260,6 +260,30 @@ class SlackAdapter(BasePlatformAdapter): return metadata["thread_ts"] return reply_to + async def _upload_file( + self, + chat_id: str, + file_path: str, + caption: Optional[str] = None, + reply_to: Optional[str] = None, + metadata: Optional[Dict[str, Any]] = None, + ) -> SendResult: + """Upload a local file to Slack.""" + if not self._app: + return SendResult(success=False, error="Not connected") + + if not os.path.exists(file_path): + raise FileNotFoundError(f"File not found: {file_path}") + + result = await self._app.client.files_upload_v2( + channel=chat_id, + file=file_path, + filename=os.path.basename(file_path), + initial_comment=caption or "", + thread_ts=self._resolve_thread_ts(reply_to, metadata), + ) + return SendResult(success=True, raw_response=result) + # ----- Markdown โ†’ mrkdwn conversion ----- def format_message(self, content: str) -> str: @@ -417,23 +441,10 @@ class SlackAdapter(BasePlatformAdapter): metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send a local image file to Slack by uploading it.""" - if not self._app: - return SendResult(success=False, error="Not connected") - try: - import os - if not os.path.exists(image_path): - return SendResult(success=False, error=f"Image file not found: {image_path}") - - result = await self._app.client.files_upload_v2( - channel=chat_id, - file=image_path, - filename=os.path.basename(image_path), - initial_comment=caption or "", - thread_ts=self._resolve_thread_ts(reply_to, metadata), - ) - return SendResult(success=True, raw_response=result) - + return await self._upload_file(chat_id, image_path, caption, reply_to, metadata) + except FileNotFoundError: + return SendResult(success=False, error=f"Image file not found: {image_path}") except Exception as e: # pragma: no cover - defensive logging logger.error( "[%s] Failed to send local Slack image %s: %s", @@ -497,19 +508,10 @@ class SlackAdapter(BasePlatformAdapter): metadata: Optional[Dict[str, Any]] = None, ) -> SendResult: """Send an audio file to Slack.""" - if not self._app: - return SendResult(success=False, error="Not connected") - try: - result = await self._app.client.files_upload_v2( - channel=chat_id, - file=audio_path, - filename=os.path.basename(audio_path), - initial_comment=caption or "", - thread_ts=self._resolve_thread_ts(reply_to, metadata), - ) - return SendResult(success=True, raw_response=result) - + return await self._upload_file(chat_id, audio_path, caption, reply_to, metadata) + except FileNotFoundError: + return SendResult(success=False, error=f"Audio file not found: {audio_path}") except Exception as e: # pragma: no cover - defensive logging logger.error( "[Slack] Failed to send audio file %s: %s", diff --git a/hermes_cli/main.py b/hermes_cli/main.py index 4a31db808ee..98c204e68dd 100644 --- a/hermes_cli/main.py +++ b/hermes_cli/main.py @@ -69,6 +69,8 @@ os.environ.setdefault("MSWEA_GLOBAL_CONFIG_DIR", str(get_hermes_home())) os.environ.setdefault("MSWEA_SILENT_STARTUP", "1") import logging +import time as _time +from datetime import datetime from hermes_cli import __version__, __release_date__ from hermes_constants import OPENROUTER_BASE_URL @@ -76,6 +78,24 @@ from hermes_constants import OPENROUTER_BASE_URL logger = logging.getLogger(__name__) +def _relative_time(ts) -> str: + """Format a timestamp as relative time (e.g., '2h ago', 'yesterday').""" + if not ts: + return "?" + delta = _time.time() - ts + if delta < 60: + return "just now" + if delta < 3600: + return f"{int(delta / 60)}m ago" + if delta < 86400: + return f"{int(delta / 3600)}h ago" + if delta < 172800: + return "yesterday" + if delta < 604800: + return f"{int(delta / 86400)}d ago" + return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") + + def _has_any_provider_configured() -> bool: """Check if at least one inference provider is usable.""" from hermes_cli.config import get_env_path, get_hermes_home @@ -140,28 +160,9 @@ def _session_browse_picker(sessions: list) -> Optional[str]: # Try curses-based picker first try: import curses - import time as _time - from datetime import datetime result_holder = [None] - def _relative_time(ts): - if not ts: - return "?" - delta = _time.time() - ts - if delta < 60: - return "just now" - elif delta < 3600: - return f"{int(delta / 60)}m ago" - elif delta < 86400: - return f"{int(delta / 3600)}h ago" - elif delta < 172800: - return "yesterday" - elif delta < 604800: - return f"{int(delta / 86400)}d ago" - else: - return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") - def _format_row(s, max_x): """Format a session row for display.""" title = (s.get("title") or "").strip() @@ -352,26 +353,6 @@ def _session_browse_picker(sessions: list) -> Optional[str]: pass # Fallback: numbered list (Windows without curses, etc.) - import time as _time - from datetime import datetime - - def _relative_time_fb(ts): - if not ts: - return "?" - delta = _time.time() - ts - if delta < 60: - return "just now" - elif delta < 3600: - return f"{int(delta / 60)}m ago" - elif delta < 86400: - return f"{int(delta / 3600)}h ago" - elif delta < 172800: - return "yesterday" - elif delta < 604800: - return f"{int(delta / 86400)}d ago" - else: - return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") - print("\n Browse sessions (enter number to resume, q to cancel)\n") for i, s in enumerate(sessions): title = (s.get("title") or "").strip() @@ -379,7 +360,7 @@ def _session_browse_picker(sessions: list) -> Optional[str]: label = title or preview or s["id"] if len(label) > 50: label = label[:47] + "..." - last_active = _relative_time_fb(s.get("last_active")) + last_active = _relative_time(s.get("last_active")) src = s.get("source", "")[:6] print(f" {i + 1:>3}. {label:<50} {last_active:<10} {src}") @@ -2846,30 +2827,6 @@ For more help on a command: if not sessions: print("No sessions found.") return - from datetime import datetime - import time as _time - - def _relative_time(ts): - """Format a timestamp as relative time (e.g., '2h ago', 'yesterday').""" - if not ts: - return "?" - delta = _time.time() - ts - if delta < 60: - return "just now" - elif delta < 3600: - mins = int(delta / 60) - return f"{mins}m ago" - elif delta < 86400: - hours = int(delta / 3600) - return f"{hours}h ago" - elif delta < 172800: - return "yesterday" - elif delta < 604800: - days = int(delta / 86400) - return f"{days}d ago" - else: - return datetime.fromtimestamp(ts).strftime("%Y-%m-%d") - has_titles = any(s.get("title") for s in sessions) if has_titles: print(f"{'Title':<22} {'Preview':<40} {'Last Active':<13} {'ID'}") diff --git a/hermes_state.py b/hermes_state.py index 84c3bf44abb..5e29321eca6 100644 --- a/hermes_state.py +++ b/hermes_state.py @@ -267,8 +267,6 @@ class SessionDB: if not title: return None - import re - # Remove ASCII control characters (0x00-0x1F, 0x7F) but keep # whitespace chars (\t=0x09, \n=0x0A, \r=0x0D) so they can be # normalized to spaces by the whitespace collapsing step below @@ -373,7 +371,6 @@ class SessionDB: Strips any existing " #N" suffix to find the base name, then finds the highest existing number and increments. """ - import re # Strip existing #N suffix to find the true base match = re.match(r'^(.*?) #(\d+)$', base_title) if match: diff --git a/tests/gateway/test_send_image_file.py b/tests/gateway/test_send_image_file.py index 9fa375ac241..bf24373455d 100644 --- a/tests/gateway/test_send_image_file.py +++ b/tests/gateway/test_send_image_file.py @@ -297,7 +297,9 @@ class TestScreenshotCleanup: def test_cleanup_removes_old_screenshots(self, tmp_path): """_cleanup_old_screenshots should remove files older than max_age_hours.""" import time - from tools.browser_tool import _cleanup_old_screenshots + from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir + + _last_screenshot_cleanup_by_dir.clear() # Create a "fresh" file fresh = tmp_path / "browser_screenshot_fresh.png" @@ -314,10 +316,32 @@ class TestScreenshotCleanup: assert fresh.exists(), "Fresh screenshot should not be removed" assert not old.exists(), "Old screenshot should be removed" + def test_cleanup_is_throttled_per_directory(self, tmp_path): + import time + from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir + + _last_screenshot_cleanup_by_dir.clear() + + old = tmp_path / "browser_screenshot_old.png" + old.write_bytes(b"old") + old_time = time.time() - (25 * 3600) + os.utime(str(old), (old_time, old_time)) + + _cleanup_old_screenshots(tmp_path, max_age_hours=24) + assert not old.exists() + + old.write_bytes(b"old-again") + os.utime(str(old), (old_time, old_time)) + _cleanup_old_screenshots(tmp_path, max_age_hours=24) + + assert old.exists(), "Repeated cleanup should be skipped while throttled" + def test_cleanup_ignores_non_screenshot_files(self, tmp_path): """Only files matching browser_screenshot_*.png should be cleaned.""" import time - from tools.browser_tool import _cleanup_old_screenshots + from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir + + _last_screenshot_cleanup_by_dir.clear() other_file = tmp_path / "important_data.txt" other_file.write_bytes(b"keep me") @@ -330,11 +354,13 @@ class TestScreenshotCleanup: def test_cleanup_handles_empty_dir(self, tmp_path): """Cleanup should not fail on empty directory.""" - from tools.browser_tool import _cleanup_old_screenshots + from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir + _last_screenshot_cleanup_by_dir.clear() _cleanup_old_screenshots(tmp_path, max_age_hours=24) # Should not raise def test_cleanup_handles_nonexistent_dir(self): """Cleanup should not fail if directory doesn't exist.""" from pathlib import Path - from tools.browser_tool import _cleanup_old_screenshots + from tools.browser_tool import _cleanup_old_screenshots, _last_screenshot_cleanup_by_dir + _last_screenshot_cleanup_by_dir.clear() _cleanup_old_screenshots(Path("/nonexistent/dir"), max_age_hours=24) # Should not raise diff --git a/tools/browser_tool.py b/tools/browser_tool.py index ae9515748eb..15f4961897c 100644 --- a/tools/browser_tool.py +++ b/tools/browser_tool.py @@ -67,6 +67,12 @@ from agent.auxiliary_client import call_llm logger = logging.getLogger(__name__) +# Standard PATH entries for environments with minimal PATH (e.g. systemd services) +_SANE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" + +# Throttle screenshot cleanup to avoid repeated full directory scans. +_last_screenshot_cleanup_by_dir: dict[str, float] = {} + # ============================================================================ # Configuration # ============================================================================ @@ -846,7 +852,6 @@ def _run_browser_command( browser_env = {**os.environ} # Ensure PATH includes standard dirs (systemd services may have minimal PATH) - _SANE_PATH = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" if "/usr/bin" not in browser_env.get("PATH", "").split(":"): browser_env["PATH"] = f"{browser_env.get('PATH', '')}:{_SANE_PATH}" browser_env["AGENT_BROWSER_SOCKET_DIR"] = task_socket_dir @@ -1578,8 +1583,17 @@ def browser_vision(question: str, annotate: bool = False, task_id: Optional[str] def _cleanup_old_screenshots(screenshots_dir, max_age_hours=24): - """Remove browser screenshots older than max_age_hours to prevent disk bloat.""" - import time + """Remove browser screenshots older than max_age_hours to prevent disk bloat. + + Throttled to run at most once per hour per directory to avoid repeated + scans on screenshot-heavy workflows. + """ + key = str(screenshots_dir) + now = time.time() + if now - _last_screenshot_cleanup_by_dir.get(key, 0.0) < 3600: + return + _last_screenshot_cleanup_by_dir[key] = now + try: cutoff = time.time() - (max_age_hours * 3600) for f in screenshots_dir.glob("browser_screenshot_*.png"): From b91cac7b4b948ddaefb9f58d4bd144b8431a9eef Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:12:15 -0700 Subject: [PATCH 4/7] test: make gateway async tests xdist-safe (#1281) * feat: improve context compaction handoff summaries Adapt PR #916 onto current main by replacing the old context summary marker with a clearer handoff wrapper, updating the summarization prompt for resume-oriented summaries, and preserving the current call_llm-based compression path. * fix: clearer error when docker backend is unavailable * fix: preserve docker discovery in backend preflight Follow up on salvaged PR #940 by reusing find_docker() during the new availability check so non-PATH Docker Desktop installs still work. Add a regression test covering the resolved executable path. * test: make gateway async tests xdist-safe Replace sync test usage of asyncio.get_event_loop().run_until_complete() with asyncio.run() so tests do not depend on an ambient current event loop. Also create the email disconnect poll task inside a running loop. This fixes xdist/CI failures where workers have no current loop in MainThread. --------- Co-authored-by: aydnOktay --- tests/gateway/test_email.py | 47 ++++++++++++++++++++----------------- 1 file changed, 25 insertions(+), 22 deletions(-) diff --git a/tests/gateway/test_email.py b/tests/gateway/test_email.py index 05cb11f552f..5344d369c30 100644 --- a/tests/gateway/test_email.py +++ b/tests/gateway/test_email.py @@ -407,7 +407,7 @@ class TestDispatchMessage(unittest.TestCase): "date": "", } - asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + asyncio.run(adapter._dispatch_message(msg_data)) adapter._message_handler.assert_not_called() def test_subject_included_in_text(self): @@ -441,7 +441,7 @@ class TestDispatchMessage(unittest.TestCase): "date": "", } - asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + asyncio.run(adapter._dispatch_message(msg_data)) self.assertEqual(len(captured_events), 1) self.assertIn("[Subject: Help with Python]", captured_events[0].text) self.assertIn("How do I use lists?", captured_events[0].text) @@ -469,7 +469,7 @@ class TestDispatchMessage(unittest.TestCase): "date": "", } - asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + asyncio.run(adapter._dispatch_message(msg_data)) self.assertEqual(len(captured_events), 1) self.assertNotIn("[Subject:", captured_events[0].text) self.assertEqual(captured_events[0].text, "Thanks for the help!") @@ -497,7 +497,7 @@ class TestDispatchMessage(unittest.TestCase): "date": "", } - asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + asyncio.run(adapter._dispatch_message(msg_data)) self.assertEqual(len(captured_events), 1) self.assertIn("(empty email)", captured_events[0].text) @@ -525,7 +525,7 @@ class TestDispatchMessage(unittest.TestCase): "date": "", } - asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + asyncio.run(adapter._dispatch_message(msg_data)) self.assertEqual(len(captured_events), 1) self.assertEqual(captured_events[0].message_type, MessageType.PHOTO) self.assertEqual(captured_events[0].media_urls, ["/tmp/img.jpg"]) @@ -553,7 +553,7 @@ class TestDispatchMessage(unittest.TestCase): "date": "", } - asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + asyncio.run(adapter._dispatch_message(msg_data)) event = captured_events[0] self.assertEqual(event.source.chat_id, "john@example.com") self.assertEqual(event.source.user_id, "john@example.com") @@ -598,7 +598,7 @@ class TestThreadContext(unittest.TestCase): "date": "", } - asyncio.get_event_loop().run_until_complete(adapter._dispatch_message(msg_data)) + asyncio.run(adapter._dispatch_message(msg_data)) ctx = adapter._thread_context.get("user@test.com") self.assertIsNotNone(ctx) self.assertEqual(ctx["subject"], "Project question") @@ -680,7 +680,7 @@ class TestSendMethods(unittest.TestCase): mock_server = MagicMock() mock_smtp.return_value = mock_server - result = asyncio.get_event_loop().run_until_complete( + result = asyncio.run( adapter.send("user@test.com", "Hello from Hermes!") ) @@ -698,7 +698,7 @@ class TestSendMethods(unittest.TestCase): with patch("smtplib.SMTP") as mock_smtp: mock_smtp.side_effect = Exception("Connection refused") - result = asyncio.get_event_loop().run_until_complete( + result = asyncio.run( adapter.send("user@test.com", "Hello") ) @@ -713,7 +713,7 @@ class TestSendMethods(unittest.TestCase): adapter.send = AsyncMock(return_value=SendResult(success=True)) - asyncio.get_event_loop().run_until_complete( + asyncio.run( adapter.send_image("user@test.com", "https://img.com/photo.jpg", "My photo") ) @@ -737,7 +737,7 @@ class TestSendMethods(unittest.TestCase): mock_server = MagicMock() mock_smtp.return_value = mock_server - result = asyncio.get_event_loop().run_until_complete( + result = asyncio.run( adapter.send_document("user@test.com", tmp_path, "Here is the file") ) @@ -759,7 +759,7 @@ class TestSendMethods(unittest.TestCase): import asyncio adapter = self._make_adapter() # Should not raise - asyncio.get_event_loop().run_until_complete(adapter.send_typing("user@test.com")) + asyncio.run(adapter.send_typing("user@test.com")) def test_get_chat_info(self): """get_chat_info should return email address as chat info.""" @@ -767,7 +767,7 @@ class TestSendMethods(unittest.TestCase): adapter = self._make_adapter() adapter._thread_context["user@test.com"] = {"subject": "Test", "message_id": ""} - info = asyncio.get_event_loop().run_until_complete( + info = asyncio.run( adapter.get_chat_info("user@test.com") ) @@ -804,7 +804,7 @@ class TestConnectDisconnect(unittest.TestCase): mock_server = MagicMock() mock_smtp.return_value = mock_server - result = asyncio.get_event_loop().run_until_complete(adapter.connect()) + result = asyncio.run(adapter.connect()) self.assertTrue(result) self.assertTrue(adapter._running) @@ -821,7 +821,7 @@ class TestConnectDisconnect(unittest.TestCase): adapter = self._make_adapter() with patch("imaplib.IMAP4_SSL", side_effect=Exception("IMAP down")): - result = asyncio.get_event_loop().run_until_complete(adapter.connect()) + result = asyncio.run(adapter.connect()) self.assertFalse(result) self.assertFalse(adapter._running) @@ -835,7 +835,7 @@ class TestConnectDisconnect(unittest.TestCase): with patch("imaplib.IMAP4_SSL", return_value=mock_imap), \ patch("smtplib.SMTP", side_effect=Exception("SMTP down")): - result = asyncio.get_event_loop().run_until_complete(adapter.connect()) + result = asyncio.run(adapter.connect()) self.assertFalse(result) def test_disconnect_cancels_poll(self): @@ -843,9 +843,12 @@ class TestConnectDisconnect(unittest.TestCase): import asyncio adapter = self._make_adapter() adapter._running = True - adapter._poll_task = asyncio.ensure_future(asyncio.sleep(100)) - asyncio.get_event_loop().run_until_complete(adapter.disconnect()) + async def _exercise_disconnect(): + adapter._poll_task = asyncio.create_task(asyncio.sleep(100)) + await adapter.disconnect() + + asyncio.run(_exercise_disconnect()) self.assertFalse(adapter._running) self.assertIsNone(adapter._poll_task) @@ -967,7 +970,7 @@ class TestPollLoop(unittest.TestCase): mock_imap.fetch.return_value = ("OK", [(b"1", raw_email.as_bytes())]) with patch("imaplib.IMAP4_SSL", return_value=mock_imap): - asyncio.get_event_loop().run_until_complete(adapter._check_inbox()) + asyncio.run(adapter._check_inbox()) self.assertEqual(len(dispatched), 1) self.assertEqual(dispatched[0]["subject"], "Inbox Test") @@ -991,7 +994,7 @@ class TestSendEmailStandalone(unittest.TestCase): mock_server = MagicMock() mock_smtp.return_value = mock_server - result = asyncio.get_event_loop().run_until_complete( + result = asyncio.run( _send_email({"address": "hermes@test.com", "smtp_host": "smtp.test.com"}, "user@test.com", "Hello") ) @@ -1009,7 +1012,7 @@ class TestSendEmailStandalone(unittest.TestCase): from tools.send_message_tool import _send_email with patch("smtplib.SMTP", side_effect=Exception("SMTP error")): - result = asyncio.get_event_loop().run_until_complete( + result = asyncio.run( _send_email({"address": "hermes@test.com", "smtp_host": "smtp.test.com"}, "user@test.com", "Hello") ) @@ -1022,7 +1025,7 @@ class TestSendEmailStandalone(unittest.TestCase): import asyncio from tools.send_message_tool import _send_email - result = asyncio.get_event_loop().run_until_complete( + result = asyncio.run( _send_email({}, "user@test.com", "Hello") ) From 41f22de20fa2afcf914665048642cffc718ac860 Mon Sep 17 00:00:00 2001 From: Wayne <122283258+quarker1337@users.noreply.github.com> Date: Sat, 14 Mar 2026 03:12:52 -0700 Subject: [PATCH 5/7] fix(cli): make TUI prompt and accent output skin-aware Salvaged from PR #932 by Wayne onto current main. Apply skin-aware prompt symbols and live prompt_toolkit color refresh, replace lingering hardcoded accent output with active-skin colors, keep ANSI-safe response rendering, preserve secret-capture and approval-prompt state handling, and add integration coverage for prompt state and style refresh behavior. --- cli.py | 225 +++++++++++++++++++++------ hermes_cli/skin_engine.py | 85 ++++++++++ tests/hermes_cli/test_skin_engine.py | 82 ++++++++++ tests/test_cli_skin_integration.py | 95 +++++++++++ 4 files changed, 436 insertions(+), 51 deletions(-) create mode 100644 tests/test_cli_skin_integration.py diff --git a/cli.py b/cli.py index 3f124f5af39..253cdd085e4 100755 --- a/cli.py +++ b/cli.py @@ -404,8 +404,10 @@ except Exception: from rich import box as rich_box from rich.console import Console +from rich.markup import escape as _escape from rich.panel import Panel from rich.table import Table +from rich.text import Text as _RichText import fire @@ -696,6 +698,24 @@ _BOLD = "\033[1m" _DIM = "\033[2m" _RST = "\033[0m" +def _accent_hex() -> str: + """Return the active skin accent color for legacy CLI output lines.""" + try: + from hermes_cli.skin_engine import get_active_skin + return get_active_skin().get_color("ui_accent", "#FFBF00") + except Exception: + return "#FFBF00" + + +def _rich_text_from_ansi(text: str) -> _RichText: + """Safely render assistant/tool output that may contain ANSI escapes. + + Using Rich Text.from_ansi preserves literal bracketed text like + ``[not markup]`` while still interpreting real ANSI color codes. + """ + return _RichText.from_ansi(text or "") + + def _cprint(text: str): """Print ANSI-colored text through prompt_toolkit's native renderer. @@ -718,7 +738,12 @@ class ChatConsole: def __init__(self): from io import StringIO self._buffer = StringIO() - self._inner = Console(file=self._buffer, force_terminal=True, highlight=False) + self._inner = Console( + file=self._buffer, + force_terminal=True, + color_system="truecolor", + highlight=False, + ) def print(self, *args, **kwargs): self._buffer.seek(0) @@ -1472,13 +1497,16 @@ class HermesCLI: title_part = "" if session_meta.get("title"): title_part = f" \"{session_meta['title']}\"" - _cprint( - f"{_GOLD}โ†ป Resumed session {_BOLD}{self.session_id}{_RST}{_GOLD}{title_part} " - f"({msg_count} user message{'s' if msg_count != 1 else ''}, " - f"{len(restored)} total messages){_RST}" + ChatConsole().print( + f"[bold {_accent_hex()}]โ†ป Resumed session[/] " + f"[bold]{_escape(self.session_id)}[/]" + f"[bold {_accent_hex()}]{_escape(title_part)}[/] " + f"({msg_count} user message{'s' if msg_count != 1 else ''}, {len(restored)} total messages)" ) else: - _cprint(f"{_GOLD}Session {self.session_id} found but has no messages. Starting fresh.{_RST}") + ChatConsole().print( + f"[bold {_accent_hex()}]Session {_escape(self.session_id)} found but has no messages. Starting fresh.[/]" + ) # Re-open the session (clear ended_at so it's active again) try: self._session_db._conn.execute( @@ -1738,6 +1766,19 @@ class HermesCLI: from rich.panel import Panel from rich.text import Text + try: + from hermes_cli.skin_engine import get_active_skin + _skin = get_active_skin() + _history_text_c = _skin.get_color("banner_text", "#FFF8DC") + _session_label_c = _skin.get_color("session_label", "#DAA520") + _session_border_c = _skin.get_color("session_border", "#8B8682") + _assistant_label_c = _skin.get_color("ui_ok", "#8FBC8F") + except Exception: + _history_text_c = "#FFF8DC" + _session_label_c = "#DAA520" + _session_border_c = "#8B8682" + _assistant_label_c = "#8FBC8F" + lines = Text() if skipped: lines.append( @@ -1747,14 +1788,14 @@ class HermesCLI: for i, (role, text) in enumerate(entries): if role == "user": - lines.append(" โ— You: ", style="dim bold #DAA520") + lines.append(" โ— You: ", style=f"dim bold {_session_label_c}") # Show first line inline, indent rest msg_lines = text.splitlines() lines.append(msg_lines[0] + "\n", style="dim") for ml in msg_lines[1:]: lines.append(f" {ml}\n", style="dim") else: - lines.append(" โ—† Hermes: ", style="dim bold #8FBC8F") + lines.append(" โ—† Hermes: ", style=f"dim bold {_assistant_label_c}") msg_lines = text.splitlines() lines.append(msg_lines[0] + "\n", style="dim") for ml in msg_lines[1:]: @@ -1764,9 +1805,10 @@ class HermesCLI: panel = Panel( lines, - title="[dim #DAA520]Previous Conversation[/]", - border_style="dim #8B8682", + title=f"[dim {_session_label_c}]Previous Conversation[/]", + border_style=f"dim {_session_border_c}", padding=(0, 1), + style=_history_text_c, ) self.console.print(panel) @@ -1976,19 +2018,30 @@ class HermesCLI: """Display help information with categorized commands.""" from hermes_cli.commands import COMMANDS_BY_CATEGORY - _cprint(f"\n{_BOLD}+{'-' * 55}+{_RST}") - _cprint(f"{_BOLD}|{' ' * 14}(^_^)? Available Commands{' ' * 15}|{_RST}") - _cprint(f"{_BOLD}+{'-' * 55}+{_RST}") + try: + from hermes_cli.skin_engine import get_active_help_header + header = get_active_help_header("(^_^)? Available Commands") + except Exception: + header = "(^_^)? Available Commands" + header = (header or "").strip() or "(^_^)? Available Commands" + inner_width = 55 + if len(header) > inner_width: + header = header[:inner_width] + _cprint(f"\n{_BOLD}+{'-' * inner_width}+{_RST}") + _cprint(f"{_BOLD}|{header:^{inner_width}}|{_RST}") + _cprint(f"{_BOLD}+{'-' * inner_width}+{_RST}") for category, commands in COMMANDS_BY_CATEGORY.items(): _cprint(f"\n {_BOLD}โ”€โ”€ {category} โ”€โ”€{_RST}") for cmd, desc in commands.items(): - _cprint(f" {_GOLD}{cmd:<15}{_RST} {_DIM}-{_RST} {desc}") + ChatConsole().print(f" [bold {_accent_hex()}]{cmd:<15}[/] [dim]-[/] {_escape(desc)}") if _skill_commands: _cprint(f"\n โšก {_BOLD}Skill Commands{_RST} ({len(_skill_commands)} installed):") for cmd, info in sorted(_skill_commands.items()): - _cprint(f" {_GOLD}{cmd:<22}{_RST} {_DIM}-{_RST} {info['description']}") + ChatConsole().print( + f" [bold {_accent_hex()}]{cmd:<22}[/] [dim]-[/] {_escape(info['description'])}" + ) _cprint(f"\n {_DIM}Tip: Just type your message to chat with Hermes!{_RST}") _cprint(f" {_DIM}Multi-line: Alt+Enter for a new line{_RST}") @@ -2981,8 +3034,7 @@ class HermesCLI: ) output = result.stdout.strip() or result.stderr.strip() if output: - from rich.text import Text as _RichText - self.console.print(_RichText.from_ansi(output)) + self.console.print(_rich_text_from_ansi(output)) else: self.console.print("[dim]Command returned no output[/]") except subprocess.TimeoutExpired: @@ -3076,27 +3128,29 @@ class HermesCLI: # Display result in the CLI (thread-safe via patch_stdout) print() - _cprint(f"{_GOLD}{'โ”€' * 40}{_RST}") + ChatConsole().print(f"[{_accent_hex()}]{'โ”€' * 40}[/]") _cprint(f" โœ… Background task #{task_num} complete") _cprint(f" Prompt: \"{prompt[:60]}{'...' if len(prompt) > 60 else ''}\"") - _cprint(f"{_GOLD}{'โ”€' * 40}{_RST}") + ChatConsole().print(f"[{_accent_hex()}]{'โ”€' * 40}[/]") if response: try: from hermes_cli.skin_engine import get_active_skin _skin = get_active_skin() label = _skin.get_branding("response_label", "โš• Hermes") _resp_color = _skin.get_color("response_border", "#CD7F32") + _resp_text = _skin.get_color("banner_text", "#FFF8DC") except Exception: label = "โš• Hermes" _resp_color = "#CD7F32" + _resp_text = "#FFF8DC" - from rich.text import Text as _RichText _chat_console = ChatConsole() _chat_console.print(Panel( - _RichText.from_ansi(response), - title=f"[bold]{label} (background #{task_num})[/bold]", + _rich_text_from_ansi(response), + title=f"[{_resp_color} bold]{label} (background #{task_num})[/]", title_align="left", border_style=_resp_color, + style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 2), )) @@ -3156,6 +3210,8 @@ class HermesCLI: else: print(f" Skin set to: {new_skin}") print(" Note: banner colors will update on next session start.") + if self._apply_tui_skin_style(): + print(" Prompt + TUI colors updated.") def _toggle_verbose(self): """Cycle tool progress mode: off โ†’ new โ†’ all โ†’ verbose โ†’ off.""" @@ -3689,8 +3745,8 @@ class HermesCLI: # Add user message to history self.conversation_history.append({"role": "user", "content": message}) - - _cprint(f"{_GOLD}{'โ”€' * 40}{_RST}") + + ChatConsole().print(f"[{_accent_hex()}]{'โ”€' * 40}[/]") print(flush=True) try: @@ -3803,17 +3859,19 @@ class HermesCLI: _skin = get_active_skin() label = _skin.get_branding("response_label", "โš• Hermes") _resp_color = _skin.get_color("response_border", "#CD7F32") + _resp_text = _skin.get_color("banner_text", "#FFF8DC") except Exception: label = "โš• Hermes" _resp_color = "#CD7F32" + _resp_text = "#FFF8DC" - from rich.text import Text as _RichText _chat_console = ChatConsole() _chat_console.print(Panel( - _RichText.from_ansi(response), - title=f"[bold]{label}[/bold]", + _rich_text_from_ansi(response), + title=f"[{_resp_color} bold]{label}[/]", title_align="left", border_style=_resp_color, + style=_resp_text, box=rich_box.HORIZONTALS, padding=(1, 2), )) @@ -3869,7 +3927,80 @@ class HermesCLI: print(f"Duration: {duration_str}") print(f"Messages: {msg_count} ({user_msgs} user, {tool_calls} tool calls)") else: - print("Goodbye! โš•") + try: + from hermes_cli.skin_engine import get_active_goodbye + goodbye = get_active_goodbye("Goodbye! โš•") + except Exception: + goodbye = "Goodbye! โš•" + print(goodbye) + + def _get_tui_prompt_symbols(self) -> tuple[str, str]: + """Return ``(normal_prompt, state_suffix)`` for the active skin. + + ``normal_prompt`` is the full ``branding.prompt_symbol``. + ``state_suffix`` is what special states (sudo/secret/approval/agent) + should render after their leading icon. + """ + try: + from hermes_cli.skin_engine import get_active_prompt_symbol + symbol = get_active_prompt_symbol("โฏ ") + except Exception: + symbol = "โฏ " + + symbol = (symbol or "โฏ ").rstrip() + " " + stripped = symbol.rstrip() + if not stripped: + return "โฏ ", "โฏ " + + parts = stripped.split() + candidate = parts[-1] if parts else "" + arrow_chars = ("โฏ", ">", "$", "#", "โ€บ", "ยป", "โ†’") + if any(ch in candidate for ch in arrow_chars): + return symbol, candidate.rstrip() + " " + + # Icon-only custom prompts should still remain visible in special states. + return symbol, symbol + + def _get_tui_prompt_fragments(self): + """Return the prompt_toolkit fragments for the current interactive state.""" + symbol, state_suffix = self._get_tui_prompt_symbols() + if self._sudo_state: + return [("class:sudo-prompt", f"๐Ÿ” {state_suffix}")] + if self._secret_state: + return [("class:sudo-prompt", f"๐Ÿ”‘ {state_suffix}")] + if self._approval_state: + return [("class:prompt-working", f"โš  {state_suffix}")] + if self._clarify_freetext: + return [("class:clarify-selected", f"โœŽ {state_suffix}")] + if self._clarify_state: + return [("class:prompt-working", f"? {state_suffix}")] + if self._command_running: + return [("class:prompt-working", f"{self._command_spinner_frame()} {state_suffix}")] + if self._agent_running: + return [("class:prompt-working", f"โš• {state_suffix}")] + return [("class:prompt", symbol)] + + def _get_tui_prompt_text(self) -> str: + """Return the visible prompt text for width calculations.""" + return "".join(text for _, text in self._get_tui_prompt_fragments()) + + def _build_tui_style_dict(self) -> dict[str, str]: + """Layer the active skin's prompt_toolkit colors over the base TUI style.""" + style_dict = dict(getattr(self, "_tui_style_base", {}) or {}) + try: + from hermes_cli.skin_engine import get_prompt_toolkit_style_overrides + style_dict.update(get_prompt_toolkit_style_overrides()) + except Exception: + pass + return style_dict + + def _apply_tui_skin_style(self) -> bool: + """Refresh prompt_toolkit styling for a running interactive TUI.""" + if not getattr(self, "_app", None) or not getattr(self, "_tui_style_base", None): + return False + self._app.style = PTStyle.from_dict(self._build_tui_style_dict()) + self._invalidate(min_interval=0.0) + return True def run(self): """Run the interactive CLI loop with persistent input at bottom.""" @@ -4241,21 +4372,7 @@ class HermesCLI: cli_ref = self def get_prompt(): - if cli_ref._sudo_state: - return [('class:sudo-prompt', '๐Ÿ” โฏ ')] - if cli_ref._secret_state: - return [('class:sudo-prompt', '๐Ÿ”‘ โฏ ')] - if cli_ref._approval_state: - return [('class:prompt-working', 'โš  โฏ ')] - if cli_ref._clarify_freetext: - return [('class:clarify-selected', 'โœŽ โฏ ')] - if cli_ref._clarify_state: - return [('class:prompt-working', '? โฏ ')] - if cli_ref._command_running: - return [('class:prompt-working', f"{cli_ref._command_spinner_frame()} โฏ ")] - if cli_ref._agent_running: - return [('class:prompt-working', 'โš• โฏ ')] - return [('class:prompt', 'โฏ ')] + return cli_ref._get_tui_prompt_fragments() # Create the input area with multiline (shift+enter), autocomplete, and paste handling input_area = TextArea( @@ -4272,11 +4389,11 @@ class HermesCLI: # Dynamic height: accounts for both explicit newlines AND visual # wrapping of long lines so the input area always fits its content. - # The prompt characters ("โฏ " etc.) consume ~4 columns. def _input_height(): try: doc = input_area.buffer.document - available_width = shutil.get_terminal_size().columns - 4 # subtract prompt width + prompt_width = max(2, len(self._get_tui_prompt_text())) + available_width = shutil.get_terminal_size().columns - prompt_width if available_width < 10: available_width = 40 visual_lines = 0 @@ -4717,7 +4834,7 @@ class HermesCLI: ) # Style for the application - style = PTStyle.from_dict({ + self._tui_style_base = { 'input-area': '#FFF8DC', 'placeholder': '#555555 italic', 'prompt': '#FFF8DC', @@ -4752,7 +4869,8 @@ class HermesCLI: 'approval-cmd': '#AAAAAA italic', 'approval-choice': '#AAAAAA', 'approval-selected': '#FFD700 bold', - }) + } + style = PTStyle.from_dict(self._build_tui_style_dict()) # Create the application app = Application( @@ -4815,20 +4933,25 @@ class HermesCLI: full_text = paste_path.read_text(encoding="utf-8") line_count = full_text.count('\n') + 1 print() - _cprint(f"{_GOLD}โ—{_RST} {_BOLD}[Pasted text: {line_count} lines]{_RST}") + ChatConsole().print( + f"[bold {_accent_hex()}]โ—[/] [bold]{_escape(f'[Pasted text: {line_count} lines]')}[/]" + ) user_input = full_text else: print() - _cprint(f"{_GOLD}โ—{_RST} {_BOLD}{user_input}{_RST}") + ChatConsole().print(f"[bold {_accent_hex()}]โ—[/] [bold]{_escape(user_input)}[/]") else: if '\n' in user_input: first_line = user_input.split('\n')[0] line_count = user_input.count('\n') + 1 print() - _cprint(f"{_GOLD}โ—{_RST} {_BOLD}{first_line}{_RST} {_DIM}(+{line_count - 1} lines){_RST}") + ChatConsole().print( + f"[bold {_accent_hex()}]โ—[/] [bold]{_escape(first_line)}[/] " + f"[dim](+{line_count - 1} lines)[/]" + ) else: print() - _cprint(f"{_GOLD}โ—{_RST} {_BOLD}{user_input}{_RST}") + ChatConsole().print(f"[bold {_accent_hex()}]โ—[/] [bold]{_escape(user_input)}[/]") # Show image attachment count if submit_images: diff --git a/hermes_cli/skin_engine.py b/hermes_cli/skin_engine.py index 6b9cb3c86f3..e73e73de571 100644 --- a/hermes_cli/skin_engine.py +++ b/hermes_cli/skin_engine.py @@ -628,3 +628,88 @@ def init_skin_from_config(config: dict) -> None: set_active_skin(skin_name.strip()) else: set_active_skin("default") + + +# ============================================================================= +# Convenience helpers for CLI modules +# ============================================================================= + + +def get_active_prompt_symbol(fallback: str = "โฏ ") -> str: + """Get the interactive prompt symbol from the active skin.""" + try: + return get_active_skin().get_branding("prompt_symbol", fallback) + except Exception: + return fallback + + + +def get_active_help_header(fallback: str = "(^_^)? Available Commands") -> str: + """Get the /help header from the active skin.""" + try: + return get_active_skin().get_branding("help_header", fallback) + except Exception: + return fallback + + + +def get_active_goodbye(fallback: str = "Goodbye! โš•") -> str: + """Get the goodbye line from the active skin.""" + try: + return get_active_skin().get_branding("goodbye", fallback) + except Exception: + return fallback + + + +def get_prompt_toolkit_style_overrides() -> Dict[str, str]: + """Return prompt_toolkit style overrides derived from the active skin. + + These are layered on top of the CLI's base TUI style so /skin can refresh + the live prompt_toolkit UI immediately without rebuilding the app. + """ + try: + skin = get_active_skin() + except Exception: + return {} + + prompt = skin.get_color("prompt", "#FFF8DC") + input_rule = skin.get_color("input_rule", "#CD7F32") + title = skin.get_color("banner_title", "#FFD700") + text = skin.get_color("banner_text", prompt) + dim = skin.get_color("banner_dim", "#555555") + label = skin.get_color("ui_label", title) + warn = skin.get_color("ui_warn", "#FF8C00") + error = skin.get_color("ui_error", "#FF6B6B") + + return { + "input-area": prompt, + "placeholder": f"{dim} italic", + "prompt": prompt, + "prompt-working": f"{dim} italic", + "hint": f"{dim} italic", + "input-rule": input_rule, + "image-badge": f"{label} bold", + "completion-menu": f"bg:#1a1a2e {text}", + "completion-menu.completion": f"bg:#1a1a2e {text}", + "completion-menu.completion.current": f"bg:#333355 {title}", + "completion-menu.meta.completion": f"bg:#1a1a2e {dim}", + "completion-menu.meta.completion.current": f"bg:#333355 {label}", + "clarify-border": input_rule, + "clarify-title": f"{title} bold", + "clarify-question": f"{text} bold", + "clarify-choice": dim, + "clarify-selected": f"{title} bold", + "clarify-active-other": f"{title} italic", + "clarify-countdown": input_rule, + "sudo-prompt": f"{error} bold", + "sudo-border": input_rule, + "sudo-title": f"{error} bold", + "sudo-text": text, + "approval-border": input_rule, + "approval-title": f"{warn} bold", + "approval-desc": f"{text} bold", + "approval-cmd": f"{dim} italic", + "approval-choice": dim, + "approval-selected": f"{title} bold", + } diff --git a/tests/hermes_cli/test_skin_engine.py b/tests/hermes_cli/test_skin_engine.py index 7de90b32c65..6a5a032f1c6 100644 --- a/tests/hermes_cli/test_skin_engine.py +++ b/tests/hermes_cli/test_skin_engine.py @@ -60,6 +60,9 @@ class TestBuiltinSkins: assert skin.name == "ares" assert skin.tool_prefix == "โ•Ž" assert skin.get_color("banner_border") == "#9F1C1C" + assert skin.get_color("response_border") == "#C7A96B" + assert skin.get_color("session_label") == "#C7A96B" + assert skin.get_color("session_border") == "#6E584B" assert skin.get_branding("agent_name") == "Ares Agent" def test_ares_has_spinner_customization(self): @@ -230,3 +233,82 @@ class TestDisplayIntegration: from agent.display import get_cute_tool_message msg = get_cute_tool_message("terminal", {"command": "ls"}, 0.5) assert msg.startswith("โ”Š") + + +class TestCliBrandingHelpers: + def test_active_prompt_symbol_default(self): + from hermes_cli.skin_engine import get_active_prompt_symbol + + assert get_active_prompt_symbol() == "โฏ " + + def test_active_prompt_symbol_ares(self): + from hermes_cli.skin_engine import set_active_skin, get_active_prompt_symbol + + set_active_skin("ares") + assert get_active_prompt_symbol() == "โš” โฏ " + + def test_active_help_header_ares(self): + from hermes_cli.skin_engine import set_active_skin, get_active_help_header + + set_active_skin("ares") + assert get_active_help_header() == "(โš”) Available Commands" + + def test_active_goodbye_ares(self): + from hermes_cli.skin_engine import set_active_skin, get_active_goodbye + + set_active_skin("ares") + assert get_active_goodbye() == "Farewell, warrior! โš”" + + def test_prompt_toolkit_style_overrides_cover_tui_classes(self): + from hermes_cli.skin_engine import set_active_skin, get_prompt_toolkit_style_overrides + + set_active_skin("ares") + overrides = get_prompt_toolkit_style_overrides() + required = { + "input-area", + "placeholder", + "prompt", + "prompt-working", + "hint", + "input-rule", + "image-badge", + "completion-menu", + "completion-menu.completion", + "completion-menu.completion.current", + "completion-menu.meta.completion", + "completion-menu.meta.completion.current", + "clarify-border", + "clarify-title", + "clarify-question", + "clarify-choice", + "clarify-selected", + "clarify-active-other", + "clarify-countdown", + "sudo-prompt", + "sudo-border", + "sudo-title", + "sudo-text", + "approval-border", + "approval-title", + "approval-desc", + "approval-cmd", + "approval-choice", + "approval-selected", + } + assert required.issubset(overrides.keys()) + + def test_prompt_toolkit_style_overrides_use_skin_colors(self): + from hermes_cli.skin_engine import ( + set_active_skin, + get_active_skin, + get_prompt_toolkit_style_overrides, + ) + + set_active_skin("ares") + skin = get_active_skin() + overrides = get_prompt_toolkit_style_overrides() + assert overrides["prompt"] == skin.get_color("prompt") + assert overrides["input-rule"] == skin.get_color("input_rule") + assert overrides["clarify-title"] == f"{skin.get_color('banner_title')} bold" + assert overrides["sudo-prompt"] == f"{skin.get_color('ui_error')} bold" + assert overrides["approval-title"] == f"{skin.get_color('ui_warn')} bold" diff --git a/tests/test_cli_skin_integration.py b/tests/test_cli_skin_integration.py new file mode 100644 index 00000000000..ef4ddb38df2 --- /dev/null +++ b/tests/test_cli_skin_integration.py @@ -0,0 +1,95 @@ +from types import SimpleNamespace +from unittest.mock import MagicMock, patch + +from cli import HermesCLI, _rich_text_from_ansi +from hermes_cli.skin_engine import get_active_skin, set_active_skin + + +def _make_cli_stub(): + cli = HermesCLI.__new__(HermesCLI) + cli._sudo_state = None + cli._secret_state = None + cli._approval_state = None + cli._clarify_state = None + cli._clarify_freetext = False + cli._command_running = False + cli._agent_running = False + cli._command_spinner_frame = lambda: "โŸณ" + cli._tui_style_base = { + "prompt": "#fff", + "input-area": "#fff", + "input-rule": "#aaa", + "prompt-working": "#888 italic", + } + cli._app = SimpleNamespace(style=None) + cli._invalidate = MagicMock() + return cli + + +class TestCliSkinPromptIntegration: + def test_default_prompt_fragments_use_default_symbol(self): + cli = _make_cli_stub() + + set_active_skin("default") + assert cli._get_tui_prompt_fragments() == [("class:prompt", "โฏ ")] + + def test_ares_prompt_fragments_use_skin_symbol(self): + cli = _make_cli_stub() + + set_active_skin("ares") + assert cli._get_tui_prompt_fragments() == [("class:prompt", "โš” โฏ ")] + + def test_secret_prompt_fragments_preserve_secret_state(self): + cli = _make_cli_stub() + cli._secret_state = {"response_queue": object()} + + set_active_skin("ares") + assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "๐Ÿ”‘ โฏ ")] + + def test_icon_only_skin_symbol_still_visible_in_special_states(self): + cli = _make_cli_stub() + cli._secret_state = {"response_queue": object()} + + with patch("hermes_cli.skin_engine.get_active_prompt_symbol", return_value="โš” "): + assert cli._get_tui_prompt_fragments() == [("class:sudo-prompt", "๐Ÿ”‘ โš” ")] + + def test_build_tui_style_dict_uses_skin_overrides(self): + cli = _make_cli_stub() + + set_active_skin("ares") + skin = get_active_skin() + style_dict = cli._build_tui_style_dict() + + assert style_dict["prompt"] == skin.get_color("prompt") + assert style_dict["input-rule"] == skin.get_color("input_rule") + assert style_dict["prompt-working"] == f"{skin.get_color('banner_dim')} italic" + assert style_dict["approval-title"] == f"{skin.get_color('ui_warn')} bold" + + def test_apply_tui_skin_style_updates_running_app(self): + cli = _make_cli_stub() + + set_active_skin("ares") + assert cli._apply_tui_skin_style() is True + assert cli._app.style is not None + cli._invalidate.assert_called_once_with(min_interval=0.0) + + def test_handle_skin_command_refreshes_live_tui(self, capsys): + cli = _make_cli_stub() + + with patch("cli.save_config_value", return_value=True): + cli._handle_skin_command("/skin ares") + + output = capsys.readouterr().out + assert "Skin set to: ares (saved)" in output + assert "Prompt + TUI colors updated." in output + assert cli._app.style is not None + + +class TestAnsiRichTextHelper: + def test_preserves_literal_brackets(self): + text = _rich_text_from_ansi("[notatag] literal") + assert text.plain == "[notatag] literal" + + def test_strips_ansi_but_keeps_plain_text(self): + text = _rich_text_from_ansi("\x1b[31mred\x1b[0m") + assert text.plain == "red" From e9a7441c9b9742859ebe707c8def0d8942a00de6 Mon Sep 17 00:00:00 2001 From: teknium1 Date: Sat, 14 Mar 2026 03:14:34 -0700 Subject: [PATCH 6/7] test: restore default event loop for sync tests --- tests/conftest.py | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/tests/conftest.py b/tests/conftest.py index 9469ee45f78..9c9f9a44ef0 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,5 +1,6 @@ """Shared fixtures for the hermes-agent test suite.""" +import asyncio import os import signal import sys @@ -59,6 +60,39 @@ def mock_config(): def _timeout_handler(signum, frame): raise TimeoutError("Test exceeded 30 second timeout") +@pytest.fixture(autouse=True) +def _ensure_current_event_loop(request): + """Provide a default event loop for sync tests that call get_event_loop(). + + Python 3.11+ no longer guarantees a current loop for plain synchronous tests. + A number of gateway tests still use asyncio.get_event_loop().run_until_complete(...). + Ensure they always have a usable loop without interfering with pytest-asyncio's + own loop management for @pytest.mark.asyncio tests. + """ + if request.node.get_closest_marker("asyncio") is not None: + yield + return + + try: + loop = asyncio.get_event_loop_policy().get_event_loop() + except RuntimeError: + loop = None + + created = loop is None or loop.is_closed() + if created: + loop = asyncio.new_event_loop() + asyncio.set_event_loop(loop) + + try: + yield + finally: + if created and loop is not None: + try: + loop.close() + finally: + asyncio.set_event_loop(None) + + @pytest.fixture(autouse=True) def _enforce_test_timeout(): """Kill any individual test that takes longer than 30 seconds.""" From 26f8b790c9cc05da57c1aa2c187dd50fca5d5b80 Mon Sep 17 00:00:00 2001 From: StefanIsMe <130151819+StefanIsMe@users.noreply.github.com> Date: Wed, 11 Mar 2026 22:47:08 +0700 Subject: [PATCH 7/7] fix(setup): persist provider when switching model endpoints --- hermes_cli/setup.py | 34 +++- tests/hermes_cli/test_setup_model_provider.py | 165 ++++++++++++++++++ 2 files changed, 197 insertions(+), 2 deletions(-) create mode 100644 tests/hermes_cli/test_setup_model_provider.py diff --git a/hermes_cli/setup.py b/hermes_cli/setup.py index 5fd2950c995..4a27339cee3 100644 --- a/hermes_cli/setup.py +++ b/hermes_cli/setup.py @@ -697,6 +697,12 @@ def setup_model_provider(config: dict): active_oauth = get_active_provider() existing_custom = get_env_value("OPENAI_BASE_URL") + model_cfg = config.get("model") if isinstance(config.get("model"), dict) else {} + current_config_provider = str(model_cfg.get("provider") or "").strip().lower() or None + if current_config_provider == "auto": + current_config_provider = None + current_config_base_url = str(model_cfg.get("base_url") or "").strip() + # Detect credentials from other CLI tools detected_creds = detect_external_credentials() if detected_creds: @@ -709,10 +715,23 @@ def setup_model_provider(config: dict): print() # Detect if any provider is already configured - has_any_provider = bool(active_oauth or existing_custom or existing_or) + has_any_provider = bool( + current_config_provider or active_oauth or existing_custom or existing_or + ) # Build "keep current" label - if active_oauth and active_oauth in PROVIDER_REGISTRY: + if current_config_provider == "custom": + custom_label = current_config_base_url or existing_custom + keep_label = ( + f"Keep current (Custom: {custom_label})" + if custom_label + else "Keep current (Custom)" + ) + elif current_config_provider == "openrouter": + keep_label = "Keep current (OpenRouter)" + elif current_config_provider and current_config_provider in PROVIDER_REGISTRY: + keep_label = f"Keep current ({PROVIDER_REGISTRY[current_config_provider].name})" + elif active_oauth and active_oauth in PROVIDER_REGISTRY: keep_label = f"Keep current ({PROVIDER_REGISTRY[active_oauth].name})" elif existing_custom: keep_label = f"Keep current (Custom: {existing_custom})" @@ -1215,6 +1234,17 @@ def setup_model_provider(config: dict): _set_model_provider(config, "anthropic") # else: provider_idx == 9 (Keep current) โ€” only shown when a provider already exists + # Normalize "keep current" to an explicit provider so downstream logic + # doesn't fall back to the generic OpenRouter/static-model path. + if selected_provider is None: + if current_config_provider: + selected_provider = current_config_provider + elif active_oauth and active_oauth in PROVIDER_REGISTRY: + selected_provider = active_oauth + elif existing_custom: + selected_provider = "custom" + elif existing_or: + selected_provider = "openrouter" # โ”€โ”€ OpenRouter API Key for tools (if not already set) โ”€โ”€ # Tools (vision, web, MoA) use OpenRouter independently of the main provider. diff --git a/tests/hermes_cli/test_setup_model_provider.py b/tests/hermes_cli/test_setup_model_provider.py new file mode 100644 index 00000000000..f7c3ce3858e --- /dev/null +++ b/tests/hermes_cli/test_setup_model_provider.py @@ -0,0 +1,165 @@ +"""Regression tests for interactive setup provider/model persistence.""" + +from __future__ import annotations + +from hermes_cli.config import load_config, save_config, save_env_value +from hermes_cli.setup import setup_model_provider + + +def _read_env(home): + env_path = home / ".env" + data = {} + if not env_path.exists(): + return data + for line in env_path.read_text().splitlines(): + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + data[k] = v + return data + + +def _clear_provider_env(monkeypatch): + for key in ( + "HERMES_INFERENCE_PROVIDER", + "OPENAI_BASE_URL", + "OPENAI_API_KEY", + "OPENROUTER_API_KEY", + "GLM_API_KEY", + "KIMI_API_KEY", + "MINIMAX_API_KEY", + "MINIMAX_CN_API_KEY", + "ANTHROPIC_TOKEN", + "ANTHROPIC_API_KEY", + ): + monkeypatch.delenv(key, raising=False) + + +def test_setup_keep_current_custom_from_config_does_not_fall_through(tmp_path, monkeypatch): + """Keep-current custom should not fall through to the generic model menu.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + config["model"] = { + "default": "custom/model", + "provider": "custom", + "base_url": "https://example.invalid/v1", + } + save_config(config) + + calls = {"count": 0} + + def fake_prompt_choice(_question, choices, default=0): + calls["count"] += 1 + if calls["count"] == 1: + assert choices[-1] == "Keep current (Custom: https://example.invalid/v1)" + return len(choices) - 1 + raise AssertionError("Model menu should not appear for keep-current custom") + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) + monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + + setup_model_provider(config) + save_config(config) + + reloaded = load_config() + assert reloaded["model"]["provider"] == "custom" + assert reloaded["model"]["default"] == "custom/model" + assert reloaded["model"]["base_url"] == "https://example.invalid/v1" + assert calls["count"] == 1 + + +def test_setup_keep_current_config_provider_uses_provider_specific_model_menu(tmp_path, monkeypatch): + """Keep-current should respect config-backed providers, not fall back to OpenRouter.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + config = load_config() + config["model"] = { + "default": "claude-opus-4-6", + "provider": "anthropic", + } + save_config(config) + + captured = {"provider_choices": None, "model_choices": None} + calls = {"count": 0} + + def fake_prompt_choice(_question, choices, default=0): + calls["count"] += 1 + if calls["count"] == 1: + captured["provider_choices"] = list(choices) + assert choices[-1] == "Keep current (Anthropic)" + return len(choices) - 1 + if calls["count"] == 2: + captured["model_choices"] = list(choices) + return len(choices) - 1 # keep current model + raise AssertionError("Unexpected extra prompt_choice call") + + monkeypatch.setattr("hermes_cli.setup.prompt_choice", fake_prompt_choice) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) + monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + monkeypatch.setattr("hermes_cli.models.provider_model_ids", lambda provider: []) + + setup_model_provider(config) + save_config(config) + + assert captured["provider_choices"] is not None + assert captured["model_choices"] is not None + assert captured["model_choices"][0] == "claude-opus-4-6" + assert "anthropic/claude-opus-4.6 (recommended)" not in captured["model_choices"] + assert calls["count"] == 2 + + +def test_setup_switch_custom_to_codex_clears_custom_endpoint_and_updates_config(tmp_path, monkeypatch): + """Switching from custom to Codex should clear custom endpoint overrides.""" + monkeypatch.setenv("HERMES_HOME", str(tmp_path)) + _clear_provider_env(monkeypatch) + + save_env_value("OPENAI_BASE_URL", "https://example.invalid/v1") + save_env_value("OPENAI_API_KEY", "sk-custom") + save_env_value("OPENROUTER_API_KEY", "sk-or") + + config = load_config() + config["model"] = { + "default": "custom/model", + "provider": "custom", + "base_url": "https://example.invalid/v1", + } + save_config(config) + + picks = iter([1, 0]) + monkeypatch.setattr("hermes_cli.setup.prompt_choice", lambda *args, **kwargs: next(picks)) + monkeypatch.setattr("hermes_cli.setup.prompt", lambda *args, **kwargs: "") + monkeypatch.setattr("hermes_cli.setup.prompt_yes_no", lambda *args, **kwargs: False) + monkeypatch.setattr("hermes_cli.auth.get_active_provider", lambda: None) + monkeypatch.setattr("hermes_cli.auth.detect_external_credentials", lambda: []) + monkeypatch.setattr("hermes_cli.auth._login_openai_codex", lambda *args, **kwargs: None) + monkeypatch.setattr( + "hermes_cli.auth.resolve_codex_runtime_credentials", + lambda *args, **kwargs: { + "base_url": "https://chatgpt.com/backend-api/codex", + "api_key": "codex-access-token", + }, + ) + monkeypatch.setattr( + "hermes_cli.codex_models.get_codex_model_ids", + lambda **kwargs: ["openai/gpt-5.3-codex", "openai/gpt-5-codex-mini"], + ) + + setup_model_provider(config) + save_config(config) + + env = _read_env(tmp_path) + reloaded = load_config() + + assert env.get("OPENAI_BASE_URL") == "" + assert env.get("OPENAI_API_KEY") == "" + assert reloaded["model"]["provider"] == "openai-codex" + assert reloaded["model"]["default"] == "openai/gpt-5.3-codex" + assert reloaded["model"]["base_url"] == "https://chatgpt.com/backend-api/codex"