mirror of
https://github.com/NCBM/plyngent.git
synced 2026-11-09 00:47:54 +08:00
cli/repl: keep second Ctrl+C inside the REPL
This commit is contained in:
@@ -22,6 +22,12 @@ def _echo_user(text: str) -> None:
|
|||||||
click.echo(text)
|
click.echo(text)
|
||||||
|
|
||||||
|
|
||||||
|
def _echo_interrupted() -> None:
|
||||||
|
click.echo()
|
||||||
|
click.secho("interrupted", fg="yellow")
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
|
||||||
async def run_repl(state: ReplState) -> None:
|
async def run_repl(state: ReplState) -> None:
|
||||||
"""Interactive chat loop with readline editing, history, and Tab completion."""
|
"""Interactive chat loop with readline editing, history, and Tab completion."""
|
||||||
setup_readline(state)
|
setup_readline(state)
|
||||||
@@ -47,7 +53,11 @@ async def run_repl(state: ReplState) -> None:
|
|||||||
continue
|
continue
|
||||||
|
|
||||||
if entry.startswith("/"):
|
if entry.startswith("/"):
|
||||||
|
try:
|
||||||
cont = await handle_slash(state, entry)
|
cont = await handle_slash(state, entry)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
_echo_interrupted()
|
||||||
|
continue
|
||||||
if not cont:
|
if not cont:
|
||||||
break
|
break
|
||||||
if state.pending_user_text is not None:
|
if state.pending_user_text is not None:
|
||||||
@@ -56,6 +66,8 @@ async def run_repl(state: ReplState) -> None:
|
|||||||
_echo_user(text)
|
_echo_user(text)
|
||||||
try:
|
try:
|
||||||
_ = await run_user_text_with_retries(state.agent, text)
|
_ = await run_user_text_with_retries(state.agent, text)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
_echo_interrupted()
|
||||||
finally:
|
finally:
|
||||||
state.expire_yolo_once()
|
state.expire_yolo_once()
|
||||||
continue
|
continue
|
||||||
@@ -63,5 +75,7 @@ async def run_repl(state: ReplState) -> None:
|
|||||||
_echo_user(entry)
|
_echo_user(entry)
|
||||||
try:
|
try:
|
||||||
_ = await run_user_text_with_retries(state.agent, entry)
|
_ = await run_user_text_with_retries(state.agent, entry)
|
||||||
|
except KeyboardInterrupt:
|
||||||
|
_echo_interrupted()
|
||||||
finally:
|
finally:
|
||||||
state.expire_yolo_once()
|
state.expire_yolo_once()
|
||||||
|
|||||||
+22
-13
@@ -131,20 +131,36 @@ def _echo_turn_usage(agent: ChatAgent) -> None:
|
|||||||
click.secho(f"[{'; '.join(parts)}]", fg="bright_black")
|
click.secho(f"[{'; '.join(parts)}]", fg="bright_black")
|
||||||
|
|
||||||
|
|
||||||
|
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:
|
async def _wait_for_retry(attempt: int, max_retries: int, wait: float) -> bool:
|
||||||
|
try:
|
||||||
click.secho(
|
click.secho(
|
||||||
f"auto-retry {attempt}/{max_retries} in {wait:g}s (Ctrl+C to cancel; then /retry later)",
|
f"auto-retry {attempt}/{max_retries} in {wait:g}s (Ctrl+C to cancel; then /retry later)",
|
||||||
fg="yellow",
|
fg="yellow",
|
||||||
)
|
)
|
||||||
try:
|
|
||||||
ok = await sleep_cancellable(wait)
|
ok = await sleep_cancellable(wait)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
ok = False
|
ok = False
|
||||||
if not ok:
|
if not ok:
|
||||||
click.secho("auto-retry cancelled; use /retry to try again", fg="yellow")
|
_echo_cancel_lines("auto-retry cancelled; use /retry to try again", "")
|
||||||
click.echo()
|
|
||||||
return False
|
return False
|
||||||
click.secho(f"retrying ({attempt}/{max_retries})…", fg="yellow")
|
_echo_cancel_lines(f"retrying ({attempt}/{max_retries})…")
|
||||||
return True
|
return True
|
||||||
|
|
||||||
|
|
||||||
@@ -170,17 +186,10 @@ async def run_turn_with_retries(
|
|||||||
await run_cancellable(render_events(current()))
|
await run_cancellable(render_events(current()))
|
||||||
except asyncio.CancelledError:
|
except asyncio.CancelledError:
|
||||||
# Do not auto-retry cancelled turns — user intent was stop, not retry.
|
# Do not auto-retry cancelled turns — user intent was stop, not retry.
|
||||||
click.echo()
|
_echo_cancel_lines("", "cancelled; user message kept — use /retry to try again", "")
|
||||||
click.secho(
|
|
||||||
"cancelled; user message kept — use /retry to try again",
|
|
||||||
fg="yellow",
|
|
||||||
)
|
|
||||||
click.echo()
|
|
||||||
return False
|
return False
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
click.echo()
|
_echo_cancel_lines("", "interrupted", "")
|
||||||
click.secho("interrupted", fg="yellow")
|
|
||||||
click.echo()
|
|
||||||
return False
|
return False
|
||||||
except Exception as exc: # noqa: BLE001 — surface and optionally retry
|
except Exception as exc: # noqa: BLE001 — surface and optionally retry
|
||||||
click.secho(f"error: {exc}", fg="red")
|
click.secho(f"error: {exc}", fg="red")
|
||||||
|
|||||||
@@ -566,3 +566,30 @@ async def test_status_shows_context_tokens(state: ReplState, capsys: pytest.Capt
|
|||||||
out2 = capsys.readouterr().out
|
out2 = capsys.readouterr().out
|
||||||
assert "context_tokens=1234/" in out2
|
assert "context_tokens=1234/" in out2
|
||||||
assert "(api)" 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
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from plyngent.agent import ChatAgent
|
|||||||
from plyngent.cli.retry import (
|
from plyngent.cli.retry import (
|
||||||
DEFAULT_MAX_AUTO_RETRIES,
|
DEFAULT_MAX_AUTO_RETRIES,
|
||||||
DEFAULT_RETRY_DELAYS_SECONDS,
|
DEFAULT_RETRY_DELAYS_SECONDS,
|
||||||
|
_wait_for_retry,
|
||||||
default_retry_delays,
|
default_retry_delays,
|
||||||
retry_pending_with_retries,
|
retry_pending_with_retries,
|
||||||
run_turn_with_retries,
|
run_turn_with_retries,
|
||||||
@@ -199,6 +200,102 @@ async def test_cancel_turn_rolls_back_and_sets_pending() -> None:
|
|||||||
await store.close()
|
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 test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
|
||||||
async def no_wait(_seconds: float) -> bool:
|
async def no_wait(_seconds: float) -> bool:
|
||||||
return True
|
return True
|
||||||
|
|||||||
Reference in New Issue
Block a user