Merge remote-tracking branch 'origin/main' into fix/egress-review-fixes

This commit is contained in:
Teknium 2026-06-14 13:13:53 -07:00
commit fb5649f2e8
No known key found for this signature in database
749 changed files with 68114 additions and 8224 deletions

View file

@ -154,7 +154,33 @@ class TestCliApprovalUi:
assert "Dangerous Command" not in lines[0]
assert any("Dangerous Command" in line for line in lines[1:3])
assert "Show full command" in rendered
assert "githubcli-archive-keyring.gpg" not in rendered
assert "githubcli-archive-" in rendered
assert "keyring.gpg" in rendered
assert "status=progress" in rendered
def test_approval_display_wraps_preview_hint_on_narrow_terminal(self):
cli = _make_cli_stub()
cli._approval_state = {
"command": "sudo " + ("very-long-command-segment-" * 8),
"description": "shell command via -c/-lc flag",
"choices": ["once", "session", "always", "deny", "view"],
"selected": 0,
"response_queue": queue.Queue(),
}
import shutil as _shutil
with patch("cli.shutil.get_terminal_size",
return_value=_shutil.os.terminal_size((30, 24))):
fragments = cli._get_approval_display_fragments()
rendered = "".join(text for _style, text in fragments)
lines = rendered.splitlines()
border_width = len(lines[0])
assert "Show full" in rendered
assert "command)" in rendered
assert all(len(line) == border_width for line in lines)
def test_approval_display_shows_full_command_after_view(self):
cli = _make_cli_stub()
@ -539,3 +565,80 @@ class TestApprovalCallbackThreadLocalWiring:
# would hold a stale reference to a disposed CLI instance.
assert seen["approval_after"] is None
assert seen["sudo_after"] is None
class TestPersistPromptSummary:
"""display.persist_prompts — one-line scrollback record of resolved modals."""
def _resolve_approval(self, cli, answer, command="rm -rf /tmp/scratch"):
result = {}
def _run():
result["value"] = cli._approval_callback(command, "danger")
t = threading.Thread(target=_run, daemon=True)
t.start()
deadline = time.time() + 2
while cli._approval_state is None and time.time() < deadline:
time.sleep(0.01)
cli._approval_state["response_queue"].put(answer)
t.join(timeout=2)
return result["value"]
def test_approval_resolution_prints_summary_line(self):
cli = _make_cli_stub()
printed = []
with patch.object(cli_module, "_cprint", printed.append):
verdict = self._resolve_approval(cli, "session")
assert verdict == "session"
summary = "\n".join(printed)
assert "Approval" in summary
assert "rm -rf /tmp/scratch" in summary
assert "allowed for session" in summary
def test_approval_summary_truncates_long_command(self):
cli = _make_cli_stub()
printed = []
long_cmd = "sudo " + ("x" * 300)
with patch.object(cli_module, "_cprint", printed.append):
self._resolve_approval(cli, "deny", command=long_cmd)
summary = "\n".join(printed)
assert "denied" in summary
assert "…" in summary
# The raw 300-char tail must not be dumped wholesale.
assert "x" * 200 not in summary
def test_persist_prompts_false_suppresses_summary(self):
cli = _make_cli_stub()
printed = []
with patch.dict(cli_module.CLI_CONFIG.get("display", {}), {"persist_prompts": False}), \
patch.object(cli_module, "_cprint", printed.append):
verdict = self._resolve_approval(cli, "once")
assert verdict == "once"
assert not any("Approval" in p for p in printed)
def test_clarify_resolution_prints_summary_line(self):
cli = _make_cli_stub()
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_deadline = 0
printed = []
result = {}
def _run():
result["value"] = cli._clarify_callback("Pick a path?", ["A", "B"])
with patch.object(cli_module, "_cprint", printed.append):
t = threading.Thread(target=_run, daemon=True)
t.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
cli._clarify_state["response_queue"].put("B")
t.join(timeout=2)
assert result["value"] == "B"
summary = "\n".join(printed)
assert "Clarify" in summary
assert "Pick a path?" in summary
assert "B" in summary

View file

@ -71,18 +71,14 @@ class TestForceFullRedraw:
"invalidate",
]
def test_resize_preserves_scrollback_and_resets_renderer(self, bare_cli, monkeypatch):
"""Resize recovery must NOT erase screen or scrollback.
def test_resize_recovery_uses_prompt_toolkit_original_resize_before_reset(self, bare_cli, monkeypatch):
"""Resize recovery must preserve prompt_toolkit's tracked cursor state.
The startup banner lives in normal terminal scrollback (printed
before prompt_toolkit owns the chrome). Clearing scrollback on
SIGWINCH removes it and ``_replay_output_history`` cannot
reconstruct it. The fix is to only reset the renderer cache and
let ``original_on_resize`` recalculate layout.
Additionally, ``_status_bar_suppressed_after_resize`` must be set
so the input rules and status bar hide until the next user input,
preventing duplicated-bar artifacts on column shrink (#19280).
prompt_toolkit's built-in Application._on_resize() starts with
renderer.erase(leave_alternate_screen=False), which uses the renderer's
cached cursor position to move back to the live prompt origin before
erase_down(). If Hermes resets the renderer first, that cursor position
is lost and stale prompt glyphs can remain after a narrow resize.
"""
app = MagicMock()
events = []
@ -94,11 +90,9 @@ class TestForceFullRedraw:
bare_cli._status_bar_suppressed_after_resize = False
bare_cli._recover_after_resize(app, original_on_resize)
assert events == [
"renderer_reset",
"invalidate",
"original_resize",
]
assert events == ["original_resize"]
app.renderer.reset.assert_not_called()
app.invalidate.assert_not_called()
# Must NOT clear the screen or scrollback — those destroy the banner.
app.renderer.output.erase_screen.assert_not_called()
app.renderer.output.write_raw.assert_not_called()

View file

@ -3,6 +3,7 @@ from datetime import datetime, timedelta
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import cli as cli_mod
from cli import HermesCLI
@ -104,91 +105,24 @@ class TestCLIStatusBar:
assert "-1" not in text
assert "0/200K" in text
def test_input_height_counts_prompt_only_on_first_wrapped_row(self):
# Regression for prompt_toolkit classic CLI resize glitches: the prompt
# is inserted by BeforeInput only on logical line 0. At three terminal
# cells, "⚔ " leaves one cell for the first input character, but
# wrapped continuation rows use the full three cells. Estimating every
# wrapped row as one-cell wide over-allocates the TextArea and can leave
# stale prompt/input cells visible after resize.
assert cli_mod._estimate_tui_input_height(["abcdef"], "⚔ ", 3) == 3
def test_input_height_counts_wide_characters_using_cell_width(self):
cli_obj = _make_cli()
# Prompt width (2 cells) + ten CJK chars (20 cells) = 22 display cells,
# which wraps to two rows at 14 terminal columns.
assert cli_mod._estimate_tui_input_height(["你" * 10], "❯ ", 14) == 2
class _Doc:
lines = ["你" * 10]
class _Buffer:
document = _Doc()
input_area = SimpleNamespace(buffer=_Buffer())
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
except Exception:
import shutil
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width))
return min(max(visual_lines, 1), 8)
except Exception:
return 1
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=14)
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
patch("prompt_toolkit.application.get_app", return_value=mock_app):
assert _input_height() == 2
def test_input_height_uses_prompt_toolkit_width_over_shutil(self):
cli_obj = _make_cli()
class _Doc:
lines = ["你" * 10]
class _Buffer:
document = _Doc()
input_area = SimpleNamespace(buffer=_Buffer())
def _input_height():
try:
from prompt_toolkit.application import get_app
from prompt_toolkit.utils import get_cwidth
doc = input_area.buffer.document
prompt_width = max(2, get_cwidth(cli_obj._get_tui_prompt_text()))
try:
available_width = get_app().output.get_size().columns - prompt_width
except Exception:
import shutil
available_width = shutil.get_terminal_size((80, 24)).columns - prompt_width
if available_width < 10:
available_width = 40
visual_lines = 0
for line in doc.lines:
line_width = get_cwidth(line)
if line_width <= 0:
visual_lines += 1
else:
visual_lines += max(1, -(-line_width // available_width))
return min(max(visual_lines, 1), 8)
except Exception:
return 1
mock_app = MagicMock()
mock_app.output.get_size.return_value = MagicMock(columns=14)
with patch.object(HermesCLI, "_get_tui_prompt_text", return_value="❯ "), \
patch("prompt_toolkit.application.get_app", return_value=mock_app), \
patch("shutil.get_terminal_size") as mock_shutil:
assert _input_height() == 2
mock_shutil.assert_not_called()
def test_input_height_clamps_zero_width_to_one_cell(self):
# Some terminals briefly report zero columns during resize. Treat that
# as a one-cell terminal rather than falling back to a fake wide width.
assert cli_mod._estimate_tui_input_height(["abcd"], "", 0) == 4
def test_build_status_bar_text_no_cost_in_status_bar(self):
cli_obj = _attach_agent(
@ -676,3 +610,54 @@ class TestStatusBarWidthSource:
mock_get_app.assert_not_called()
mock_shutil.assert_not_called()
assert len(text) > 0
class TestIdleSinceLastTurn:
"""Time-since-last-final-agent-response read-out on the status bar."""
def test_hidden_before_first_turn(self):
assert HermesCLI._format_idle_since(None, turn_live=False) == ""
def test_hidden_while_turn_is_live(self):
assert HermesCLI._format_idle_since(time.time() - 30, turn_live=True) == ""
def test_shows_compact_idle_time_after_turn(self):
label = HermesCLI._format_idle_since(time.time() - 42, turn_live=False)
assert label.startswith("✓ ")
assert label == "✓ 42s"
def test_scales_to_minutes(self):
label = HermesCLI._format_idle_since(time.time() - 3 * 60, turn_live=False)
assert label == "✓ 3m"
def test_snapshot_carries_idle_since(self):
cli_obj = _make_cli()
cli_obj._last_turn_finished_at = time.time() - 10
cli_obj._prompt_start_time = None
cli_obj._prompt_duration = 5.0
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["idle_since"].startswith("✓ ")
def test_snapshot_idle_empty_during_live_turn(self):
cli_obj = _make_cli()
cli_obj._last_turn_finished_at = time.time() - 10
cli_obj._prompt_start_time = time.time()
cli_obj._prompt_duration = 0.0
snapshot = cli_obj._get_status_bar_snapshot()
assert snapshot["idle_since"] == ""
def test_wide_status_bar_text_includes_idle(self):
cli_obj = _attach_agent(
_make_cli(),
prompt_tokens=10_230,
completion_tokens=2_220,
total_tokens=12_450,
api_calls=7,
context_tokens=12_450,
context_length=200_000,
)
cli_obj._last_turn_finished_at = time.time() - 42
cli_obj._prompt_start_time = None
cli_obj._prompt_duration = 7.0
text = cli_obj._build_status_bar_text(width=160)
assert "✓ 42s" in text