fix: improve /history message display

This commit is contained in:
stablegenius49 2026-03-07 20:15:06 -08:00 committed by teknium1
parent 90fa9e54ca
commit 77f47768dd
2 changed files with 287 additions and 12 deletions

65
cli.py
View file

@ -1546,24 +1546,65 @@ class HermesCLI:
if not self.conversation_history: if not self.conversation_history:
print("(._.) No conversation history yet.") print("(._.) No conversation history yet.")
return 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()
print("+" + "-" * 50 + "+") print("+" + "-" * 50 + "+")
print("|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|") print("|" + " " * 12 + "(^_^) Conversation History" + " " * 11 + "|")
print("+" + "-" * 50 + "+") print("+" + "-" * 50 + "+")
for i, msg in enumerate(self.conversation_history, 1): for msg in self.conversation_history:
role = msg.get("role", "unknown") 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": if role == "user":
print(f"\n [You #{i}]") print(f"\n [You #{visible_index}]")
print(f" {content[:200]}{'...' if len(content) > 200 else ''}") print(
elif role == "assistant": f" {content_text[:preview_limit]}{'...' if len(content_text) > preview_limit else ''}"
print(f"\n [Hermes #{i}]") )
preview = content[:200] if content else "(tool calls)" continue
print(f" {preview}{'...' if len(str(content)) > 200 else ''}")
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() print()
def reset_conversation(self): def reset_conversation(self):

View file

@ -3,6 +3,8 @@ that only manifest at runtime (not in mocked unit tests)."""
import os import os
import sys import sys
import types
from contextlib import nullcontext
from unittest.mock import patch, MagicMock from unittest.mock import patch, MagicMock
import pytest import pytest
@ -10,8 +12,208 @@ import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..")) 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): def _make_cli(env_overrides=None, **kwargs):
"""Create a HermesCLI instance with minimal mocking.""" """Create a HermesCLI instance with minimal mocking."""
_install_prompt_toolkit_stubs()
_install_rich_stubs()
_install_cli_dependency_stubs()
import cli as _cli_mod import cli as _cli_mod
from cli import HermesCLI from cli import HermesCLI
_clean_config = { _clean_config = {
@ -72,6 +274,38 @@ class TestVerboseAndToolProgress:
assert cli.tool_progress_mode in ("off", "new", "all", "verbose") 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: class TestProviderResolution:
def test_api_key_is_string_or_none(self): def test_api_key_is_string_or_none(self):
cli = _make_cli() cli = _make_cli()