Files
hermes-agent/tests/hermes_cli/test_steer_inline_repaint.py
teknium1 d10bb2ab6f test: make tests/ mirror the source tree; drop issue numbers from filenames
`scripts/run_tests.sh tests/<dir>/` is how a change gets its regression
coverage run, so a test filed under the wrong directory is a test nobody
runs when that code changes. Two kinds of drift had accumulated.

Parallel directories for one source package, folded into the mirror:
  tests/acp        -> tests/acp_adapter   (its __init__/conftest move with it)
  tests/cli        -> tests/hermes_cli    (prompt_toolkit fixture merged into
                                           hermes_cli/conftest.py)
  tests/run_agent  -> tests/agent         (backoff fixture becomes
                                           agent/conftest.py)
  tests/relay      -> tests/gateway/relay
  tests/state      -> tests/hermes_state

246 loose files at tests/ root, routed by the package they import/patch:
hermes_cli, hermes_state, agent, gateway, tools, plugins, tui_gateway, cron.
Installer and desktop-update script tests go to tests/scripts/{install,
desktop_update}/. 43 tests of root-level modules (batch_runner, utils,
hermes_constants, packaging) stay at the root.

Filenames drop their issue numbers (95 files: test_89315_x.py -> test_x.py);
the number stays in the module docstring where it has context.

Collisions: test_cli_skin_integration.py existed in both tests/ and tests/cli
with different subsets — merged into one (10 tests, all kept);
run_agent/test_pre_compress_memory_context.py -> agent/..._handoff.py;
tests/test_account_usage.py -> agent/test_account_usage_fetch.py;
tests/test_web_server.py -> hermes_cli/test_web_server_ws_ping.py.
Deleted: test_minisweagent_path.py (empty since PR #2804),
test_model_picker_scroll.py (tested a private copy of the logic, imported
nothing), test_process_loop_event_loop_warning.py (asserted asyncio behaviour,
imported nothing from Hermes).

Repo-root path arithmetic (Path(__file__).parents[N], dirname chains) is
bumped for the 202 files that changed depth and verified by evaluating every
such expression against the new location. classify_changes' desktop-updater
lane prefix, tests-os.yml's ignore glob and every in-tree path comment follow
the moves. tests/test_tests_tree_layout.py keeps the tree from drifting back.
2026-09-13 09:18:02 -07:00

116 lines
4.6 KiB
Python

"""Regression guard for issue #34569 — inline /steer (and /model) submit
must repaint the input area after clearing the buffer.
Mechanism of the bug
--------------------
``handle_enter`` dispatches ``/steer`` (and ``/model``) inline on the UI
thread while the agent is running. Those branches called
``buffer.reset(append_to_history=True)`` but — unlike every *other*
early-return branch in the handler — did NOT call ``event.app.invalidate()``.
Because ``process_command()`` prints through ``patch_stdout`` (which scrolls
output above the prompt and never triggers a prompt_toolkit redraw), the
just-cleared input area could keep showing the submitted ``/steer <text>``
until some unrelated redraw fired. The user saw their submitted text as if
it were unsent and could accidentally re-submit it.
This test pins the contract structurally: inside ``handle_enter``, any
inline-command early-return that resets the buffer must be followed by an
``event.app.invalidate()`` before its ``return``. It is an *invariant*
(every reset-then-return repaints), not a snapshot of current source.
"""
from __future__ import annotations
import ast
from pathlib import Path
ENTER_HANDLERS = ("_tui_handle_enter", "_tui_enter_inline_command", "_tui_enter_overlay",
"_tui_enter_clarify_freetext", "_tui_enter_clarify_choice")
def _load_handle_enter_node() -> ast.Module:
"""Return a synthetic module holding the Enter handler and its phase helpers."""
cli_path = Path(__file__).resolve().parents[2] / "hermes_cli" / "cli_tui_mixin.py"
tree = ast.parse(cli_path.read_text(encoding="utf-8"))
found = [n for n in ast.walk(tree) if isinstance(n, ast.FunctionDef) and n.name in ENTER_HANDLERS]
assert {n.name for n in found} == set(ENTER_HANDLERS), "Enter handlers not found in cli_tui_mixin.py"
return ast.Module(body=found, type_ignores=[])
def _is_buffer_reset(node: ast.stmt) -> bool:
"""True if the statement is ``...current_buffer.reset(...)``."""
if not isinstance(node, ast.Expr):
return False
call = node.value
if not isinstance(call, ast.Call):
return False
func = call.func
return isinstance(func, ast.Attribute) and func.attr == "reset"
def _is_invalidate(node: ast.stmt) -> bool:
"""True if the statement is ``event.app.invalidate()``."""
if not isinstance(node, ast.Expr):
return False
call = node.value
if not isinstance(call, ast.Call):
return False
func = call.func
return isinstance(func, ast.Attribute) and func.attr == "invalidate"
def _collect_reset_blocks(func: ast.AST) -> list[list[ast.stmt]]:
"""Find every statement sequence (a block body/orelse/finalbody) within
``handle_enter`` that contains a ``buffer.reset()`` call."""
blocks: list[list[ast.stmt]] = []
for node in ast.walk(func):
for attr in ("body", "orelse", "finalbody"):
seq = getattr(node, attr, None)
if not isinstance(seq, list):
continue
if any(isinstance(s, ast.stmt) and _is_buffer_reset(s) for s in seq):
blocks.append(seq)
return blocks
def test_inline_command_reset_branches_invalidate():
"""Every handle_enter branch that resets the buffer and then returns must
invalidate the app first (issue #34569)."""
func = _load_handle_enter_node()
reset_blocks = _collect_reset_blocks(func)
assert reset_blocks, "expected to find buffer.reset() calls in handle_enter"
offenders = []
for seq in reset_blocks:
for i, stmt in enumerate(seq):
if not _is_buffer_reset(stmt):
continue
# Find the next return after this reset in the same block.
ret_idx = None
for j in range(i + 1, len(seq)):
if isinstance(seq[j], ast.Return):
ret_idx = j
break
if ret_idx is None:
# reset not directly followed by a return in this block
# (e.g. the fall-through reset at the end of the handler) —
# the next user input naturally repaints, so skip.
continue
between = seq[i + 1 : ret_idx]
if not any(_is_invalidate(s) for s in between):
offenders.append(ast.dump(stmt))
assert not offenders, (
"handle_enter has reset-then-return branch(es) that never call "
"event.app.invalidate() — the input area can keep showing the "
"submitted text (issue #34569). Offending reset stmts:\n"
+ "\n".join(offenders)
)
if __name__ == "__main__": # pragma: no cover
test_inline_command_reset_branches_invalidate()
print("ok")