diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index 76b7dd2..a332bcd 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -22,6 +22,12 @@ def _echo_user(text: str) -> None: click.echo(text) +def _echo_interrupted() -> None: + click.echo() + click.secho("interrupted", fg="yellow") + click.echo() + + async def run_repl(state: ReplState) -> None: """Interactive chat loop with readline editing, history, and Tab completion.""" setup_readline(state) @@ -47,7 +53,11 @@ async def run_repl(state: ReplState) -> None: continue if entry.startswith("/"): - cont = await handle_slash(state, entry) + try: + cont = await handle_slash(state, entry) + except KeyboardInterrupt: + _echo_interrupted() + continue if not cont: break if state.pending_user_text is not None: @@ -56,6 +66,8 @@ async def run_repl(state: ReplState) -> None: _echo_user(text) try: _ = await run_user_text_with_retries(state.agent, text) + except KeyboardInterrupt: + _echo_interrupted() finally: state.expire_yolo_once() continue @@ -63,5 +75,7 @@ async def run_repl(state: ReplState) -> None: _echo_user(entry) try: _ = await run_user_text_with_retries(state.agent, entry) + except KeyboardInterrupt: + _echo_interrupted() finally: state.expire_yolo_once() diff --git a/src/plyngent/cli/retry.py b/src/plyngent/cli/retry.py index 0e4bafb..c14c77a 100644 --- a/src/plyngent/cli/retry.py +++ b/src/plyngent/cli/retry.py @@ -131,20 +131,36 @@ def _echo_turn_usage(agent: ChatAgent) -> None: click.secho(f"[{'; '.join(parts)}]", fg="bright_black") -async def _wait_for_retry(attempt: int, max_retries: int, wait: float) -> bool: - click.secho( - f"auto-retry {attempt}/{max_retries} in {wait:g}s (Ctrl+C to cancel; then /retry later)", - fg="yellow", - ) +def _echo_cancel_lines(*lines: str) -> None: + """Print post-cancel lines, treating a further Ctrl+C as benign. + + After a turn-cancel the asyncio SIGINT handler is removed, so a second + Ctrl+C during this output would otherwise raise KeyboardInterrupt and + exit the REPL instead of returning to the prompt. + """ try: + for line in lines: + if line: + click.secho(line, fg="yellow") + else: + click.echo() + except KeyboardInterrupt: + pass + + +async def _wait_for_retry(attempt: int, max_retries: int, wait: float) -> bool: + try: + click.secho( + f"auto-retry {attempt}/{max_retries} in {wait:g}s (Ctrl+C to cancel; then /retry later)", + fg="yellow", + ) ok = await sleep_cancellable(wait) except KeyboardInterrupt: ok = False if not ok: - click.secho("auto-retry cancelled; use /retry to try again", fg="yellow") - click.echo() + _echo_cancel_lines("auto-retry cancelled; use /retry to try again", "") return False - click.secho(f"retrying ({attempt}/{max_retries})…", fg="yellow") + _echo_cancel_lines(f"retrying ({attempt}/{max_retries})…") return True @@ -170,17 +186,10 @@ async def run_turn_with_retries( await run_cancellable(render_events(current())) except asyncio.CancelledError: # Do not auto-retry cancelled turns — user intent was stop, not retry. - click.echo() - click.secho( - "cancelled; user message kept — use /retry to try again", - fg="yellow", - ) - click.echo() + _echo_cancel_lines("", "cancelled; user message kept — use /retry to try again", "") return False except KeyboardInterrupt: - click.echo() - click.secho("interrupted", fg="yellow") - click.echo() + _echo_cancel_lines("", "interrupted", "") return False except Exception as exc: # noqa: BLE001 — surface and optionally retry click.secho(f"error: {exc}", fg="red") diff --git a/tests/test_cli/test_repl_commands.py b/tests/test_cli/test_repl_commands.py index a7aaa2c..7ef0001 100644 --- a/tests/test_cli/test_repl_commands.py +++ b/tests/test_cli/test_repl_commands.py @@ -566,3 +566,30 @@ async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.Capt out2 = capsys.readouterr().out assert "context_tokens=1234/" in out2 assert "(api)" in out2 + + +async def test_repl_ctrl_c_from_turn_returns_to_prompt( + state: ReplState, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A Ctrl+C escaping a turn lands back at the prompt, not out of the REPL.""" + from plyngent.cli import repl as repl_mod + from plyngent.cli.repl import run_repl + + calls: dict[str, int] = {"reads": 0} + + def fake_read_entry() -> str: + calls["reads"] += 1 + if calls["reads"] >= 2: + raise EOFError + return "hello" + + async def interrupted_turn(_agent: object, _text: str) -> bool: + 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", interrupted_turn) + + await run_repl(state) # must return normally (EOF), never raise KeyboardInterrupt + assert calls["reads"] == 2 diff --git a/tests/test_cli/test_retry.py b/tests/test_cli/test_retry.py index dc40eda..354f0f0 100644 --- a/tests/test_cli/test_retry.py +++ b/tests/test_cli/test_retry.py @@ -8,6 +8,7 @@ from plyngent.agent import ChatAgent from plyngent.cli.retry import ( DEFAULT_MAX_AUTO_RETRIES, DEFAULT_RETRY_DELAYS_SECONDS, + _wait_for_retry, default_retry_delays, retry_pending_with_retries, run_turn_with_retries, @@ -199,6 +200,102 @@ async def test_cancel_turn_rolls_back_and_sets_pending() -> None: await store.close() +async def test_second_ctrl_c_during_cancel_message_stays_in_repl( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: a second Ctrl+C during the cancel message must not exit. + + After the turn task is cancelled the asyncio SIGINT handler is gone, so the + second Ctrl+C arrives as a KeyboardInterrupt while the "cancelled" message + is printing. It must be swallowed and control must return to the REPL + (``False``), not propagate out of ``run_turn_with_retries``. + """ + import asyncio + + calls: list[int] = [0] + + def raiser(message: object = None, **kwargs: object) -> None: + calls[0] += 1 + if calls[0] == 1: + raise KeyboardInterrupt + + monkeypatch.setattr("plyngent.cli.retry.click.secho", raiser) + + store = await MemoryStore.open(DatabaseConfig()) + session = await store.create_session(name="t") + + class HangClient: + @overload + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: Literal[False] = False + ) -> ChatCompletionResponse: ... + + @overload + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: Literal[True] + ) -> AsyncIterator[ChatCompletionChunk]: ... + + async def chat_completions( + self, param: ChatCompletionsParam, *, stream: bool = False + ) -> ChatCompletionResponse | AsyncIterator[ChatCompletionChunk]: + del param, stream + await asyncio.sleep(60) + return _response(AssistantChatMessage(content="never")) + + agent = ChatAgent(HangClient(), model="m", memory=store, session_id=session.sid) + turn = asyncio.create_task(run_turn_with_retries(agent, starter=lambda: agent.run("cancel-me"), delays=())) + await asyncio.sleep(0.05) + _ = turn.cancel() # first Ctrl+C: cancels the in-flight turn + ok = await turn # must return False instead of raising KeyboardInterrupt + assert ok is False + assert agent.pending_retry_text == "cancel-me" + await store.close() + + +async def test_second_ctrl_c_during_retry_cancel_message_stays_benign( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Regression: a second Ctrl+C during the retry-cancel message is benign.""" + + async def interrupted_sleep(_seconds: float) -> bool: + return False # first Ctrl+C interrupted the retry wait + + monkeypatch.setattr("plyngent.cli.retry.sleep_cancellable", interrupted_sleep) + + calls: list[int] = [0] + + def raiser(message: object = None, **kwargs: object) -> None: + calls[0] += 1 + if calls[0] == 2: # second Ctrl+C lands on the "auto-retry cancelled" line + raise KeyboardInterrupt + + monkeypatch.setattr("plyngent.cli.retry.click.secho", raiser) + + assert await _wait_for_retry(1, 3, 0.01) is False + + +async def test_second_ctrl_c_during_retry_announce_stays_benign( + monkeypatch: pytest.MonkeyPatch, +) -> None: + """A Ctrl+C during the "retrying" announcement does not abort the turn.""" + + async def ok_sleep(_seconds: float) -> bool: + return True + + monkeypatch.setattr("plyngent.cli.retry.sleep_cancellable", ok_sleep) + + calls: list[int] = [0] + + def raiser(message: object = None, **kwargs: object) -> None: + calls[0] += 1 + if calls[0] == 2: # Ctrl+C lands on the "retrying" line + raise KeyboardInterrupt + + monkeypatch.setattr("plyngent.cli.retry.click.secho", raiser) + + assert await _wait_for_retry(1, 3, 0.01) is True + + async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None: async def no_wait(_seconds: float) -> bool: return True