mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
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:
@@ -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`).
|
||||
|
||||
@@ -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})")
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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 <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
|
||||
/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:
|
||||
|
||||
+37
-15
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user