test: cover the body a prompt notification carries

What a prompt declares (question, object, deadline), how the two channels
render it (lines apart on the desktop, one line in a terminal), that a long
reason goes before the deadline does, that a question keeps its reply field
*and* the way back to the terminal, that a body click leaves the prompt
waiting, that a reply spelling "review" is still an answer, and that a secret
is never announced.
This commit is contained in:
2026-10-24 04:22:00 +08:00
parent 04d34b34f3
commit f511c08afb
3 changed files with 179 additions and 4 deletions
+47
View File
@@ -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
+87 -3
View File
@@ -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()
+45 -1
View File
@@ -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] = []