mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-07 15:37:55 +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
|
||||
|
||||
import contextlib
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
@@ -23,9 +24,13 @@ def _echo_user(text: str) -> None:
|
||||
|
||||
|
||||
def _echo_interrupted() -> None:
|
||||
click.echo()
|
||||
click.secho("interrupted", fg="yellow")
|
||||
click.echo()
|
||||
# A further Ctrl+C during this output must stay benign (same rule as
|
||||
# retry._echo_cancel_lines): after a turn the SIGINT handler is removed, so
|
||||
# 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:
|
||||
@@ -49,33 +54,31 @@ async def run_repl(state: ReplState) -> None:
|
||||
click.echo()
|
||||
break
|
||||
|
||||
if entry is None:
|
||||
continue
|
||||
|
||||
if entry.startswith("/"):
|
||||
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)
|
||||
# Between turns the turn-task SIGINT handler is removed (run_cancellable),
|
||||
# so a stray Ctrl+C raises KeyboardInterrupt at whatever bytecode is
|
||||
# executing — echoing user text, expire_yolo_once, gaps between
|
||||
# statements. Swallow it and re-prompt instead of exiting the REPL.
|
||||
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:
|
||||
_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
|
||||
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