From 77f47768dde5aa519b93e46b298bb5eade500997 Mon Sep 17 00:00:00 2001 From: stablegenius49 <16443023+stablegenius49@users.noreply.github.com> Date: Sat, 7 Mar 2026 20:15:06 -0800 Subject: [PATCH] fix: improve /history message display --- cli.py | 65 +++++++++--- tests/test_cli_init.py | 234 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 287 insertions(+), 12 deletions(-) diff --git a/cli.py b/cli.py index 9e8ee21c3..f29ad3af0 100755 --- a/cli.py +++ b/cli.py @@ -1546,24 +1546,65 @@ class HermesCLI: if not self.conversation_history: print("(._.) No conversation history yet.") return - + + preview_limit = 400 + visible_index = 0 + hidden_tool_messages = 0 + + def flush_tool_summary(): + nonlocal hidden_tool_messages + if not hidden_tool_messages: + return + + noun = "message" if hidden_tool_messages == 1 else "messages" + print("\n [Tools]") + print(f" ({hidden_tool_messages} tool {noun} hidden)") + hidden_tool_messages = 0 + print() print("+" + "-" * 50 + "+") print("|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|") print("+" + "-" * 50 + "+") - - for i, msg in enumerate(self.conversation_history, 1): + + for msg in self.conversation_history: role = msg.get("role", "unknown") - content = msg.get("content") or "" - + + if role == "tool": + hidden_tool_messages += 1 + continue + + if role not in {"user", "assistant"}: + continue + + flush_tool_summary() + visible_index += 1 + + content = msg.get("content") + content_text = "" if content is None else str(content) + if role == "user": - print(f"\n [You #{i}]") - print(f" {content[:200]}{'...' if len(content) > 200 else ''}") - elif role == "assistant": - print(f"\n [Hermes #{i}]") - preview = content[:200] if content else "(tool calls)" - print(f" {preview}{'...' if len(str(content)) > 200 else ''}") - + print(f"\n [You #{visible_index}]") + print( + f" {content_text[:preview_limit]}{'...' if len(content_text) > preview_limit else ''}" + ) + continue + + print(f"\n [Hermes #{visible_index}]") + tool_calls = msg.get("tool_calls") or [] + if content_text: + preview = content_text[:preview_limit] + suffix = "..." if len(content_text) > preview_limit else "" + elif tool_calls: + tool_count = len(tool_calls) + noun = "call" if tool_count == 1 else "calls" + preview = f"(requested {tool_count} tool {noun})" + suffix = "" + else: + preview = "(no text response)" + suffix = "" + print(f" {preview}{suffix}") + + flush_tool_summary() print() def reset_conversation(self): diff --git a/tests/test_cli_init.py b/tests/test_cli_init.py index f679d7706..445f5d42a 100644 --- a/tests/test_cli_init.py +++ b/tests/test_cli_init.py @@ -3,6 +3,8 @@ that only manifest at runtime (not in mocked unit tests).""" import os import sys +import types +from contextlib import nullcontext from unittest.mock import patch, MagicMock import pytest @@ -10,8 +12,208 @@ import pytest sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) +def _install_prompt_toolkit_stubs(): + """Provide minimal prompt_toolkit shims for non-TUI unit tests.""" + if "prompt_toolkit" in sys.modules: + return + + class _StubBase: + def __init__(self, *args, **kwargs): + pass + + def __call__(self, *args, **kwargs): + return None + + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + class _StubStyle: + @classmethod + def from_dict(cls, *_args, **_kwargs): + return cls() + + prompt_toolkit = types.ModuleType("prompt_toolkit") + prompt_toolkit.print_formatted_text = lambda *args, **kwargs: None + + history = types.ModuleType("prompt_toolkit.history") + history.FileHistory = _StubBase + + styles = types.ModuleType("prompt_toolkit.styles") + styles.Style = _StubStyle + + patch_stdout = types.ModuleType("prompt_toolkit.patch_stdout") + patch_stdout.patch_stdout = nullcontext + + application = types.ModuleType("prompt_toolkit.application") + application.Application = _StubBase + + layout = types.ModuleType("prompt_toolkit.layout") + layout.Layout = _StubBase + layout.HSplit = _StubBase + layout.Window = _StubBase + layout.FormattedTextControl = _StubBase + layout.ConditionalContainer = _StubBase + + processors = types.ModuleType("prompt_toolkit.layout.processors") + processors.Processor = _StubBase + processors.Transformation = _StubBase + processors.PasswordProcessor = _StubBase + processors.ConditionalProcessor = _StubBase + + filters = types.ModuleType("prompt_toolkit.filters") + filters.Condition = lambda fn: fn + + dimension = types.ModuleType("prompt_toolkit.layout.dimension") + dimension.Dimension = _StubBase + + menus = types.ModuleType("prompt_toolkit.layout.menus") + menus.CompletionsMenu = _StubBase + + widgets = types.ModuleType("prompt_toolkit.widgets") + widgets.TextArea = _StubBase + + key_binding = types.ModuleType("prompt_toolkit.key_binding") + key_binding.KeyBindings = _StubBase + + completion = types.ModuleType("prompt_toolkit.completion") + completion.Completer = object + completion.Completion = _StubBase + + formatted_text = types.ModuleType("prompt_toolkit.formatted_text") + formatted_text.ANSI = str + + sys.modules.update( + { + "prompt_toolkit": prompt_toolkit, + "prompt_toolkit.history": history, + "prompt_toolkit.styles": styles, + "prompt_toolkit.patch_stdout": patch_stdout, + "prompt_toolkit.application": application, + "prompt_toolkit.layout": layout, + "prompt_toolkit.layout.processors": processors, + "prompt_toolkit.filters": filters, + "prompt_toolkit.layout.dimension": dimension, + "prompt_toolkit.layout.menus": menus, + "prompt_toolkit.widgets": widgets, + "prompt_toolkit.key_binding": key_binding, + "prompt_toolkit.completion": completion, + "prompt_toolkit.formatted_text": formatted_text, + } + ) + + +def _install_rich_stubs(): + """Provide minimal rich shims for CLI unit tests.""" + if "rich" in sys.modules: + return + + rich = types.ModuleType("rich") + console = types.ModuleType("rich.console") + panel = types.ModuleType("rich.panel") + table = types.ModuleType("rich.table") + + class _RichStub: + def __init__(self, *args, **kwargs): + pass + + def __call__(self, *args, **kwargs): + return None + + def __getattr__(self, _name): + return lambda *args, **kwargs: None + + console.Console = _RichStub + panel.Panel = _RichStub + table.Table = _RichStub + + sys.modules.update( + { + "rich": rich, + "rich.console": console, + "rich.panel": panel, + "rich.table": table, + } + ) + + +def _install_cli_dependency_stubs(): + """Stub heavy runtime-only dependencies so CLI unit tests stay lightweight.""" + if "fire" not in sys.modules: + sys.modules["fire"] = types.ModuleType("fire") + + if "run_agent" not in sys.modules: + run_agent = types.ModuleType("run_agent") + run_agent.AIAgent = object + sys.modules["run_agent"] = run_agent + + if "model_tools" not in sys.modules: + model_tools = types.ModuleType("model_tools") + model_tools.get_tool_definitions = lambda *args, **kwargs: [] + model_tools.get_toolset_for_tool = lambda *args, **kwargs: None + sys.modules["model_tools"] = model_tools + + if "hermes_cli.banner" not in sys.modules: + banner = types.ModuleType("hermes_cli.banner") + banner.cprint = lambda *args, **kwargs: None + banner._GOLD = banner._BOLD = banner._DIM = banner._RST = "" + banner.VERSION = "test" + banner.HERMES_AGENT_LOGO = "" + banner.HERMES_CADUCEUS = "" + banner.COMPACT_BANNER = "" + banner.get_available_skills = lambda *args, **kwargs: [] + banner.build_welcome_banner = lambda *args, **kwargs: "" + sys.modules.setdefault("hermes_cli", types.ModuleType("hermes_cli")) + sys.modules["hermes_cli.banner"] = banner + + if "hermes_cli.commands" not in sys.modules: + commands = types.ModuleType("hermes_cli.commands") + commands.COMMANDS = {} + commands.SlashCommandCompleter = object + sys.modules["hermes_cli.commands"] = commands + + if "hermes_cli.callbacks" not in sys.modules: + callbacks = types.ModuleType("hermes_cli.callbacks") + callbacks.register_approval_callback = lambda *args, **kwargs: None + callbacks.register_sudo_password_callback = lambda *args, **kwargs: None + sys.modules["hermes_cli.callbacks"] = callbacks + sys.modules.setdefault("hermes_cli", types.ModuleType("hermes_cli")).callbacks = callbacks + + if "toolsets" not in sys.modules: + toolsets = types.ModuleType("toolsets") + toolsets.get_all_toolsets = lambda *args, **kwargs: [] + toolsets.get_toolset_info = lambda *args, **kwargs: {} + toolsets.resolve_toolset = lambda *args, **kwargs: [] + toolsets.validate_toolset = lambda *_args, **_kwargs: True + sys.modules["toolsets"] = toolsets + + if "cron" not in sys.modules: + cron = types.ModuleType("cron") + cron.create_job = lambda *args, **kwargs: None + cron.list_jobs = lambda *args, **kwargs: [] + cron.remove_job = lambda *args, **kwargs: None + cron.get_job = lambda *args, **kwargs: None + sys.modules["cron"] = cron + + sys.modules.setdefault("tools", types.ModuleType("tools")) + + if "tools.terminal_tool" not in sys.modules: + terminal_tool = types.ModuleType("tools.terminal_tool") + terminal_tool.cleanup_all_environments = lambda *args, **kwargs: None + terminal_tool.set_sudo_password_callback = lambda *args, **kwargs: None + terminal_tool.set_approval_callback = lambda *args, **kwargs: None + sys.modules["tools.terminal_tool"] = terminal_tool + + if "tools.browser_tool" not in sys.modules: + browser_tool = types.ModuleType("tools.browser_tool") + browser_tool._emergency_cleanup_all_sessions = lambda *args, **kwargs: None + sys.modules["tools.browser_tool"] = browser_tool + + def _make_cli(env_overrides=None, **kwargs): """Create a HermesCLI instance with minimal mocking.""" + _install_prompt_toolkit_stubs() + _install_rich_stubs() + _install_cli_dependency_stubs() import cli as _cli_mod from cli import HermesCLI _clean_config = { @@ -72,6 +274,38 @@ class TestVerboseAndToolProgress: assert cli.tool_progress_mode in ("off", "new", "all", "verbose") +class TestHistoryDisplay: + def test_history_numbers_only_visible_messages_and_summarizes_tools(self, capsys): + cli = _make_cli() + cli.conversation_history = [ + {"role": "system", "content": "system prompt"}, + {"role": "user", "content": "Hello"}, + { + "role": "assistant", + "content": None, + "tool_calls": [{"id": "call_1"}, {"id": "call_2"}], + }, + {"role": "tool", "content": "tool output 1"}, + {"role": "tool", "content": "tool output 2"}, + {"role": "assistant", "content": "All set."}, + {"role": "user", "content": "A" * 250}, + ] + + cli.show_history() + output = capsys.readouterr().out + + assert "[You #1]" in output + assert "[Hermes #2]" in output + assert "(requested 2 tool calls)" in output + assert "[Tools]" in output + assert "(2 tool messages hidden)" in output + assert "[Hermes #3]" in output + assert "[You #4]" in output + assert "[You #5]" not in output + assert "A" * 250 in output + assert "A" * 250 + "..." not in output + + class TestProviderResolution: def test_api_key_is_string_or_none(self): cli = _make_cli()