mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-22 16:25:58 +00:00
Port from openai/codex#31494: user-visible history replay must strip CSI sequences and control characters. Stored conversation history can carry raw terminal escapes (pasted content, gateway-origin text, model output echoing injected tool results). Replaying it via /resume's recap panel or build_recap (/status on CLI + gateway) wrote those bytes straight to the terminal — an injected message could clear the screen, retitle the window, move the cursor, or restyle the recap UI. Rich's Text() does not neutralize raw escape bytes. - tools/ansi_strip.py: add sanitize_display_text() — strip_ansi() plus bare C0/C1 control removal, preserving \n and \t, normalizing \r to \n (adapted to Python from Codex's sanitize_user_text; reuses the existing ECMA-48 stripper instead of transcribing their char-walk) - hermes_cli/cli_agent_setup_mixin.py: sanitize user + assistant text in _display_resumed_history() before building the Rich recap panel - hermes_cli/session_recap.py: sanitize preview lines in build_recap() (_truncate choke point) so /status recaps are clean on every platform - tests: 10 new sanitize_display_text cases (incl. the exact codex#31494 fixture), recap + resume-display leak assertions
193 lines
5.1 KiB
Python
193 lines
5.1 KiB
Python
"""Unit tests for hermes_cli.session_recap."""
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
|
||
|
||
from hermes_cli.session_recap import build_recap
|
||
|
||
|
||
def _user(text):
|
||
return {"role": "user", "content": text}
|
||
|
||
|
||
def _assistant(text=None, tool_calls=None):
|
||
msg = {"role": "assistant", "content": text}
|
||
if tool_calls:
|
||
msg["tool_calls"] = tool_calls
|
||
return msg
|
||
|
||
|
||
def _tool_call(name, args):
|
||
return {
|
||
"id": f"call_{name}",
|
||
"type": "function",
|
||
"function": {"name": name, "arguments": json.dumps(args)},
|
||
}
|
||
|
||
|
||
def _tool_result(content="ok"):
|
||
return {"role": "tool", "content": content}
|
||
|
||
|
||
def test_empty_history():
|
||
out = build_recap([])
|
||
assert "Session recap" in out
|
||
assert "nothing to recap" in out
|
||
|
||
|
||
def test_header_shows_title_when_provided():
|
||
out = build_recap([_user("hello")], session_title="Refactor the adapter")
|
||
assert "Refactor the adapter" in out.splitlines()[0]
|
||
|
||
|
||
def test_header_shows_short_id_when_no_title():
|
||
out = build_recap([_user("hello")], session_id="abcdef1234567890")
|
||
assert "abcdef12" in out.splitlines()[0]
|
||
|
||
|
||
def test_counts_recent_turns():
|
||
msgs = [
|
||
_user("one"),
|
||
_assistant("first reply"),
|
||
_user("two"),
|
||
_assistant("second reply"),
|
||
]
|
||
out = build_recap(msgs)
|
||
assert "2 user turn" in out
|
||
assert "assistant repl" in out
|
||
|
||
|
||
def test_last_ask_and_reply_are_surfaced():
|
||
msgs = [
|
||
_user("old question"),
|
||
_assistant("old answer"),
|
||
_user("summarise the docs"),
|
||
_assistant("here is the summary of the docs you asked for"),
|
||
]
|
||
out = build_recap(msgs)
|
||
assert "summarise the docs" in out
|
||
assert "summary of the docs" in out
|
||
|
||
|
||
def test_tool_counts_and_files():
|
||
msgs = [
|
||
_user("edit the readme and run tests"),
|
||
_assistant(
|
||
tool_calls=[
|
||
_tool_call("read_file", {"path": "README.md"}),
|
||
_tool_call("patch", {"path": "README.md"}),
|
||
]
|
||
),
|
||
_tool_result(),
|
||
_tool_result(),
|
||
_assistant(
|
||
tool_calls=[
|
||
_tool_call("terminal", {"command": "pytest"}),
|
||
]
|
||
),
|
||
_tool_result("tests ok"),
|
||
_assistant("All green."),
|
||
]
|
||
out = build_recap(msgs)
|
||
assert "patch×1" in out
|
||
assert "terminal×1" in out
|
||
assert "read_file×1" in out
|
||
# README.md should appear (may include cwd-relative prefix stripping).
|
||
assert "README.md" in out
|
||
|
||
|
||
def test_tool_preview_length_truncates_long_user_prompt():
|
||
long = "x " * 500
|
||
out = build_recap([_user(long)])
|
||
ask_line = [l for l in out.splitlines() if "Last ask" in l][0]
|
||
assert len(ask_line) < 300 # truncated with ellipsis
|
||
assert "…" in ask_line
|
||
|
||
|
||
def test_respects_recent_window():
|
||
# 30 turns of user+assistant; only the most recent 20 should be summarised.
|
||
msgs = []
|
||
for i in range(30):
|
||
msgs.append(_user(f"question {i}"))
|
||
msgs.append(_assistant(f"answer {i}"))
|
||
out = build_recap(msgs)
|
||
# We scoped to the 20-turn window but show "of 30/30 total".
|
||
assert "of 30/30 total" in out
|
||
|
||
|
||
def test_multimodal_content_blocks_flattened():
|
||
msgs = [
|
||
{
|
||
"role": "user",
|
||
"content": [
|
||
{"type": "text", "text": "check this file"},
|
||
{"type": "image_url", "image_url": {"url": "..."}},
|
||
],
|
||
},
|
||
_assistant("Looked at your image."),
|
||
]
|
||
out = build_recap(msgs)
|
||
assert "check this file" in out
|
||
assert "Looked at your image" in out
|
||
|
||
|
||
def test_handles_arguments_as_dict_not_string():
|
||
# Some providers return arguments already as a dict.
|
||
msgs = [
|
||
_user("go"),
|
||
{
|
||
"role": "assistant",
|
||
"content": None,
|
||
"tool_calls": [
|
||
{
|
||
"type": "function",
|
||
"function": {
|
||
"name": "patch",
|
||
"arguments": {"path": "foo.py"},
|
||
},
|
||
}
|
||
],
|
||
},
|
||
]
|
||
out = build_recap(msgs)
|
||
assert "patch×1" in out
|
||
assert "foo.py" in out
|
||
|
||
|
||
def test_no_assistant_activity_hint():
|
||
out = build_recap([_user("just sent my first message")])
|
||
assert "no assistant activity" in out or "Last ask" in out
|
||
|
||
|
||
def test_tool_message_count_reported():
|
||
msgs = [
|
||
_user("go"),
|
||
_assistant(tool_calls=[_tool_call("read_file", {"path": "a"})]),
|
||
_tool_result(),
|
||
_tool_result(),
|
||
_assistant("done"),
|
||
]
|
||
out = build_recap(msgs)
|
||
assert "2 tool result" in out
|
||
|
||
|
||
def test_ignores_non_mapping_entries_gracefully():
|
||
msgs = [None, "stray", _user("hi"), _assistant("hello")]
|
||
# Should not raise.
|
||
out = build_recap(msgs)
|
||
assert "Session recap" in out
|
||
|
||
|
||
def test_escape_sequences_sanitized_in_previews():
|
||
"""Recap previews must not carry raw terminal escapes (codex#31494 class)."""
|
||
msgs = [
|
||
_user("please \x1b[2J\x1b]0;pwned\x07 do the thing"),
|
||
_assistant("done \x9b31m with it\x07"),
|
||
]
|
||
out = build_recap(msgs)
|
||
assert "\x1b" not in out
|
||
assert "\x9b" not in out
|
||
assert "\x07" not in out
|
||
assert "do the thing" in out
|
||
assert "with it" in out
|