diff --git a/CLAUDE.md b/CLAUDE.md index 08b51ba..9320034 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -77,6 +77,7 @@ Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`. - **`plyngent chat`**: provider/model selection (flags or interactive), SQLite sessions via config `[database]` (file DB under user data if unset/`:memory:`), sessions bound to workspace dir; resumes latest **for cwd/`--workspace`** by default (`--new` / `--session`). - Slash: `/history`, `/sessions`, `/resume`, `/rounds`, `/retry`, … +- Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**. - Failed/cancelled turns: not written to DB; Ctrl+C cancels the in-flight turn task; **TTY confirms** (max-rounds / destructive tools) pause cancel so prompts work; auto-retry 10s/20s/30s; `/retry` manual. - **`plyngent providers`**: list config providers. - **`plyngent config path|edit`**: print or open config in `$EDITOR` (`shlex`-split, e.g. `codium --wait`). diff --git a/src/plyngent/cli/app.py b/src/plyngent/cli/app.py index 4427c79..afe13ba 100644 --- a/src/plyngent/cli/app.py +++ b/src/plyngent/cli/app.py @@ -85,8 +85,14 @@ async def _run_chat( ) click.secho(f"workspace: {state.workspace}", fg="bright_black") if session_id is not None: - await state.resume_session(session_id) - click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)") + try: + await state.resume_session(session_id) + except ValueError as exc: + raise click.ClickException(str(exc)) from exc + click.echo( + f"resumed session {session_id} ({len(state.agent.messages)} messages) " + f"workspace={state.workspace}" + ) elif new_session: await state.new_session() click.echo(f"new session {state.session_id} (workspace={state.workspace})") diff --git a/src/plyngent/cli/limits.py b/src/plyngent/cli/limits.py index d171ed2..5850a43 100644 --- a/src/plyngent/cli/limits.py +++ b/src/plyngent/cli/limits.py @@ -1,6 +1,6 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Literal import click @@ -10,6 +10,8 @@ from plyngent.tools.process.pty_session import PtyManager if TYPE_CHECKING: from collections.abc import Mapping +type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"] + def prompt_continue_limit(reason: str) -> bool: """Ask the user whether to raise a limit and continue (TTY).""" @@ -34,6 +36,37 @@ def prompt_confirm_tool(name: str, args: Mapping[str, object], reason: str) -> b return False +def prompt_workspace_mismatch( + session_id: int, + session_workspace: str, + current_workspace: str, +) -> WorkspaceMismatchChoice: + """Ask how to handle resuming a session bound to a different directory.""" + click.echo() + click.secho(f"[workspace] session {session_id} is bound to a different directory:", fg="yellow") + click.echo(f" session: {session_workspace}") + click.echo(f" current: {current_workspace}") + click.echo(" k = keep session workspace (switch tools root to session path)") + click.echo(" u = update binding to current workspace") + click.echo(" a = abort resume") + with pause_task_cancel_for_prompt(): + try: + raw = click.prompt( + "Choice", + type=click.Choice(["k", "u", "a"], case_sensitive=False), + default="k", + show_choices=True, + ) + except (click.Abort, KeyboardInterrupt): + return "abort" + key = str(raw).strip().lower() + if key == "u": + return "rebind" + if key == "a": + return "abort" + return "keep" + + def install_cli_limit_hooks() -> None: """Register interactive continue hooks for process-global tool limits.""" PtyManager.set_limit_continue_hook(prompt_continue_limit) diff --git a/src/plyngent/cli/repl.py b/src/plyngent/cli/repl.py index ed655db..46c8dfa 100644 --- a/src/plyngent/cli/repl.py +++ b/src/plyngent/cli/repl.py @@ -30,7 +30,7 @@ Commands: /history [n] Show last n messages in this session (default 20) /sessions List sessions for this workspace /new [name] Start a new session (bound to workspace) - /resume Resume a session by id (must match workspace) + /resume Resume a session by id (prompts if workspace differs) /provider [name] Show or switch provider /model [id] Show or switch model /tools [on|off] Show or toggle tools @@ -98,7 +98,10 @@ async def _cmd_resume(state: ReplState, arg: str) -> None: except ValueError as exc: click.echo(f"error: {exc}") return - click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)") + click.echo( + f"resumed session {session_id} ({len(state.agent.messages)} messages) " + f"workspace={state.workspace}" + ) def _cmd_provider(state: ReplState, arg: str) -> None: diff --git a/src/plyngent/cli/state.py b/src/plyngent/cli/state.py index 347cf53..d420f38 100644 --- a/src/plyngent/cli/state.py +++ b/src/plyngent/cli/state.py @@ -88,16 +88,20 @@ class ReplState: self.agent = self._make_agent() self.agent.messages = messages + def _set_workspace(self, path: Path) -> None: + """Update REPL + tool workspace root.""" + resolved = path.expanduser().resolve() + if not resolved.is_dir(): + msg = f"workspace is not a directory: {resolved}" + raise ValueError(msg) + self.workspace = resolved + _ = set_workspace_root(resolved) + def _apply_session_workspace(self, row: SessionRow) -> None: """Bind tools/REPL workspace to the session's directory when set.""" if not row.workspace: return - path = Path(row.workspace).expanduser().resolve() - if not path.is_dir(): - msg = f"session {row.sid} workspace is not a directory: {path}" - raise ValueError(msg) - self.workspace = path - _ = set_workspace_root(path) + self._set_workspace(Path(row.workspace)) async def new_session(self, name: str = "chat") -> None: session = await self.memory.create_session(name=name, workspace=self.workspace) @@ -106,18 +110,33 @@ class ReplState: self.agent.pending_retry_text = None async def resume_session(self, session_id: int) -> None: + """Load a session; on workspace mismatch, prompt keep / rebind / abort.""" + from plyngent.cli.limits import prompt_workspace_mismatch + row = await self.memory.get_session(session_id) if row is None: msg = f"session not found: {session_id}" raise ValueError(msg) - expected = self._workspace_key() - if row.workspace is not None and row.workspace != expected: - msg = ( - f"session {session_id} is bound to workspace {row.workspace!r}, " - f"not current {expected!r} (use matching --workspace or /resume from that dir)" - ) - raise ValueError(msg) - self._apply_session_workspace(row) + + current = self._workspace_key() + if row.workspace is None: + # Legacy unbound session: attach to the current workspace. + row = await self.memory.update_session_workspace(session_id, self.workspace) + elif row.workspace != current: + choice = prompt_workspace_mismatch(session_id, row.workspace, current) + if choice == "abort": + msg = "resume aborted" + raise ValueError(msg) + if choice == "rebind": + row = await self.memory.update_session_workspace(session_id, self.workspace) + else: + # keep: switch live workspace to the session binding + try: + self._set_workspace(Path(row.workspace)) + except ValueError as exc: + msg = f"cannot keep session workspace: {exc}" + raise ValueError(msg) from exc + self.session_id = session_id self.agent = self._make_agent() await self.agent.load_history() @@ -129,5 +148,8 @@ class ReplState: await self.new_session(name=name) return "new" latest = max(sessions, key=lambda s: (s.updated_at, s.sid)) - await self.resume_session(latest.sid) + # Same-workspace list: no mismatch prompt expected. + self.session_id = latest.sid + self.agent = self._make_agent() + await self.agent.load_history() return "resume" diff --git a/src/plyngent/memory/database/store.py b/src/plyngent/memory/database/store.py index f7b70d2..26e350e 100644 --- a/src/plyngent/memory/database/store.py +++ b/src/plyngent/memory/database/store.py @@ -134,6 +134,24 @@ class MemoryStore: stmt = stmt.where(Session.workspace == ws) result = await session.execute(stmt) return result.scalars().all() + + async def update_session_workspace( + self, + sid: int, + workspace: str | Path | None, + ) -> Session: + """Set or clear the workspace binding for a session.""" + ws = normalize_workspace(workspace) + async with self._session_factory() as session: + row = await session.get(Session, sid) + if row is None: + msg = f"session not found: {sid}" + raise ValueError(msg) + row.workspace = ws + await session.commit() + await session.refresh(row) + return row + async def append_message(self, sid: int, message: AnyChatMessage) -> Message: """Append a chat message to a session with the next sequence number.""" data = msgspec.to_builtins(message) diff --git a/tests/test_cli/test_workspace_resume.py b/tests/test_cli/test_workspace_resume.py new file mode 100644 index 0000000..8592a4e --- /dev/null +++ b/tests/test_cli/test_workspace_resume.py @@ -0,0 +1,187 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Literal, cast, overload + +import pytest +import tomlkit + +from plyngent.agent import ChatAgent +from plyngent.cli.limits import prompt_workspace_mismatch +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, + ChatCompletionChoice, + ChatCompletionChunk, + ChatCompletionResponse, + ChatCompletionsParam, +) +from plyngent.memory import MemoryStore +from plyngent.memory.database.store import normalize_workspace +from plyngent.tools import set_workspace_root + +if TYPE_CHECKING: + from collections.abc import AsyncIterator + from pathlib import Path + + from plyngent.cli.limits import WorkspaceMismatchChoice + + +class DummyClient: + @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 + + if stream: + + async def empty() -> AsyncIterator[ChatCompletionChunk]: + if False: # pragma: no cover + yield cast("ChatCompletionChunk", None) + + return empty() + return ChatCompletionResponse( + id="1", + object="chat.completion", + created=0, + model="m", + choices=[ + ChatCompletionChoice( + index=0, + message=AssistantChatMessage(content="ok"), + logprobs={}, + finish_reason="stop", + ) + ], + system_fingerprint="", + usage={}, + ) + + +def _make_state(memory: MemoryStore, workspace: Path) -> ReplState: + _ = set_workspace_root(workspace) + provider = OpenAIProvider(access_key_or_token="sk-test") + config = ConfigStore(path=workspace / "plyngent.toml", document=tomlkit.document()) + config.providers = {"local": provider} + st = ReplState( + config=config, + memory=memory, + workspace=workspace, + 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) + return st + + +def test_prompt_workspace_mismatch_choices(monkeypatch: pytest.MonkeyPatch) -> None: + def prompt_u(*_a: object, **_k: object) -> str: + return "u" + + def prompt_k(*_a: object, **_k: object) -> str: + return "k" + + def prompt_a(*_a: object, **_k: object) -> str: + return "a" + + monkeypatch.setattr("click.prompt", prompt_u) + assert prompt_workspace_mismatch(1, "/old", "/new") == "rebind" + monkeypatch.setattr("click.prompt", prompt_k) + assert prompt_workspace_mismatch(1, "/old", "/new") == "keep" + monkeypatch.setattr("click.prompt", prompt_a) + assert prompt_workspace_mismatch(1, "/old", "/new") == "abort" + + +async def test_resume_mismatch_rebind(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + memory = await MemoryStore.open(DatabaseConfig()) + try: + session = await memory.create_session(name="t", workspace=a) + state = _make_state(memory, b) + + def choose(*_a: object, **_k: object) -> WorkspaceMismatchChoice: + return cast("WorkspaceMismatchChoice", "rebind") + + monkeypatch.setattr("plyngent.cli.limits.prompt_workspace_mismatch", choose) + await state.resume_session(session.sid) + assert normalize_workspace(state.workspace) == normalize_workspace(b) + row = await memory.get_session(session.sid) + assert row is not None + assert row.workspace == normalize_workspace(b) + finally: + await memory.close() + + +async def test_resume_mismatch_keep(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + memory = await MemoryStore.open(DatabaseConfig()) + try: + session = await memory.create_session(name="t", workspace=a) + state = _make_state(memory, b) + + def choose(*_a: object, **_k: object) -> WorkspaceMismatchChoice: + return cast("WorkspaceMismatchChoice", "keep") + + monkeypatch.setattr("plyngent.cli.limits.prompt_workspace_mismatch", choose) + await state.resume_session(session.sid) + assert normalize_workspace(state.workspace) == normalize_workspace(a) + row = await memory.get_session(session.sid) + assert row is not None + assert row.workspace == normalize_workspace(a) + finally: + await memory.close() + + +async def test_resume_mismatch_abort(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> None: + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + memory = await MemoryStore.open(DatabaseConfig()) + try: + session = await memory.create_session(name="t", workspace=a) + state = _make_state(memory, b) + + def choose(*_a: object, **_k: object) -> WorkspaceMismatchChoice: + return cast("WorkspaceMismatchChoice", "abort") + + monkeypatch.setattr("plyngent.cli.limits.prompt_workspace_mismatch", choose) + with pytest.raises(ValueError, match="aborted"): + await state.resume_session(session.sid) + assert normalize_workspace(state.workspace) == normalize_workspace(b) + finally: + await memory.close() + + +async def test_resume_unbound_binds_current(tmp_path: Path) -> None: + memory = await MemoryStore.open(DatabaseConfig()) + try: + session = await memory.create_session(name="legacy") + assert session.workspace is None + state = _make_state(memory, tmp_path) + await state.resume_session(session.sid) + row = await memory.get_session(session.sid) + assert row is not None + assert row.workspace == normalize_workspace(tmp_path) + finally: + await memory.close() diff --git a/tests/test_memory/test_store.py b/tests/test_memory/test_store.py index 4d1797e..b330c7b 100644 --- a/tests/test_memory/test_store.py +++ b/tests/test_memory/test_store.py @@ -78,6 +78,21 @@ async def test_session_workspace_binding(store: MemoryStore, tmp_path: object) - assert {s.sid for s in listed_b} == {sb.sid} +async def test_update_session_workspace(store: MemoryStore, tmp_path: object) -> None: + from pathlib import Path + + assert isinstance(tmp_path, Path) + a = tmp_path / "a" + b = tmp_path / "b" + a.mkdir() + b.mkdir() + session = await store.create_session(name="x", workspace=a) + updated = await store.update_session_workspace(session.sid, b) + assert updated.workspace == str(b.resolve()) + listed = await store.list_sessions(workspace=b) + assert {s.sid for s in listed} == {session.sid} + + async def test_workspace_column_migration(tmp_path: object) -> None: """Existing DBs without session.workspace get the column via ALTER.""" from pathlib import Path