mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
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:
@@ -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
|
||||
|
||||
@@ -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
@@ -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] = []
|
||||
|
||||
Reference in New Issue
Block a user