cli/repl: keep second Ctrl+C inside the REPL

This commit is contained in:
2026-08-01 01:45:10 +08:00
parent 3fe1c4cf91
commit a877dba8c1
4 changed files with 165 additions and 18 deletions
+27
View File
@@ -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
+97
View File
@@ -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