mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +08:00
prompting: let Ctrl+C cancel poll-based prompt reads
The wait tool's prompt runs off-loop on a worker thread, but its read polls select/msvcrt, so it can act on a cancel: the SIGINT target now sets a flag the reader checks between polls and raises PromptCancelledError with, which the tool reports as "cancelled by user" while the turn continues. run_prompt_async / run_cancellable_prompt_async hand the matching hook to the CLI guard; a stale cancel never leaks into the next prompt read.
This commit is contained in:
@@ -21,7 +21,13 @@ from plyngent.cli.interrupt import (
|
||||
)
|
||||
from plyngent.cli.limits import prompt_continue_limit
|
||||
from plyngent.cli.retry import run_cancellable, sleep_cancellable
|
||||
from plyngent.prompting import temporary_backend
|
||||
from plyngent.prompting import (
|
||||
PromptCancelledError,
|
||||
configure_prompting,
|
||||
prompt_cancelled,
|
||||
run_cancellable_prompt_async,
|
||||
temporary_backend,
|
||||
)
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -224,6 +230,37 @@ async def test_off_loop_prompt_cancel_hook_is_preferred(router: None) -> None:
|
||||
assert turn_calls == []
|
||||
|
||||
|
||||
async def test_sigint_cancels_off_loop_prompt_read(router: None) -> None:
|
||||
"""Wiring: SIGINT aborts a poll-based read (not the turn) and clears the flag."""
|
||||
del router
|
||||
turn_calls: list[str] = []
|
||||
seen: list[str] = []
|
||||
|
||||
def _poll_read(prompt: str, timeout: float) -> str:
|
||||
"""Stand-in for ``read_line_with_timeout``: polls the cancel flag."""
|
||||
del prompt
|
||||
deadline = time.monotonic() + timeout
|
||||
while time.monotonic() < deadline:
|
||||
if prompt_cancelled():
|
||||
seen.append("cancelled")
|
||||
raise PromptCancelledError
|
||||
time.sleep(0.01)
|
||||
seen.append("timed out")
|
||||
return ""
|
||||
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
configure_prompting(pause_factory=off_loop_prompt)
|
||||
with sigint_cancels(FakeTarget(turn_calls, "turn")):
|
||||
reader = asyncio.create_task(run_cancellable_prompt_async(_poll_read, "p> ", 30.0))
|
||||
await asyncio.sleep(0.05)
|
||||
os.kill(os.getpid(), signal.SIGINT)
|
||||
with pytest.raises(PromptCancelledError):
|
||||
await asyncio.wait_for(reader, timeout=5)
|
||||
assert seen == ["cancelled"]
|
||||
assert turn_calls == []
|
||||
assert not prompt_cancelled() # cleared for the next prompt
|
||||
|
||||
|
||||
async def test_pause_task_cancel_for_prompt_swaps_sigint(router: None) -> None:
|
||||
"""A main-thread blocking read takes SIGINT as a KeyboardInterrupt."""
|
||||
del router
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import io
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
@@ -9,15 +11,33 @@ from plyngent.prompting import (
|
||||
FormField,
|
||||
NonInteractiveBackend,
|
||||
NonInteractiveError,
|
||||
PromptCancelledError,
|
||||
_read_line_with_timeout_posix,
|
||||
ask,
|
||||
cancel_prompt_read,
|
||||
choose,
|
||||
configure_prompting,
|
||||
confirm,
|
||||
form,
|
||||
prompt_cancelled,
|
||||
read_line_with_timeout,
|
||||
reset_prompting,
|
||||
run_cancellable_prompt_async,
|
||||
run_prompt_async,
|
||||
temporary_backend,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Generator, Iterator
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def reset_prompting_state() -> Iterator[None]:
|
||||
"""Prompting state (backend, SIGINT guard, cancel flag) is process-global."""
|
||||
reset_prompting()
|
||||
yield
|
||||
reset_prompting()
|
||||
|
||||
|
||||
def _ready_poll(r, w, x, t):
|
||||
return (r, w, x)
|
||||
@@ -51,6 +71,43 @@ def test_read_line_with_timeout_negative_timeout() -> None:
|
||||
assert read_line_with_timeout("p> ", -1) is None
|
||||
|
||||
|
||||
def test_read_line_with_timeout_cancelled() -> None:
|
||||
"""A cancel request (SIGINT during an off-loop prompt) ends the read."""
|
||||
stream = io.StringIO()
|
||||
cancel_prompt_read()
|
||||
try:
|
||||
assert prompt_cancelled()
|
||||
with pytest.raises(PromptCancelledError):
|
||||
_read_line_with_timeout_posix("p> ", 5.0, stream=stream, poll=_never_poll)
|
||||
finally:
|
||||
stream.close()
|
||||
|
||||
|
||||
async def test_run_prompt_async_clears_cancel_flag() -> None:
|
||||
"""A stale cancel request must not leak into the next prompt read."""
|
||||
cancel_prompt_read()
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
assert await run_prompt_async(lambda: "ok") == "ok"
|
||||
assert not prompt_cancelled()
|
||||
|
||||
|
||||
async def test_run_prompt_async_passes_cancel_hook_only_when_cancellable() -> None:
|
||||
"""The CLI guard gets the read's cancel hook for poll-based reads only."""
|
||||
seen: list[object] = []
|
||||
|
||||
@contextlib.contextmanager
|
||||
def _guard(cancel: object) -> Generator[None]:
|
||||
seen.append(cancel)
|
||||
yield
|
||||
|
||||
with temporary_backend(NonInteractiveBackend()):
|
||||
# temporary_backend resets the guard, so install it inside.
|
||||
configure_prompting(pause_factory=_guard)
|
||||
assert await run_cancellable_prompt_async(lambda: "a") == "a"
|
||||
assert await run_prompt_async(lambda: "b") == "b"
|
||||
assert seen == [cancel_prompt_read, None]
|
||||
|
||||
|
||||
class ScriptedBackend:
|
||||
"""Deterministic backend for unit tests."""
|
||||
|
||||
|
||||
@@ -3,8 +3,10 @@ from __future__ import annotations
|
||||
import importlib
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from plyngent.agent import ToolRegistry
|
||||
from plyngent.prompting import NonInteractiveBackend, temporary_backend
|
||||
from plyngent.prompting import NonInteractiveBackend, PromptCancelledError, temporary_backend
|
||||
from plyngent.tools.chat import CHAT_TOOLS, ask_user, choose_user, form_user, wait
|
||||
from tests.test_prompting import ScriptedBackend
|
||||
|
||||
@@ -146,6 +148,20 @@ async def test_wait_tool_times_out(monkeypatch) -> None:
|
||||
assert out == "waited 3s"
|
||||
|
||||
|
||||
async def test_wait_tool_cancelled_by_user(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||
"""Ctrl+C during the wait prompt cancels the wait; the turn continues."""
|
||||
|
||||
def _cancelled(prompt: str, timeout: float) -> str:
|
||||
del prompt, timeout
|
||||
raise PromptCancelledError
|
||||
|
||||
monkeypatch.setattr(wait_module, "read_line_with_timeout", _cancelled)
|
||||
with temporary_backend(ScriptedBackend([])):
|
||||
registry = ToolRegistry([wait])
|
||||
out = await registry.execute("wait", '{"duration": 30}')
|
||||
assert out == "cancelled by user"
|
||||
|
||||
|
||||
async def test_wait_tool_prompt_two_lines_with_reason(monkeypatch) -> None:
|
||||
prompts: list[str] = []
|
||||
monkeypatch.setattr(
|
||||
|
||||
Reference in New Issue
Block a user