Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 53 additions & 12 deletions cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -1385,24 +1385,65 @@ def show_history(self):
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):
Expand Down
234 changes: 234 additions & 0 deletions tests/test_cli_init.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,15 +3,217 @@

import os
import sys
import types
from contextlib import nullcontext
from unittest.mock import patch, MagicMock

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 = {
Expand Down Expand Up @@ -72,6 +274,38 @@ def test_tool_progress_mode_is_string(self):
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()
Expand Down