mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 07:17:54 +08:00
4bab780603
The four kinds and the words each produces, at the registry level and through the four policy gates: a timeout names its deadline, a decline carries the comment, and neither is reported as the other. The existing denial assertions now expect the specific clause.
509 lines
18 KiB
Python
509 lines
18 KiB
Python
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import sys
|
|
from typing import TYPE_CHECKING
|
|
|
|
import pytest
|
|
|
|
from plyngent.agent import Denial, DenialKind
|
|
from plyngent.cli.display import close_open_pretty_line
|
|
from plyngent.cli.limits import (
|
|
auto_continue_enabled,
|
|
format_tool_confirm_box,
|
|
install_cli_limit_hooks,
|
|
prompt_confirm_tool,
|
|
prompt_continue_limit,
|
|
reset_auto_continue,
|
|
reset_auto_continue_turn,
|
|
set_auto_continue_default,
|
|
)
|
|
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
|
|
from tests.test_prompting import ScriptedBackend
|
|
|
|
if TYPE_CHECKING:
|
|
from collections.abc import Generator, Iterator
|
|
|
|
|
|
@pytest.fixture
|
|
def clean_auto_continue() -> Iterator[None]:
|
|
reset_auto_continue()
|
|
yield
|
|
reset_auto_continue()
|
|
|
|
|
|
def test_prompt_continue_limit_yes() -> None:
|
|
backend = ScriptedBackend(["y"])
|
|
with temporary_backend(backend):
|
|
assert prompt_continue_limit("hit a wall") is True
|
|
|
|
|
|
def test_prompt_continue_limit_default_yes() -> None:
|
|
backend = ScriptedBackend([])
|
|
with temporary_backend(backend):
|
|
assert prompt_continue_limit("hit a wall") is True
|
|
|
|
|
|
def test_prompt_continue_limit_no() -> None:
|
|
backend = ScriptedBackend(["n"])
|
|
with temporary_backend(backend):
|
|
assert prompt_continue_limit("hit a wall") is False
|
|
|
|
|
|
def test_prompt_continue_limit_yyy_sets_turn_skip(clean_auto_continue: None) -> None:
|
|
del clean_auto_continue
|
|
backend = ScriptedBackend(["yyy"])
|
|
with temporary_backend(backend):
|
|
assert prompt_continue_limit("hit a wall") is True
|
|
assert auto_continue_enabled() is True
|
|
# No further prompt: the script is empty and must not be consumed.
|
|
assert prompt_continue_limit("hit a wall again") is True
|
|
|
|
|
|
def test_reset_auto_continue_turn_clears_yyy(clean_auto_continue: None) -> None:
|
|
del clean_auto_continue
|
|
with temporary_backend(ScriptedBackend(["yyy"])):
|
|
assert prompt_continue_limit("limit") is True
|
|
reset_auto_continue_turn()
|
|
assert auto_continue_enabled() is False
|
|
with temporary_backend(ScriptedBackend(["n"])):
|
|
assert prompt_continue_limit("limit") is False
|
|
|
|
|
|
def test_auto_continue_default_short_circuits_prompt(clean_auto_continue: None) -> None:
|
|
del clean_auto_continue
|
|
set_auto_continue_default(enabled=True)
|
|
assert auto_continue_enabled() is True
|
|
with temporary_backend(NonInteractiveBackend()):
|
|
assert prompt_continue_limit("limit") is True
|
|
|
|
|
|
def test_prompt_continue_limit_noninteractive_denies() -> None:
|
|
with temporary_backend(NonInteractiveBackend()):
|
|
assert prompt_continue_limit("limit") is False
|
|
|
|
|
|
def test_setup_hooks_installs_pty_hook_for_auto_continue(clean_auto_continue: None) -> None:
|
|
del clean_auto_continue
|
|
from plyngent.cli.app import _setup_hooks
|
|
from plyngent.config.models import NotifyConfig
|
|
|
|
_setup_hooks(interactive=False, auto_continue=True, notify=NotifyConfig())
|
|
assert PtyManager._limit_continue is not None
|
|
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
|
|
assert PtyManager._limit_continue is None
|
|
|
|
|
|
def test_setup_hooks_installs_the_notifier_only_for_an_interactive_run(clean_auto_continue: None) -> None:
|
|
"""A one-shot run never waits on a human, so it installs no wait hook."""
|
|
del clean_auto_continue
|
|
from plyngent.cli.app import _setup_hooks
|
|
from plyngent.config.models import NotifyConfig
|
|
|
|
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
|
|
assert get_prompt_wait_hook() is None
|
|
_setup_hooks(interactive=True, auto_continue=False, notify=NotifyConfig())
|
|
assert get_prompt_wait_hook() is not None
|
|
_setup_hooks(interactive=False, auto_continue=False, notify=NotifyConfig())
|
|
assert get_prompt_wait_hook() is None
|
|
|
|
|
|
def test_pty_offer_raise_uses_auto_continue(clean_auto_continue: None) -> None:
|
|
del clean_auto_continue
|
|
PtyManager.set_limit_continue_hook(prompt_continue_limit)
|
|
set_auto_continue_default(enabled=True)
|
|
try:
|
|
with temporary_backend(NonInteractiveBackend()):
|
|
assert PtyManager._offer_raise("PTY session limit reached") is True
|
|
finally:
|
|
PtyManager.set_limit_continue_hook(None)
|
|
|
|
|
|
def test_prompt_confirm_tool_default_deny() -> None:
|
|
backend = ScriptedBackend([], confirms=[False])
|
|
with temporary_backend(backend):
|
|
answer = prompt_confirm_tool("delete_path", {"path": "x"}, "delete path 'x'")
|
|
assert answer == Denial(DenialKind.DECLINED)
|
|
|
|
|
|
def test_format_tool_confirm_box_multiline() -> None:
|
|
reason = "run_argv: python3 -c (review code before allow)\nargv:\n python3 -c print(1)\n-c code:\n print(1)"
|
|
box = format_tool_confirm_box("run_argv", reason)
|
|
assert "┌" in box and "└" in box
|
|
assert "confirm · tool 'run_argv'" in box
|
|
assert "python3 -c" in box
|
|
assert "print(1)" in box
|
|
# Distinct lines, not one jammed row
|
|
assert box.count("\n") >= 4
|
|
|
|
|
|
def test_install_cli_limit_hooks() -> None:
|
|
install_cli_limit_hooks()
|
|
assert callable(getattr(PtyManager, "_limit_continue", None))
|
|
# A prompt raised mid-call must end the open pretty tool line first.
|
|
assert get_prompt_output_hook() is close_open_pretty_line
|
|
PtyManager.set_limit_continue_hook(None)
|
|
# Backend remains usable after install.
|
|
assert get_prompt_backend().is_interactive() or True
|
|
|
|
|
|
def test_policy_confirm_noninteractive_denies() -> None:
|
|
from plyngent.cli.limits import prompt_policy_command_confirm
|
|
from plyngent.prompting import NonInteractiveBackend
|
|
|
|
with temporary_backend(NonInteractiveBackend()):
|
|
assert prompt_policy_command_confirm("sudo", ["sudo", "id"], 1.0) == Denial(DenialKind.NO_PROMPT)
|
|
|
|
|
|
def test_directory_access_prompt_noninteractive_denies() -> None:
|
|
from pathlib import Path
|
|
|
|
from plyngent.cli.limits import prompt_directory_access_confirm
|
|
from plyngent.prompting import NonInteractiveBackend
|
|
from plyngent.tools.workspace import AccessMode
|
|
|
|
with temporary_backend(NonInteractiveBackend()):
|
|
assert prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 1.0) == Denial(
|
|
DenialKind.NO_PROMPT
|
|
)
|
|
|
|
|
|
def test_directory_access_prompt_yes_and_level(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
from pathlib import Path
|
|
|
|
from plyngent.cli import limits
|
|
from plyngent.tools.access import AccessDecision
|
|
from plyngent.tools.workspace import AccessMode
|
|
|
|
target = Path("/tmp")
|
|
with temporary_backend(ScriptedBackend([])):
|
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "y\n")
|
|
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
|
|
AccessMode.READ
|
|
)
|
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "exec\n")
|
|
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == AccessDecision(
|
|
AccessMode.EXEC
|
|
)
|
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: "n\n")
|
|
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == Denial(
|
|
DenialKind.DECLINED
|
|
)
|
|
# Silence is its own answer: the deadline is named, not the human.
|
|
monkeypatch.setattr(limits, "_read_yes_no_line_with_timeout", lambda _timeout, **_kwargs: None)
|
|
assert limits.prompt_directory_access_confirm(target, AccessMode.READ, "why", 30.0) == Denial(
|
|
DenialKind.TIMEOUT, seconds=30.0
|
|
)
|
|
|
|
|
|
class _AnswerSource:
|
|
"""A host surface offering answers to a waiting prompt."""
|
|
|
|
supports_actions = True
|
|
|
|
def __init__(self, *answers: str) -> None:
|
|
self._answers = list(answers)
|
|
|
|
def take(self) -> str | None:
|
|
return self._answers.pop(0) if self._answers else None
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _answered(*answers: str) -> Generator[_AnswerSource]:
|
|
"""Install a wait hook whose prompt can be answered by the notification."""
|
|
from plyngent.prompting import set_prompt_wait_hook
|
|
|
|
source = _AnswerSource(*answers)
|
|
previous = get_prompt_wait_hook()
|
|
set_prompt_wait_hook(lambda _wait: contextlib.nullcontext(source))
|
|
try:
|
|
yield source
|
|
finally:
|
|
set_prompt_wait_hook(previous)
|
|
|
|
|
|
def test_directory_access_takes_a_notification_answer() -> None:
|
|
"""``allow`` from the notification approves the requested level."""
|
|
from pathlib import Path
|
|
|
|
from plyngent.cli import limits
|
|
from plyngent.tools.access import AccessDecision
|
|
from plyngent.tools.workspace import AccessMode
|
|
|
|
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
|
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) == AccessDecision(
|
|
AccessMode.READ
|
|
)
|
|
|
|
|
|
def test_directory_access_denied_by_the_notification() -> None:
|
|
from pathlib import Path
|
|
|
|
from plyngent.cli import limits
|
|
from plyngent.tools.workspace import AccessMode
|
|
|
|
with temporary_backend(ScriptedBackend([])), _answered("deny"):
|
|
assert limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0) == Denial(
|
|
DenialKind.DECLINED
|
|
)
|
|
|
|
|
|
def test_tool_confirm_takes_a_notification_answer() -> None:
|
|
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
|
assert prompt_confirm_tool("run_argv", {}, "runs a command") is True
|
|
|
|
|
|
def test_tool_confirm_denied_by_the_notification() -> None:
|
|
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
|
|
assert prompt_confirm_tool("run_argv", {}, "runs a command") == Denial(DenialKind.DECLINED)
|
|
|
|
|
|
@pytest.mark.skipif(sys.platform == "win32", reason="a pty is POSIX-only")
|
|
def test_a_button_replaces_the_terminal_read(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
"""A button pressed in the notification answers the waiting prompt.
|
|
|
|
The read consults stdin *and* the notification while it blocks: the button
|
|
only exists once the notification is shown, which is after the read began.
|
|
"""
|
|
import os
|
|
import pty
|
|
import threading
|
|
|
|
from plyngent.cli.notify import ALLOW, DesktopRequest, WaitNotifier
|
|
from plyngent.config.models import NotifyConfig
|
|
from plyngent.prompting import set_prompt_wait_hook
|
|
|
|
class PressesTheButton:
|
|
"""A desktop surface that answers once the notification is up."""
|
|
|
|
def __init__(self) -> None:
|
|
self.requests: list[DesktopRequest] = []
|
|
|
|
def capabilities(self) -> frozenset[str]:
|
|
return frozenset({"buttons", "reply"})
|
|
|
|
def send(self, request: DesktopRequest) -> str | None:
|
|
self.requests.append(request)
|
|
answer = request.on_answer
|
|
if answer is not None:
|
|
threading.Timer(0.2, answer, args=(ALLOW,)).start()
|
|
return "id-1"
|
|
|
|
def clear(self, identifier: str | None) -> None:
|
|
del identifier
|
|
|
|
desktop = PressesTheButton()
|
|
notifier = WaitNotifier(
|
|
NotifyConfig(grace_seconds=0.0, min_interval_seconds=0.0),
|
|
desktop=desktop,
|
|
write=lambda _sequence: None,
|
|
)
|
|
master, slave = pty.openpty()
|
|
stream = os.fdopen(slave)
|
|
set_prompt_wait_hook(notifier)
|
|
monkeypatch.setattr(sys, "stdin", stream)
|
|
try:
|
|
with temporary_backend(ScriptedBackend([])):
|
|
assert prompt_confirm_tool("run_argv", {}, "runs a command") is True
|
|
finally:
|
|
set_prompt_wait_hook(None)
|
|
stream.close()
|
|
os.close(master)
|
|
assert desktop.requests
|
|
|
|
|
|
def test_batch_confirm_takes_a_notification_answer() -> None:
|
|
from plyngent.agent import ToolConfirmRequest
|
|
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
|
|
|
requests = [
|
|
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
|
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
|
]
|
|
with temporary_backend(ScriptedBackend([])), _answered("allow"):
|
|
assert _prompt_confirm_batch_sync(requests).allowed is True
|
|
|
|
|
|
def test_batch_confirm_denied_by_the_notification() -> None:
|
|
from plyngent.agent import ToolConfirmRequest
|
|
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
|
|
|
requests = [
|
|
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
|
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
|
]
|
|
with temporary_backend(ScriptedBackend([""])), _answered("deny"):
|
|
assert _prompt_confirm_batch_sync(requests).allowed is False
|
|
|
|
|
|
def test_batch_confirm_can_ask_for_one_at_a_time_from_the_notification() -> None:
|
|
"""``m`` is offered as a button, so the manual review stays reachable."""
|
|
from plyngent.agent import ToolConfirmRequest
|
|
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
|
|
|
requests = [
|
|
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
|
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
|
]
|
|
with temporary_backend(ScriptedBackend([])), _answered("m"):
|
|
decision = _prompt_confirm_batch_sync(requests)
|
|
assert decision.allowed is False
|
|
assert decision.each is True
|
|
|
|
|
|
def test_batch_confirm_offers_its_three_answers() -> None:
|
|
from plyngent.agent import ToolConfirmRequest
|
|
from plyngent.cli.limits import _prompt_confirm_batch_sync
|
|
from plyngent.prompting import get_prompt_wait_hook, set_prompt_wait_hook
|
|
|
|
offered: list[tuple[tuple[str, str], ...]] = []
|
|
|
|
@contextlib.contextmanager
|
|
def hook(wait: object) -> Generator[None]:
|
|
offered.append(wait.choices) # type: ignore[attr-defined]
|
|
yield None
|
|
|
|
requests = [ToolConfirmRequest(name="run_argv", args={}, reason="one")]
|
|
previous = get_prompt_wait_hook()
|
|
set_prompt_wait_hook(hook)
|
|
try:
|
|
with temporary_backend(ScriptedBackend(["n"])):
|
|
_ = _prompt_confirm_batch_sync(requests)
|
|
finally:
|
|
set_prompt_wait_hook(previous)
|
|
assert [key for key, _label in offered[0]] == ["y", "m", "n"]
|
|
|
|
|
|
def test_a_limit_prompt_takes_its_answer_from_the_notification() -> None:
|
|
from plyngent.cli import limits
|
|
|
|
with temporary_backend(ScriptedBackend([])), _answered("y"):
|
|
assert limits.prompt_continue_limit("hit a wall") is True
|
|
|
|
|
|
def test_a_limit_prompt_can_stop_asking_from_the_notification(clean_auto_continue: None) -> None:
|
|
"""The ``yyy`` button is the one answer a single ``y`` cannot express."""
|
|
del clean_auto_continue
|
|
from plyngent.cli import limits
|
|
|
|
with temporary_backend(ScriptedBackend([])), _answered("yyy"):
|
|
assert limits.prompt_continue_limit("hit a wall") is True
|
|
assert auto_continue_enabled() is True
|
|
|
|
|
|
def test_a_limit_prompt_can_stop_the_turn_from_the_notification() -> None:
|
|
from plyngent.cli import limits
|
|
|
|
with temporary_backend(ScriptedBackend([])), _answered("n"):
|
|
assert limits.prompt_continue_limit("hit a wall") is False
|
|
|
|
|
|
def test_a_limit_prompt_still_names_its_answers_at_the_terminal(capsys: pytest.CaptureFixture[str]) -> None:
|
|
"""Buttons elsewhere do not say what typing here would do."""
|
|
from plyngent.cli import limits
|
|
|
|
with temporary_backend(ScriptedBackend([])), _answered("y"):
|
|
assert limits.prompt_continue_limit("hit a wall") is True
|
|
assert "[Y/n/yyy]" in capsys.readouterr().err
|
|
|
|
|
|
def test_a_skill_write_can_be_allowed_for_the_chat_from_the_notification() -> None:
|
|
"""``s`` (this chat) is a button, not only a terminal token."""
|
|
from pathlib import Path
|
|
|
|
from plyngent.cli import limits
|
|
|
|
with temporary_backend(ScriptedBackend([])), _answered("s"):
|
|
decision = limits.prompt_skill_write_confirm(Path("/tmp/skill"), "write", "one line", 30.0)
|
|
assert decision.allow is True
|
|
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
|
|
|
|
from plyngent.agent import ToolConfirmRequest
|
|
from plyngent.cli import limits
|
|
from plyngent.tools.workspace import AccessMode
|
|
|
|
kinds: list[str] = []
|
|
|
|
@contextlib.contextmanager
|
|
def hook(wait: object) -> Generator[None]:
|
|
kinds.append(wait.kind) # type: ignore[attr-defined]
|
|
yield None
|
|
|
|
from plyngent.prompting import set_prompt_wait_hook
|
|
|
|
previous = get_prompt_wait_hook()
|
|
set_prompt_wait_hook(hook)
|
|
try:
|
|
with temporary_backend(ScriptedBackend(["y", "y", "y"], confirms=[True])):
|
|
_ = prompt_confirm_tool("run_argv", {}, "reason")
|
|
_ = limits.prompt_directory_access_confirm(Path("/tmp"), AccessMode.READ, "why", 30.0)
|
|
_ = limits._prompt_confirm_batch_sync(
|
|
[
|
|
ToolConfirmRequest(name="run_argv", args={}, reason="one"),
|
|
ToolConfirmRequest(name="run_argv", args={}, reason="two"),
|
|
]
|
|
)
|
|
_ = prompt_continue_limit("limit")
|
|
finally:
|
|
set_prompt_wait_hook(previous)
|
|
assert kinds == ["confirm", "access", "batch", "limit"]
|