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:
2026-09-22 19:28:03 +08:00
parent 6ec7b4de12
commit 27bd7b46f7
5 changed files with 204 additions and 17 deletions
+38 -1
View File
@@ -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