core/cli: level-1 cancel of in-flight turns via Ctrl+C

Run turns as asyncio tasks; SIGINT cancels the task; agent rolls back
and keeps pending_retry_text for /retry.
This commit is contained in:
2026-07-14 21:02:54 +08:00
parent e0916a6352
commit 25ff849efe
5 changed files with 127 additions and 11 deletions
+60 -2
View File
@@ -2,6 +2,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING, Literal, overload
import pytest
from plyngent.agent import ChatAgent
from plyngent.cli.retry import retry_pending_with_retries, run_turn_with_retries, sleep_cancellable
from plyngent.config.models import DatabaseConfig
@@ -18,8 +20,6 @@ from plyngent.memory import MemoryStore
if TYPE_CHECKING:
from collections.abc import AsyncIterator
import pytest
def _response(message: AssistantChatMessage) -> ChatCompletionResponse:
return ChatCompletionResponse(
@@ -105,6 +105,64 @@ async def test_auto_retry_eventually_succeeds(monkeypatch: pytest.MonkeyPatch) -
await store.close()
async def test_run_cancellable_cancels_task() -> None:
import asyncio
from plyngent.cli.retry import run_cancellable
started = asyncio.Event()
async def hang() -> None:
started.set()
await asyncio.sleep(60)
task = asyncio.create_task(run_cancellable(hang()))
_ = await started.wait()
_ = task.cancel()
with pytest.raises(asyncio.CancelledError):
await task
async def test_cancel_turn_rolls_back_and_sets_pending() -> None:
import asyncio
from plyngent.cli.display import render_events
from plyngent.cli.retry import run_cancellable
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_cancellable(render_events(agent.run("cancel-me"))))
await asyncio.sleep(0.05)
_ = turn.cancel()
with pytest.raises(asyncio.CancelledError):
await turn
assert agent.pending_retry_text == "cancel-me"
assert agent.messages == []
assert await store.list_messages(session.sid) == []
await store.close()
async def test_manual_retry_after_exhausted(monkeypatch: pytest.MonkeyPatch) -> None:
async def no_wait(_seconds: float) -> bool:
return True