core/cli+memory: prompt on session workspace mismatch

When /resume or --session hits a different directory, ask keep (switch
tools root), update binding to current, or abort. Legacy unbound
sessions attach to the current workspace.
This commit is contained in:
2026-07-15 09:33:39 +08:00
parent ede7dcccd6
commit b80ebf783a
8 changed files with 305 additions and 20 deletions
+1
View File
@@ -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`). - **`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`, … - 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. - 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 providers`**: list config providers.
- **`plyngent config path|edit`**: print or open config in `$EDITOR` (`shlex`-split, e.g. `codium --wait`). - **`plyngent config path|edit`**: print or open config in `$EDITOR` (`shlex`-split, e.g. `codium --wait`).
+7 -1
View File
@@ -85,8 +85,14 @@ async def _run_chat(
) )
click.secho(f"workspace: {state.workspace}", fg="bright_black") click.secho(f"workspace: {state.workspace}", fg="bright_black")
if session_id is not None: if session_id is not None:
try:
await state.resume_session(session_id) await state.resume_session(session_id)
click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)") 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: elif new_session:
await state.new_session() await state.new_session()
click.echo(f"new session {state.session_id} (workspace={state.workspace})") click.echo(f"new session {state.session_id} (workspace={state.workspace})")
+34 -1
View File
@@ -1,6 +1,6 @@
from __future__ import annotations from __future__ import annotations
from typing import TYPE_CHECKING from typing import TYPE_CHECKING, Literal
import click import click
@@ -10,6 +10,8 @@ from plyngent.tools.process.pty_session import PtyManager
if TYPE_CHECKING: if TYPE_CHECKING:
from collections.abc import Mapping from collections.abc import Mapping
type WorkspaceMismatchChoice = Literal["keep", "rebind", "abort"]
def prompt_continue_limit(reason: str) -> bool: def prompt_continue_limit(reason: str) -> bool:
"""Ask the user whether to raise a limit and continue (TTY).""" """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 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: def install_cli_limit_hooks() -> None:
"""Register interactive continue hooks for process-global tool limits.""" """Register interactive continue hooks for process-global tool limits."""
PtyManager.set_limit_continue_hook(prompt_continue_limit) PtyManager.set_limit_continue_hook(prompt_continue_limit)
+5 -2
View File
@@ -30,7 +30,7 @@ Commands:
/history [n] Show last n messages in this session (default 20) /history [n] Show last n messages in this session (default 20)
/sessions List sessions for this workspace /sessions List sessions for this workspace
/new [name] Start a new session (bound to workspace) /new [name] Start a new session (bound to workspace)
/resume <id> Resume a session by id (must match workspace) /resume <id> Resume a session by id (prompts if workspace differs)
/provider [name] Show or switch provider /provider [name] Show or switch provider
/model [id] Show or switch model /model [id] Show or switch model
/tools [on|off] Show or toggle tools /tools [on|off] Show or toggle tools
@@ -98,7 +98,10 @@ async def _cmd_resume(state: ReplState, arg: str) -> None:
except ValueError as exc: except ValueError as exc:
click.echo(f"error: {exc}") click.echo(f"error: {exc}")
return 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: def _cmd_provider(state: ReplState, arg: str) -> None:
+36 -14
View File
@@ -88,16 +88,20 @@ class ReplState:
self.agent = self._make_agent() self.agent = self._make_agent()
self.agent.messages = messages 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: def _apply_session_workspace(self, row: SessionRow) -> None:
"""Bind tools/REPL workspace to the session's directory when set.""" """Bind tools/REPL workspace to the session's directory when set."""
if not row.workspace: if not row.workspace:
return return
path = Path(row.workspace).expanduser().resolve() self._set_workspace(Path(row.workspace))
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)
async def new_session(self, name: str = "chat") -> None: async def new_session(self, name: str = "chat") -> None:
session = await self.memory.create_session(name=name, workspace=self.workspace) session = await self.memory.create_session(name=name, workspace=self.workspace)
@@ -106,18 +110,33 @@ class ReplState:
self.agent.pending_retry_text = None self.agent.pending_retry_text = None
async def resume_session(self, session_id: int) -> 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) row = await self.memory.get_session(session_id)
if row is None: if row is None:
msg = f"session not found: {session_id}" msg = f"session not found: {session_id}"
raise ValueError(msg) raise ValueError(msg)
expected = self._workspace_key()
if row.workspace is not None and row.workspace != expected: current = self._workspace_key()
msg = ( if row.workspace is None:
f"session {session_id} is bound to workspace {row.workspace!r}, " # Legacy unbound session: attach to the current workspace.
f"not current {expected!r} (use matching --workspace or /resume from that dir)" 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) raise ValueError(msg)
self._apply_session_workspace(row) 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.session_id = session_id
self.agent = self._make_agent() self.agent = self._make_agent()
await self.agent.load_history() await self.agent.load_history()
@@ -129,5 +148,8 @@ class ReplState:
await self.new_session(name=name) await self.new_session(name=name)
return "new" return "new"
latest = max(sessions, key=lambda s: (s.updated_at, s.sid)) 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" return "resume"
+18
View File
@@ -134,6 +134,24 @@ class MemoryStore:
stmt = stmt.where(Session.workspace == ws) stmt = stmt.where(Session.workspace == ws)
result = await session.execute(stmt) result = await session.execute(stmt)
return result.scalars().all() 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: async def append_message(self, sid: int, message: AnyChatMessage) -> Message:
"""Append a chat message to a session with the next sequence number.""" """Append a chat message to a session with the next sequence number."""
data = msgspec.to_builtins(message) data = msgspec.to_builtins(message)
+187
View File
@@ -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()
+15
View File
@@ -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} 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: async def test_workspace_column_migration(tmp_path: object) -> None:
"""Existing DBs without session.workspace get the column via ALTER.""" """Existing DBs without session.workspace get the column via ALTER."""
from pathlib import Path from pathlib import Path