mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 23:47:54 +08:00
cli: keep REPL alive on stray Ctrl+C outside turn calls
After a turn the run_cancellable SIGINT handler is removed, so a Ctrl+C landing between turns (echoing user text, expire_yolo_once, gaps between statements, or re-entering _echo_interrupted) raised KeyboardInterrupt at an arbitrary bytecode and escaped run_repl, killing the whole REPL. Wrap the loop body in one try/except KeyboardInterrupt that re-prompts, and make _echo_interrupted itself suppress a further interrupt.
This commit is contained in:
+33
-30
@@ -1,5 +1,6 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import contextlib
|
||||||
from typing import TYPE_CHECKING
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
import click
|
import click
|
||||||
@@ -23,9 +24,13 @@ def _echo_user(text: str) -> None:
|
|||||||
|
|
||||||
|
|
||||||
def _echo_interrupted() -> None:
|
def _echo_interrupted() -> None:
|
||||||
click.echo()
|
# A further Ctrl+C during this output must stay benign (same rule as
|
||||||
click.secho("interrupted", fg="yellow")
|
# retry._echo_cancel_lines): after a turn the SIGINT handler is removed, so
|
||||||
click.echo()
|
# the next Ctrl+C raises KeyboardInterrupt at an arbitrary bytecode.
|
||||||
|
with contextlib.suppress(KeyboardInterrupt):
|
||||||
|
click.echo()
|
||||||
|
click.secho("interrupted", fg="yellow")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
async def run_repl(state: ReplState) -> None:
|
async def run_repl(state: ReplState) -> None:
|
||||||
@@ -49,33 +54,31 @@ async def run_repl(state: ReplState) -> None:
|
|||||||
click.echo()
|
click.echo()
|
||||||
break
|
break
|
||||||
|
|
||||||
if entry is None:
|
# Between turns the turn-task SIGINT handler is removed (run_cancellable),
|
||||||
continue
|
# so a stray Ctrl+C raises KeyboardInterrupt at whatever bytecode is
|
||||||
|
# executing — echoing user text, expire_yolo_once, gaps between
|
||||||
if entry.startswith("/"):
|
# statements. Swallow it and re-prompt instead of exiting the REPL.
|
||||||
try:
|
|
||||||
cont = await handle_slash(state, entry)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
_echo_interrupted()
|
|
||||||
continue
|
|
||||||
if not cont:
|
|
||||||
break
|
|
||||||
if state.pending_user_text is not None:
|
|
||||||
text = state.pending_user_text
|
|
||||||
state.pending_user_text = None
|
|
||||||
_echo_user(text)
|
|
||||||
try:
|
|
||||||
_ = await run_user_text_with_retries(state.agent, text)
|
|
||||||
except KeyboardInterrupt:
|
|
||||||
_echo_interrupted()
|
|
||||||
finally:
|
|
||||||
state.expire_yolo_once()
|
|
||||||
continue
|
|
||||||
|
|
||||||
_echo_user(entry)
|
|
||||||
try:
|
try:
|
||||||
_ = await run_user_text_with_retries(state.agent, entry)
|
if entry is None:
|
||||||
|
continue
|
||||||
|
if entry.startswith("/"):
|
||||||
|
cont = await handle_slash(state, entry)
|
||||||
|
if not cont:
|
||||||
|
break
|
||||||
|
if state.pending_user_text is not None:
|
||||||
|
text = state.pending_user_text
|
||||||
|
state.pending_user_text = None
|
||||||
|
_echo_user(text)
|
||||||
|
try:
|
||||||
|
_ = await run_user_text_with_retries(state.agent, text)
|
||||||
|
finally:
|
||||||
|
state.expire_yolo_once()
|
||||||
|
continue
|
||||||
|
|
||||||
|
_echo_user(entry)
|
||||||
|
try:
|
||||||
|
_ = await run_user_text_with_retries(state.agent, entry)
|
||||||
|
finally:
|
||||||
|
state.expire_yolo_once()
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
_echo_interrupted()
|
_echo_interrupted()
|
||||||
finally:
|
|
||||||
state.expire_yolo_once()
|
|
||||||
|
|||||||
@@ -593,3 +593,43 @@ async def test_repl_ctrl_c_from_turn_returns_to_prompt(
|
|||||||
|
|
||||||
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
|
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
|
||||||
assert calls["reads"] == 2
|
assert calls["reads"] == 2
|
||||||
|
|
||||||
|
|
||||||
|
async def test_repl_second_ctrl_c_after_cancel_returns_to_prompt(
|
||||||
|
state: ReplState,
|
||||||
|
monkeypatch: pytest.MonkeyPatch,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
"""A second Ctrl+C landing outside the turn call (e.g. while echoing the
|
||||||
|
next user input, right after a cancel) must return to the prompt, not exit."""
|
||||||
|
from plyngent.cli import repl as repl_mod
|
||||||
|
from plyngent.cli.repl import run_repl
|
||||||
|
|
||||||
|
reads = ["first", "second", "third"]
|
||||||
|
calls: dict[str, int] = {"reads": 0, "echoes": 0}
|
||||||
|
|
||||||
|
def fake_read_entry() -> str:
|
||||||
|
calls["reads"] += 1
|
||||||
|
if calls["reads"] > len(reads):
|
||||||
|
raise EOFError
|
||||||
|
return reads[calls["reads"] - 1]
|
||||||
|
|
||||||
|
async def cancel_turn(_agent: object, _text: str) -> bool:
|
||||||
|
raise KeyboardInterrupt # first Ctrl+C: cancels every turn
|
||||||
|
|
||||||
|
def echo_user(text: str) -> None:
|
||||||
|
calls["echoes"] += 1
|
||||||
|
if calls["echoes"] == 2:
|
||||||
|
# second Ctrl+C lands while echoing user text — outside the turn
|
||||||
|
# call, previously unguarded (used to exit the whole REPL).
|
||||||
|
raise KeyboardInterrupt
|
||||||
|
|
||||||
|
monkeypatch.setattr(repl_mod, "setup_readline", lambda _state: None)
|
||||||
|
monkeypatch.setattr(repl_mod, "read_repl_entry", fake_read_entry)
|
||||||
|
monkeypatch.setattr(repl_mod, "run_user_text_with_retries", cancel_turn)
|
||||||
|
monkeypatch.setattr(repl_mod, "_echo_user", echo_user)
|
||||||
|
|
||||||
|
await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt
|
||||||
|
assert calls["reads"] == 4 # three entries + EOF
|
||||||
|
assert calls["echoes"] == 3 # the interrupting echo still counted, loop survived
|
||||||
|
assert "interrupted" in capsys.readouterr().out
|
||||||
|
|||||||
Reference in New Issue
Block a user