diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index a332bcd..3632430 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -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() diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index 7ef0001..ad756b9 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -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