diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index a178e25..018b865 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -167,7 +167,8 @@ async def _bind_session( ) -> None: if session_id is not None: try: - await state.resume_session(session_id) + # Startup resume: this process did not run the session's last turn. + await state.resume_session(session_id, restarted=True) except ValueError as exc: raise click.ClickException(str(exc)) from exc if not quiet and not oneshot: @@ -184,7 +185,7 @@ async def _bind_session( err=True, ) return - mode = await state.resume_latest_or_new() + mode = await state.resume_latest_or_new(restarted=True) if quiet: return if mode == "resume": @@ -324,6 +325,7 @@ async def _run_chat( # noqa: C901, PLR0912, PLR0915 — chat orchestration interactive_limits=interactive, auto_continue_limits=auto_effective, yolo=yolo, + quiet=quiet, mcp_manager=mcp_manager, ) # Path denylist and policy confirm live on instance.workspace (no process bag). diff --git a/src/plyngent/cli/display.py b/src/plyngent/cli/display.py index 067ebc4..d184ae8 100644 --- a/src/plyngent/cli/display.py +++ b/src/plyngent/cli/display.py @@ -27,7 +27,7 @@ from plyngent.lmproto.openai_compatible.model import AssistantFunctionToolCall if TYPE_CHECKING: from collections.abc import AsyncIterator, Callable - from plyngent.agent import AgentEvent + from plyngent.agent import AgentEvent, Notice _TOOL_RESULT_PREVIEW = 120 _TOOL_ARGS_PREVIEW = 80 @@ -72,6 +72,12 @@ def markdown_render_available() -> bool: return interactive_terminal() +def echo_notice(notice: Notice, *, err: bool = False) -> None: + """Show a host notice to the user; the model gets the full body.""" + text = f"[notice] {notice.kind}: {notice.summary}" if notice.summary else f"[notice] {notice.kind}" + click.secho(text, fg="yellow", err=err) + + def _preview(text: str, limit: int) -> str: if len(text) <= limit: return text diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 3bae017..16327f8 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -11,6 +11,8 @@ from plyngent.agent.loop import DEFAULT_MAX_ROUNDS from plyngent.agent.todo_stack import TodoStack if TYPE_CHECKING: + from datetime import datetime + from plyngent.agent.types import AnyLLMClient from plyngent.cli.models_source import ( DEFAULT_MODELS_CACHE_TTL, @@ -65,6 +67,8 @@ class ReplState: max_rounds: int = DEFAULT_MAX_ROUNDS stream_enabled: bool = True verbose: bool = False + # Less status on stderr (--quiet); host notices are still pushed to the model. + quiet: bool = False # End-of-turn Rich markdown for assistant text (TTY only). markdown_enabled: bool = True # One-shot / scripts: never prompt to raise tool-loop limits. @@ -704,8 +708,12 @@ class ReplState: await self.new_session() return was_current - async def resume_session(self, session_id: int) -> None: - """Load a session; on workspace mismatch, prompt keep / rebind / abort.""" + async def resume_session(self, session_id: int, *, restarted: bool = False) -> None: + """Load a session; on workspace mismatch, prompt keep / rebind / abort. + + With *restarted* (a session resumed by a freshly started process) the + model gets a notice about the process-scoped state that a restart drops. + """ from plyngent.cli.limits import prompt_workspace_mismatch self._reset_limit_auto_continue() @@ -743,9 +751,15 @@ class ReplState: await self.agent.load_history() await self.load_todo_stack() await self.load_access_grants() + if restarted: + await self._notice_restart(last_active=row.updated_at) - async def resume_latest_or_new(self, name: str = "chat") -> str: - """Resume most recently updated session for this workspace, or create one.""" + async def resume_latest_or_new(self, name: str = "chat", *, restarted: bool = False) -> str: + """Resume most recently updated session for this workspace, or create one. + + With *restarted* the resumed session gets the new-process notice; a + freshly created session has nothing to explain, so it gets none. + """ self._reset_limit_auto_continue() latest = await self.memory.get_latest_session(workspace=self.workspace) if latest is None: @@ -760,9 +774,22 @@ class ReplState: await self.agent.load_history() await self.load_todo_stack() await self.load_access_grants() + if restarted: + # Before touch_session: the stamp should be the previous run's activity. + await self._notice_restart(last_active=latest.updated_at) _ = await self.memory.touch_session(latest.sid) return "resume" + async def _notice_restart(self, *, last_active: datetime | None) -> None: + """Tell the model — and the user — that this session outlived a restart.""" + from plyngent.agent import resume_notice + from plyngent.cli.display import echo_notice + + notice = resume_notice(last_active=last_active) + _ = await self.agent.push_notice(notice) + if not self.quiet: + echo_notice(notice, err=True) + async def compact_to_new_session(self, *, name: str | None = None) -> tuple[int, int, str]: """Soft-compact + model-summarize current history into a new workspace session. diff --git a/tests/test_cli/test_resume_notice.py b/tests/test_cli/test_resume_notice.py new file mode 100644 index 0000000..5247853 --- /dev/null +++ b/tests/test_cli/test_resume_notice.py @@ -0,0 +1,152 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, overload + +import pytest +import tomlkit + +from plyngent.agent import ChatAgent +from plyngent.cli.app import _bind_session +from plyngent.cli.state import ReplState +from plyngent.config.models import DatabaseConfig, OpenAIProvider +from plyngent.config.store import ConfigStore +from plyngent.lmproto.openai_compatible.model import ( + AssistantChatMessage, + ChatCompletionChunk, + ChatCompletionResponse, + ChatCompletionsParam, + DeveloperChatMessage, + UserChatMessage, +) +from plyngent.memory import MemoryStore + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from pathlib import Path + + +class DummyClient: + """Client stub: these tests never run a model turn.""" + + @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 + msg = "no model call expected" + raise AssertionError(msg) + + +@pytest.fixture +async def state(tmp_path: Path) -> AsyncIterator[ReplState]: + memory = await MemoryStore.open(DatabaseConfig()) + provider = OpenAIProvider(access_key_or_token="sk-test") + config = ConfigStore(path=tmp_path / "plyngent.toml", document=tomlkit.document()) + config.providers = {"local": provider} + st = ReplState( + config=config, + memory=memory, + workspace=tmp_path, + provider_name="local", + provider=provider, + model="gpt-test", + tools_enabled=False, + ) + st.client = DummyClient() + st.agent = ChatAgent(st.client, model=st.model, memory=st.memory, session_id=None) + yield st + await memory.close() + + +async def _seed_finished_turn(state: ReplState) -> int: + """Start a session and give it one finished exchange; return its id.""" + await state.new_session("t") + sid = state.session_id + assert sid is not None + _ = await state.memory.append_message(sid, UserChatMessage(content="hello")) + _ = await state.memory.append_message(sid, AssistantChatMessage(content="hi there")) + return sid + + +async def test_startup_resume_notifies_model_and_user( + state: ReplState, + capsys: pytest.CaptureFixture[str], +) -> None: + sid = await _seed_finished_turn(state) + + await _bind_session(state, session_id=sid, new_session=False, oneshot=False, quiet=False) + + rows = await state.memory.list_messages(sid) + tail = rows[-1] + assert isinstance(tail, DeveloperChatMessage) + assert tail.content.startswith("[plyngent notice: resume]") + assert "resumed in a new plyngent process" in tail.content + assert "grants were restored" in tail.content + # The live agent holds the same notice after loading history. + assert state.agent.messages[-1].content == tail.content + captured = capsys.readouterr() + assert "[notice] resume:" in captured.err + assert "resumed session" in captured.err + + +async def test_quiet_startup_resume_still_notifies_model( + state: ReplState, + capsys: pytest.CaptureFixture[str], +) -> None: + state.quiet = True + sid = await _seed_finished_turn(state) + + await _bind_session(state, session_id=sid, new_session=False, oneshot=False, quiet=True) + + rows = await state.memory.list_messages(sid) + assert isinstance(rows[-1], DeveloperChatMessage) + assert "[notice]" not in capsys.readouterr().err + + +async def test_mid_process_resume_has_no_restart_notice( + state: ReplState, + capsys: pytest.CaptureFixture[str], +) -> None: + """``/resume`` in a live process loses nothing, so nothing is announced.""" + sid = await _seed_finished_turn(state) + before = len(await state.memory.list_messages(sid)) + + await state.resume_session(sid) + + assert len(await state.memory.list_messages(sid)) == before + assert "[notice]" not in capsys.readouterr().err + + +async def test_fresh_session_gets_no_restart_notice(state: ReplState) -> None: + mode = await state.resume_latest_or_new(restarted=True) + + assert mode == "new" + sid = state.session_id + assert sid is not None + assert await state.memory.list_messages(sid) == [] + + +async def test_latest_resume_stamps_previous_activity( + state: ReplState, + capsys: pytest.CaptureFixture[str], +) -> None: + sid = await _seed_finished_turn(state) + + mode = await state.resume_latest_or_new(restarted=True) + + assert mode == "resume" + rows = await state.memory.list_messages(sid) + tail = rows[-1] + assert isinstance(tail, DeveloperChatMessage) + # touch_session must not overwrite the stamp with the resume time. + assert "previous run's last activity: unknown" not in tail.content + assert "[notice] resume:" in capsys.readouterr().err