diff --git a/tests/test_cli/test_limits.py b/tests/test_cli/test_limits.py index be5b7f7..ac9c9dd 100644 --- a/tests/test_cli/test_limits.py +++ b/tests/test_cli/test_limits.py @@ -19,9 +19,11 @@ from plyngent.cli.limits import ( ) from plyngent.prompting import ( NonInteractiveBackend, + PromptWait, get_prompt_backend, get_prompt_output_hook, get_prompt_wait_hook, + set_prompt_wait_hook, temporary_backend, ) from plyngent.tools.process.pty_session import PtyManager @@ -406,6 +408,51 @@ def test_a_skill_write_can_be_allowed_for_the_chat_from_the_notification() -> No assert decision.session is True +def test_a_tool_confirm_names_the_object_it_is_about() -> None: + """A notification body carries the program or file, never the argument.""" + seen: list[PromptWait] = [] + + @contextlib.contextmanager + def hook(wait: PromptWait) -> Generator[None]: + seen.append(wait) + yield None + + previous = get_prompt_wait_hook() + set_prompt_wait_hook(hook) + try: + with temporary_backend(ScriptedBackend([], confirms=[True, True])): + _ = prompt_confirm_tool("run_argv", {"argv": ["/usr/bin/rm", "-rf", "build"]}, "command 'rm'") + _ = prompt_confirm_tool("write_file", {"path": "notes/todo.md", "content": "x"}, "overwrite") + finally: + set_prompt_wait_hook(previous) + assert [wait.facts for wait in seen] == [("program: rm",), ("target: todo.md",)] + + +def test_a_timed_prompt_says_when_silence_answers() -> None: + """Timeout is a deny, so the notification must not hide the clock.""" + from pathlib import Path + + from plyngent.cli import limits + from plyngent.tools.workspace import AccessMode + + seen: list[PromptWait] = [] + + @contextlib.contextmanager + def hook(wait: PromptWait) -> Generator[None]: + seen.append(wait) + yield None + + previous = get_prompt_wait_hook() + set_prompt_wait_hook(hook) + try: + with temporary_backend(ScriptedBackend(["y"], confirms=[True])): + _ = limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) + finally: + set_prompt_wait_hook(previous) + assert seen[0].expires_in == 30.0 + assert seen[0].facts == ("kind: directory",) + + def test_every_confirm_announces_itself() -> None: """Each blocking confirm reports its kind, so a host can pick a surface.""" from pathlib import Path diff --git a/tests/test_cli/test_notify.py b/tests/test_cli/test_notify.py index 20730bf..1e81162 100644 --- a/tests/test_cli/test_notify.py +++ b/tests/test_cli/test_notify.py @@ -294,14 +294,85 @@ def test_no_answer_is_expected_without_the_capability() -> None: assert handle.supports_actions is False -def test_an_ask_offers_a_reply_field_instead_of_buttons() -> None: +def test_a_question_offers_a_reply_field_without_the_answers() -> None: desktop = _FakeDesktop() notifier, _ = _notifier(desktop=desktop) - with notifier(PromptWait("ask", "Which branch?")) as handle: + with notifier(PromptWait("ask", "a question is waiting", body="Which branch?")) as handle: handle._notify() request = desktop.requests[0] assert request.reply_field is True - assert request.buttons == () + assert request.buttons == ((REVIEW, "Go to terminal"),) + + +def test_a_question_carries_the_question_in_its_body() -> None: + """A question *is* what is asked, so the title cannot be the whole story.""" + desktop = _FakeDesktop() + notifier, _ = _notifier(desktop=desktop) + question = "Which branch should the release be cut from?" + with notifier(PromptWait("ask", "a question is waiting", body=question)) as handle: + handle._notify() + assert desktop.requests[0].message == question + + +def test_the_body_names_the_object_and_when_not_answering_answers() -> None: + desktop = _FakeDesktop() + notifier, _ = _notifier(desktop=desktop) + wait = PromptWait("policy", "allow command 'rm'?", facts=("program: rm",), expires_in=30.0) + with notifier(wait) as handle: + handle._notify() + # A popup wraps, so the desktop body keeps its lines apart. + assert desktop.requests[0].message.split("\n") == ["program: rm", "no answer in 30s = deny"] + + +def test_the_terminal_body_stays_on_one_line() -> None: + notifier, _ = _notifier() + wait = PromptWait("confirm", "Allow?", facts=("program: rm",), expires_in=30.0) + assert notifier._message(wait) == "program: rm no answer in 30s = deny" + + +def test_a_long_reason_goes_before_the_deadline_does() -> None: + """The deadline is short and is the one line a human must not miss.""" + notifier, _ = _notifier(include_reason=True) + wait = PromptWait("confirm", "Allow?", "x" * 400, facts=("program: rm",), expires_in=30.0) + message = notifier._message(wait) + assert message.endswith("no answer in 30s = deny") + assert len(message) <= 200 + + +def test_a_click_on_the_notification_leaves_the_prompt_waiting() -> None: + """The body click is the default action: back to the terminal.""" + desktop = _FakeDesktop() + notifier, _ = _notifier(desktop=desktop) + with notifier(PromptWait("confirm", "Allow?")) as handle: + handle._notify() + request = desktop.requests[0] + assert request.on_click is not None + request.on_click() + assert handle.take() is None + assert desktop.cleared == ["id-1"] + + +def test_a_reply_spelling_review_reaches_the_prompt() -> None: + """Typed text is an answer even when it reads like a button.""" + desktop = _FakeDesktop() + notifier, _ = _notifier(desktop=desktop) + with notifier(PromptWait("ask", "a question is waiting", body="What now?")) as handle: + handle._notify() + request = desktop.requests[0] + assert request.on_reply is not None + request.on_reply(REVIEW) + assert handle.take() == REVIEW + + +def test_a_secret_prompt_is_never_announced() -> None: + """The value would have to come back through a surface that outlives it.""" + desktop = _FakeDesktop() + notifier, written = _notifier(desktop=desktop, grace_seconds=0.0) + with notifier(PromptWait("secret", "API key?")) as handle: + assert notifier.may_notify(handle) is False + handle._notify() + assert desktop.requests == [] + assert written == [] def test_a_limit_prompt_can_be_answered() -> None: @@ -469,6 +540,19 @@ def test_a_desktop_notification_is_kept_on_screen() -> None: assert sent["buttons"] and sent["reply_field"] is not None +@pytest.mark.usefixtures("_stub_extra") +def test_the_desktop_call_carries_the_click_handler() -> None: + """Clicking the body is the default action, so it must reach the prompt.""" + channel = DesktopNotifierChannel() + clicked: list[str] = [] + + def _click() -> None: + clicked.append("click") + + _ = channel.send(DesktopRequest(title="Allow?", message="", on_click=_click)) + assert _RecordingNotifier.sent[-1]["on_clicked"] is _click + + def test_a_prompt_offers_its_own_answers() -> None: """The prompt's ``choices`` become the buttons, plus a way to the terminal.""" desktop = _FakeDesktop() diff --git a/tests/test_prompting.py b/tests/test_prompting.py index b53e07a..cb47aa4 100644 --- a/tests/test_prompting.py +++ b/tests/test_prompting.py @@ -356,6 +356,34 @@ def test_prompt_wait_without_hook_yields_none() -> None: assert source is None +def test_prompt_wait_carries_what_the_caller_declares() -> None: + """Everything a host needs to say what is asked and when silence answers.""" + seen: list[PromptWait] = [] + + @contextlib.contextmanager + def hook(wait: PromptWait) -> Generator[None]: + seen.append(wait) + yield None + + set_prompt_wait_hook(hook) + choices = (("y", "Continue"), ("n", "Stop")) + with prompt_wait( + "policy", + "allow command 'rm'?", + "argv: rm -rf x", + choices, + body="", + facts=("program: rm",), + expires_in=30.0, + ): + pass + assert seen[0].body == "" + assert seen[0].detail == "argv: rm -rf x" + assert seen[0].choices == choices + assert seen[0].facts == ("program: rm",) + assert seen[0].expires_in == 30.0 + + def test_prompt_wait_hook_wraps_ask() -> None: """The CLI hook sees one PromptWait per prompt and is exited afterwards.""" seen: list[PromptWait] = [] @@ -374,10 +402,26 @@ def test_prompt_wait_hook_wraps_ask() -> None: backend = ScriptedBackend(["hi"]) with temporary_backend(backend): assert ask("Name?") == "hi" - assert [(wait.kind, wait.title) for wait in seen] == [("ask", "Name?")] + assert [(wait.kind, wait.title, wait.body) for wait in seen] == [("ask", "a question is waiting", "Name?")] assert entered == ["in", "out"] +def test_a_choice_prompt_names_its_question_and_options() -> None: + """The prompt a host shows carries the question and what can be picked.""" + seen: list[PromptWait] = [] + + @contextlib.contextmanager + def hook(wait: PromptWait) -> Generator[None]: + seen.append(wait) + yield None + + set_prompt_wait_hook(hook) + with temporary_backend(ScriptedBackend(["1"])): + assert choose("Pick one", ["main", "release", "next", "other", "fifth"]) == "main" + assert seen[0].body == "Pick one" + assert seen[0].facts == ("main", "release", "next", "other") + + def test_prompt_wait_hook_reports_the_prompt_family() -> None: """Each entry point announces its own kind so a host can pick a surface.""" kinds: list[str] = []