Files
hermes-agent/tests/cli/test_cli_clarify_batch.py
ethernet 100380bbf5 feat(cli): batch clarify back-navigation and answer visibility
Shift-Tab walks backwards through the questions, with wrap, the same
way Tab walks forward. A locked answer now renders on its own indented
line in a distinct color under its question, instead of an arrow
suffix on the status line, so the current answers stay readable while
the cursor moves.

A re-visited question restores its earlier state: a choice answer puts
the cursor back on that choice, a typed answer highlights the Other
row and shows the typed text next to it. Enter on an answered Other
switches to freetext with the composer prefilled with the earlier
text, so the user edits instead of retyping. Answer metadata records
how each answer was produced to drive the restore.
2026-08-18 21:28:53 -04:00

344 lines
12 KiB
Python

"""Batch (multi-question) clarify panel state machine — CLI side.
Drives ``_clarify_callback`` with a ``questions`` list on a background
thread (the way the agent thread calls it) and simulates the keybinding
handlers by calling the same helper methods they call
(``_clarify_batch_set_active`` for Tab, ``_clarify_batch_enter`` for
Enter, ``_clarify_batch_lock`` for the freetext submit path). No real
terminal needed — mirrors tests/cli/test_cli_approval_ui.py.
"""
import json
import threading
import time
from unittest.mock import MagicMock, patch
from cli import HermesCLI
def _make_cli_stub():
cli = HermesCLI.__new__(HermesCLI)
cli._clarify_state = None
cli._clarify_freetext = False
cli._clarify_multi_base = None
cli._clarify_prefill = ""
cli._clarify_deadline = None
cli._paint_now = MagicMock()
cli._persist_prompt_summary = MagicMock()
return cli
def _q(index, question, choices=None, multi_select=False):
"""One normalized batch entry, shaped like _normalize_questions output."""
return {
"qid": f"q{index}",
"id": None,
"question": question,
"choices": list(choices) if choices else None,
"choices_offered": list(choices) if choices else None,
"multi_select": bool(multi_select) and bool(choices),
}
def _start_batch(cli, questions):
"""Run the batch callback on a thread; wait for the panel state."""
result = {}
def _run():
result["value"] = cli._clarify_callback("", None, questions=questions)
thread = threading.Thread(target=_run, daemon=True)
thread.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._clarify_state is not None
return thread, result
class TestClarifyBatchPanel:
def test_all_locked_returns_answers_dict_keyed_by_qid(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
assert state["active"] == 0
assert state["choices"] == ["red", "blue"]
# Enter locks the active question's highlighted choice, then the
# cursor advances to the next unanswered question.
cli._clarify_batch_enter(state)
assert state["answers"] == {"q0": "red"}
assert state["active"] == 1
state["selected"] = 1
cli._clarify_batch_enter(state)
thread.join(timeout=2)
assert result["value"] == {"answers": {"q0": "red", "q1": "large"}}
assert cli._clarify_state is None
def test_any_order_answering_via_tab_cycle(self):
cli = _make_cli_stub()
questions = [
_q(0, "First?", ["a", "b"]),
_q(1, "Second?", ["c", "d"]),
_q(2, "Third?", ["e", "f"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Tab twice: q0 -> q1 -> q2 (what the tab keybinding does).
cli._clarify_batch_set_active(state, (state["active"] + 1) % 3)
cli._clarify_batch_set_active(state, (state["active"] + 1) % 3)
assert state["active"] == 2
cli._clarify_batch_enter(state) # lock q2 = "e"
# Advance wraps to the next unanswered question (q0).
assert state["active"] == 0
state["selected"] = 1
cli._clarify_batch_enter(state) # lock q0 = "b"
assert state["active"] == 1
cli._clarify_batch_enter(state) # lock q1 = "c"
thread.join(timeout=2)
assert result["value"] == {
"answers": {"q0": "b", "q1": "c", "q2": "e"}
}
def test_reanswer_overwrites_before_completion(self):
cli = _make_cli_stub()
questions = [
_q(0, "Approach?", ["quick", "thorough"]),
_q(1, "Scope?", ["narrow", "wide"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
cli._clarify_batch_enter(state) # lock q0 = "quick"
assert state["answers"]["q0"] == "quick"
assert state["active"] == 1
# Tab back to the answered question and change the answer.
cli._clarify_batch_set_active(state, 0)
state["selected"] = 1
cli._clarify_batch_enter(state) # overwrite q0 = "thorough"
assert state["answers"]["q0"] == "thorough"
# Advance lands on the still-unanswered q1.
assert state["active"] == 1
cli._clarify_batch_enter(state) # lock q1 = "narrow"
thread.join(timeout=2)
assert result["value"] == {
"answers": {"q0": "thorough", "q1": "narrow"}
}
def test_timeout_returns_partials_with_timed_out_flag(self):
cli = _make_cli_stub()
questions = [
_q(0, "Answered?", ["yes", "no"]),
_q(1, "Never answered?", ["x", "y"]),
]
with patch(
"tools.clarify_gateway.resolve_clarify_timeout", return_value=1
):
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
cli._clarify_batch_enter(state) # lock q0 only
thread.join(timeout=5)
assert not thread.is_alive()
assert result["value"] == {"answers": {"q0": "yes"}, "timed_out": True}
assert cli._clarify_state is None
def test_multi_select_lock_produces_json_array_string(self):
cli = _make_cli_stub()
questions = [
_q(0, "Toppings?", ["ham", "olives", "basil"], multi_select=True),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
assert state["multi_select"] is True
state["selected_indices"].update({0, 2})
cli._clarify_batch_enter(state)
thread.join(timeout=2)
answer = result["value"]["answers"]["q0"]
assert isinstance(answer, str)
assert json.loads(answer) == ["ham", "basil"]
def test_open_ended_question_locks_typed_answer(self):
cli = _make_cli_stub()
questions = [
_q(0, "Anything else?"),
_q(1, "Pick one", ["a", "b"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Open-ended active question drops straight into freetext.
assert cli._clarify_freetext is True
# The Enter freetext submit path locks the typed text.
cli._clarify_freetext = False
cli._clarify_batch_lock(state, "custom words")
assert state["active"] == 1
cli._clarify_batch_enter(state)
thread.join(timeout=2)
assert result["value"] == {
"answers": {"q0": "custom words", "q1": "a"}
}
def test_locked_question_persists_scrollback_summary(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
cli._clarify_batch_enter(state)
cli._clarify_batch_enter(state)
thread.join(timeout=2)
calls = cli._persist_prompt_summary.call_args_list
assert len(calls) == 2
assert calls[0].args == ("?", "Clarify", "Color?", "red")
assert calls[1].args == ("?", "Clarify", "Size?", "small")
def test_single_question_path_returns_plain_string(self):
cli = _make_cli_stub()
result = {}
def _run():
result["value"] = cli._clarify_callback("Pick?", ["a", "b"])
thread = threading.Thread(target=_run, daemon=True)
thread.start()
deadline = time.time() + 2
while cli._clarify_state is None and time.time() < deadline:
time.sleep(0.01)
assert cli._clarify_state is not None
assert "questions" not in cli._clarify_state
cli._clarify_state["response_queue"].put("a")
thread.join(timeout=2)
assert result["value"] == "a"
class TestClarifyBatchNavigation:
"""Shift-Tab, answer restore on re-visit, and Other edit-prefill."""
def test_backward_navigation_wraps(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
_q(2, "Speed?", ["slow", "fast"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Shift-Tab from question 0 wraps to the last question.
cli._clarify_batch_set_active(state, (state["active"] - 1) % 3)
assert state["active"] == 2
cli._clarify_batch_set_active(state, (state["active"] - 1) % 3)
assert state["active"] == 1
state["response_queue"].put("cancel")
thread.join(timeout=2)
def test_revisit_choice_answer_restores_cursor(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Lock "blue" (index 1) on q0; the cursor advances to q1.
state["selected"] = 1
cli._clarify_batch_enter(state)
assert state["active"] == 1
# Tab back to q0: the cursor sits on the earlier answer, not row 0.
cli._clarify_batch_set_active(state, 0)
assert state["selected"] == 1
state["response_queue"].put("cancel")
thread.join(timeout=2)
def test_revisit_other_answer_highlights_other_and_prefills_edit(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# Answer q0 via Other: select the Other row, then the freetext
# submit path locks the typed text with its meta.
state["selected"] = 2
cli._clarify_batch_enter(state)
assert cli._clarify_freetext is True
cli._clarify_freetext = False
cli._clarify_batch_lock(
state, "chartreuse", meta={"kind": "other", "other_text": "chartreuse"}
)
assert state["active"] == 1
# Tab back to q0: the cursor highlights the Other row.
cli._clarify_batch_set_active(state, 0)
assert state["selected"] == 2
# Enter on the answered Other switches to freetext and prefills the
# earlier text for editing.
cli._clarify_batch_enter(state)
assert cli._clarify_freetext is True
assert cli._clarify_prefill == "chartreuse"
state["response_queue"].put("cancel")
thread.join(timeout=2)
def test_reanswer_overwrites_and_updates_meta(self):
cli = _make_cli_stub()
questions = [
_q(0, "Color?", ["red", "blue"]),
_q(1, "Size?", ["small", "large"]),
]
thread, result = _start_batch(cli, questions)
state = cli._clarify_state
# First answer via Other.
cli._clarify_batch_lock(
state, "teal", meta={"kind": "other", "other_text": "teal"}
)
# Re-visit and overwrite with a plain choice.
cli._clarify_batch_set_active(state, 0)
assert state["selected"] == 2
state["selected"] = 0
cli._clarify_batch_enter(state)
assert state["answers"]["q0"] == "red"
assert state["answer_meta"]["q0"] == {"kind": "choice"}
# Finish q1 so the batch resolves with the overwritten answer.
cli._clarify_batch_set_active(state, 1)
cli._clarify_batch_enter(state)
thread.join(timeout=2)
assert result["value"] == {"answers": {"q0": "red", "q1": "small"}}