mirror of
https://github.com/NCBM/plyngent.git
synced 2026-07-23 05:55:16 +08:00
core/memory+cli: session rename, delete, and export
Add MemoryStore rename/delete with explicit message cleanup. Slash commands /rename, /delete (confirm), /export md|json write transcripts from DB; deleting the current session starts a fresh empty one.
This commit is contained in:
@@ -84,7 +84,7 @@ Shared interactive I/O: `ask` / `choose` / `form` / `confirm` with pluggable bac
|
|||||||
Click app + readline REPL. Entry: `plyngent` / `python -m plyngent`.
|
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 **most recently updated** session 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 **most recently updated** session for cwd/`--workspace` by default (`--new` / `--session`).
|
||||||
- Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/compact`, `/status` (incl. context char estimate), `/rounds`, `/stream`, `/verbose`, `/retry`, …
|
- Slash: `/history`, `/sessions` (newest first), `/resume [id]`, `/rename`, `/delete` (confirm), `/export md|json`, `/compact`, `/status`, `/rounds`, `/stream`, `/verbose`, `/retry`, …
|
||||||
- Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**.
|
- Explicit `/resume` or `--session` from another workspace prompts: **keep** session path, **update** binding to current, or **abort**.
|
||||||
- Failed/cancelled turns: user message kept in DB; partial assistant/tool rolled back; Ctrl+C cancels in-flight turn; **TTY confirms** off-loop; auto-retry 10s/20s/30s then `/retry` (no duplicate user message).
|
- Failed/cancelled turns: user message kept in DB; partial assistant/tool rolled back; Ctrl+C cancels in-flight turn; **TTY confirms** off-loop; auto-retry 10s/20s/30s then `/retry` (no duplicate user message).
|
||||||
- **`plyngent providers`**: list config providers.
|
- **`plyngent providers`**: list config providers.
|
||||||
|
|||||||
@@ -0,0 +1,114 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from typing import TYPE_CHECKING
|
||||||
|
|
||||||
|
import msgspec
|
||||||
|
from msgspec import UNSET
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import (
|
||||||
|
AssistantChatMessage,
|
||||||
|
AssistantFunctionToolCall,
|
||||||
|
ToolChatMessage,
|
||||||
|
UserChatMessage,
|
||||||
|
)
|
||||||
|
|
||||||
|
if TYPE_CHECKING:
|
||||||
|
from collections.abc import Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AnyChatMessage
|
||||||
|
|
||||||
|
|
||||||
|
def _iso(value: datetime | None) -> str | None:
|
||||||
|
if value is None:
|
||||||
|
return None
|
||||||
|
return value.isoformat()
|
||||||
|
|
||||||
|
|
||||||
|
def session_export_payload(
|
||||||
|
*,
|
||||||
|
sid: int,
|
||||||
|
name: str,
|
||||||
|
workspace: str | None,
|
||||||
|
created_at: datetime | None,
|
||||||
|
updated_at: datetime | None,
|
||||||
|
messages: Sequence[AnyChatMessage],
|
||||||
|
) -> dict[str, object]:
|
||||||
|
"""Build a JSON-serializable dict for a session transcript."""
|
||||||
|
return {
|
||||||
|
"session_id": sid,
|
||||||
|
"name": name,
|
||||||
|
"workspace": workspace,
|
||||||
|
"created_at": _iso(created_at),
|
||||||
|
"updated_at": _iso(updated_at),
|
||||||
|
"messages": [msgspec.to_builtins(m) for m in messages],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def encode_session_export_json(payload: dict[str, object]) -> str:
|
||||||
|
return msgspec.json.encode(payload).decode()
|
||||||
|
|
||||||
|
|
||||||
|
def format_session_export_md(
|
||||||
|
messages: Sequence[AnyChatMessage],
|
||||||
|
*,
|
||||||
|
sid: int,
|
||||||
|
name: str,
|
||||||
|
workspace: str | None = None,
|
||||||
|
) -> str:
|
||||||
|
"""Render a simple markdown transcript."""
|
||||||
|
lines: list[str] = [f"# Session {sid}: {name}"]
|
||||||
|
if workspace:
|
||||||
|
lines.append(f"workspace: `{workspace}`")
|
||||||
|
lines.append("")
|
||||||
|
for message in messages:
|
||||||
|
lines.extend(_format_message_md(message))
|
||||||
|
lines.append("")
|
||||||
|
return "\n".join(lines).rstrip() + "\n"
|
||||||
|
|
||||||
|
|
||||||
|
def _format_message_md(message: AnyChatMessage) -> list[str]:
|
||||||
|
if isinstance(message, UserChatMessage):
|
||||||
|
return ["## user", "", message.content]
|
||||||
|
if isinstance(message, AssistantChatMessage):
|
||||||
|
parts: list[str] = ["## assistant", ""]
|
||||||
|
reasoning = message.reasoning_content
|
||||||
|
if isinstance(reasoning, str) and reasoning:
|
||||||
|
parts.extend(["### reasoning", "", reasoning, ""])
|
||||||
|
if isinstance(message.content, str) and message.content:
|
||||||
|
parts.append(message.content)
|
||||||
|
tool_calls = message.tool_calls
|
||||||
|
if tool_calls is not UNSET and tool_calls:
|
||||||
|
names: list[str] = []
|
||||||
|
for call in tool_calls:
|
||||||
|
if isinstance(call, AssistantFunctionToolCall):
|
||||||
|
names.append(call.function.name)
|
||||||
|
else:
|
||||||
|
names.append("custom")
|
||||||
|
parts.append(f"*tool_calls: {', '.join(names)}*")
|
||||||
|
# Header only ("## assistant", "") with no body yet.
|
||||||
|
if len(parts) <= 2: # noqa: PLR2004
|
||||||
|
parts.append("(empty)")
|
||||||
|
return parts
|
||||||
|
if isinstance(message, ToolChatMessage):
|
||||||
|
return [f"## tool (`{message.tool_call_id}`)", "", message.content]
|
||||||
|
role = getattr(message, "role", type(message).__name__)
|
||||||
|
content = getattr(message, "content", "")
|
||||||
|
return [f"## {role}", "", str(content)]
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_export_path(sid: int, fmt: str, path_arg: str | None) -> Path:
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
if path_arg:
|
||||||
|
return Path(path_arg).expanduser()
|
||||||
|
ext = "md" if fmt == "md" else "json"
|
||||||
|
return Path.cwd() / f"session-{sid}.{ext}"
|
||||||
|
|
||||||
|
|
||||||
|
def write_export_file(path: Path, text: str) -> Path:
|
||||||
|
"""Write export text to ``path`` (sync helper; CLI is not on hot async I/O)."""
|
||||||
|
_ = path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
_ = path.write_text(text, encoding="utf-8")
|
||||||
|
return path.resolve()
|
||||||
@@ -24,6 +24,9 @@ SLASH_COMMANDS: tuple[str, ...] = (
|
|||||||
"/sessions",
|
"/sessions",
|
||||||
"/new",
|
"/new",
|
||||||
"/resume",
|
"/resume",
|
||||||
|
"/rename",
|
||||||
|
"/delete",
|
||||||
|
"/export",
|
||||||
"/compact",
|
"/compact",
|
||||||
"/provider",
|
"/provider",
|
||||||
"/model",
|
"/model",
|
||||||
@@ -36,6 +39,7 @@ SLASH_COMMANDS: tuple[str, ...] = (
|
|||||||
)
|
)
|
||||||
|
|
||||||
_ON_OFF_ARGS: tuple[str, ...] = ("on", "off")
|
_ON_OFF_ARGS: tuple[str, ...] = ("on", "off")
|
||||||
|
_EXPORT_ARGS: tuple[str, ...] = ("md", "json")
|
||||||
|
|
||||||
|
|
||||||
def history_path() -> Path:
|
def history_path() -> Path:
|
||||||
@@ -78,6 +82,8 @@ def _argument_options(state: ReplState, command: str, text: str) -> list[str]:
|
|||||||
return filter_prefix(text, sorted(state.provider.models.keys()))
|
return filter_prefix(text, sorted(state.provider.models.keys()))
|
||||||
if command in {"/tools", "/stream", "/verbose"}:
|
if command in {"/tools", "/stream", "/verbose"}:
|
||||||
return filter_prefix(text, list(_ON_OFF_ARGS))
|
return filter_prefix(text, list(_ON_OFF_ARGS))
|
||||||
|
if command == "/export":
|
||||||
|
return filter_prefix(text, list(_EXPORT_ARGS))
|
||||||
if command == "/resume":
|
if command == "/resume":
|
||||||
return []
|
return []
|
||||||
return []
|
return []
|
||||||
|
|||||||
@@ -31,6 +31,9 @@ Commands:
|
|||||||
/sessions List sessions for this workspace (newest first)
|
/sessions List sessions for this workspace (newest first)
|
||||||
/new [name] Start a new session (bound to workspace)
|
/new [name] Start a new session (bound to workspace)
|
||||||
/resume [id] Resume session id, or latest for this workspace if omitted
|
/resume [id] Resume session id, or latest for this workspace if omitted
|
||||||
|
/rename <name> Rename the current session
|
||||||
|
/delete [id] Hard-delete a session (confirm; current → new empty)
|
||||||
|
/export [md|json] [path] Export session transcript from DB
|
||||||
/compact [name] Soft-compact + model-summarize into a new session
|
/compact [name] Soft-compact + model-summarize into a new session
|
||||||
/provider [name] Show or switch provider
|
/provider [name] Show or switch provider
|
||||||
/model [id] Show or switch model
|
/model [id] Show or switch model
|
||||||
@@ -108,6 +111,113 @@ async def _cmd_new(state: ReplState, arg: str) -> None:
|
|||||||
click.echo(f"new session id={state.session_id} name={name}")
|
click.echo(f"new session id={state.session_id} name={name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_rename(state: ReplState, arg: str) -> None:
|
||||||
|
name = arg.strip()
|
||||||
|
if not name:
|
||||||
|
click.echo("usage: /rename <name>")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
row = await state.rename_current_session(name)
|
||||||
|
except ValueError as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
click.echo(f"renamed session {row.sid} -> {row.name}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_delete(state: ReplState, arg: str) -> None:
|
||||||
|
from plyngent.prompting import NonInteractiveError, confirm_async
|
||||||
|
|
||||||
|
token = arg.strip()
|
||||||
|
if token:
|
||||||
|
try:
|
||||||
|
sid = int(token)
|
||||||
|
except ValueError:
|
||||||
|
click.echo("usage: /delete [session id]")
|
||||||
|
return
|
||||||
|
else:
|
||||||
|
if state.session_id is None:
|
||||||
|
click.echo("error: no active session")
|
||||||
|
return
|
||||||
|
sid = state.session_id
|
||||||
|
try:
|
||||||
|
allowed = await confirm_async(
|
||||||
|
f"Permanently delete session {sid} and all messages?",
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
except NonInteractiveError:
|
||||||
|
click.echo("error: delete requires interactive confirm (or TTY)")
|
||||||
|
return
|
||||||
|
if not allowed:
|
||||||
|
click.echo("delete cancelled")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
was_current = await state.delete_session_and_maybe_replace(sid)
|
||||||
|
except ValueError as exc:
|
||||||
|
click.echo(f"error: {exc}")
|
||||||
|
return
|
||||||
|
if was_current:
|
||||||
|
click.echo(f"deleted session {sid}; new session {state.session_id}")
|
||||||
|
else:
|
||||||
|
click.echo(f"deleted session {sid}")
|
||||||
|
|
||||||
|
|
||||||
|
async def _cmd_export(state: ReplState, arg: str) -> None:
|
||||||
|
from plyngent.cli.export import (
|
||||||
|
encode_session_export_json,
|
||||||
|
format_session_export_md,
|
||||||
|
resolve_export_path,
|
||||||
|
session_export_payload,
|
||||||
|
write_export_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
if state.session_id is None:
|
||||||
|
click.echo("error: no active session")
|
||||||
|
return
|
||||||
|
parts = arg.split()
|
||||||
|
fmt = "md"
|
||||||
|
path_arg: str | None = None
|
||||||
|
if parts:
|
||||||
|
first = parts[0].lower()
|
||||||
|
if first in {"md", "markdown", "json"}:
|
||||||
|
fmt = "json" if first == "json" else "md"
|
||||||
|
path_arg = parts[1] if len(parts) > 1 else None
|
||||||
|
else:
|
||||||
|
path_arg = parts[0]
|
||||||
|
if len(parts) > 1:
|
||||||
|
click.echo("usage: /export [md|json] [path]")
|
||||||
|
return
|
||||||
|
row = await state.memory.get_session(state.session_id)
|
||||||
|
if row is None:
|
||||||
|
click.echo(f"error: session not found: {state.session_id}")
|
||||||
|
return
|
||||||
|
messages = await state.memory.list_messages(state.session_id)
|
||||||
|
out_path = resolve_export_path(state.session_id, fmt, path_arg)
|
||||||
|
if fmt == "json":
|
||||||
|
text = encode_session_export_json(
|
||||||
|
session_export_payload(
|
||||||
|
sid=row.sid,
|
||||||
|
name=row.name,
|
||||||
|
workspace=row.workspace,
|
||||||
|
created_at=row.created_at,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
text = format_session_export_md(
|
||||||
|
messages,
|
||||||
|
sid=row.sid,
|
||||||
|
name=row.name,
|
||||||
|
workspace=row.workspace,
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
written = write_export_file(out_path, text)
|
||||||
|
except OSError as exc:
|
||||||
|
click.echo(f"error: write failed: {exc}")
|
||||||
|
return
|
||||||
|
click.echo(f"exported session {row.sid} ({fmt}) -> {written}")
|
||||||
|
|
||||||
|
|
||||||
async def _cmd_resume(state: ReplState, arg: str) -> None:
|
async def _cmd_resume(state: ReplState, arg: str) -> None:
|
||||||
if not arg.strip():
|
if not arg.strip():
|
||||||
mode = await state.resume_latest_or_new()
|
mode = await state.resume_latest_or_new()
|
||||||
@@ -321,6 +431,9 @@ async def _dispatch_slash(state: ReplState, command: str, arg: str) -> bool:
|
|||||||
"history": lambda: _cmd_history(state, arg),
|
"history": lambda: _cmd_history(state, arg),
|
||||||
"sessions": lambda: _cmd_sessions(state),
|
"sessions": lambda: _cmd_sessions(state),
|
||||||
"new": lambda: _cmd_new(state, arg),
|
"new": lambda: _cmd_new(state, arg),
|
||||||
|
"rename": lambda: _cmd_rename(state, arg),
|
||||||
|
"delete": lambda: _cmd_delete(state, arg),
|
||||||
|
"export": lambda: _cmd_export(state, arg),
|
||||||
"resume": lambda: _cmd_resume(state, arg),
|
"resume": lambda: _cmd_resume(state, arg),
|
||||||
"compact": lambda: _cmd_compact(state, arg),
|
"compact": lambda: _cmd_compact(state, arg),
|
||||||
"provider": lambda: _cmd_provider(state, arg),
|
"provider": lambda: _cmd_provider(state, arg),
|
||||||
|
|||||||
@@ -120,6 +120,26 @@ class ReplState:
|
|||||||
self.session_id = session.sid
|
self.session_id = session.sid
|
||||||
self.agent = self._make_agent()
|
self.agent = self._make_agent()
|
||||||
|
|
||||||
|
async def rename_current_session(self, name: str) -> SessionRow:
|
||||||
|
if self.session_id is None:
|
||||||
|
msg = "no active session"
|
||||||
|
raise ValueError(msg)
|
||||||
|
return await self.memory.rename_session(self.session_id, name)
|
||||||
|
|
||||||
|
async def delete_session_and_maybe_replace(self, sid: int) -> bool:
|
||||||
|
"""Hard-delete ``sid``. If it was current, start a new empty session.
|
||||||
|
|
||||||
|
Returns True when the deleted session was the active one.
|
||||||
|
"""
|
||||||
|
was_current = self.session_id == sid
|
||||||
|
ok = await self.memory.delete_session(sid)
|
||||||
|
if not ok:
|
||||||
|
msg = f"session not found: {sid}"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if was_current:
|
||||||
|
await self.new_session()
|
||||||
|
return was_current
|
||||||
|
|
||||||
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."""
|
"""Load a session; on workspace mismatch, prompt keep / rebind / abort."""
|
||||||
from plyngent.cli.limits import prompt_workspace_mismatch
|
from plyngent.cli.limits import prompt_workspace_mismatch
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from pathlib import Path
|
|||||||
from typing import TYPE_CHECKING, Self
|
from typing import TYPE_CHECKING, Self
|
||||||
|
|
||||||
import msgspec
|
import msgspec
|
||||||
from sqlalchemy import select, text
|
from sqlalchemy import delete, select, text
|
||||||
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
from sqlalchemy.ext.asyncio import AsyncEngine, AsyncSession, async_sessionmaker
|
||||||
|
|
||||||
from plyngent.config.models import DatabaseConfig
|
from plyngent.config.models import DatabaseConfig
|
||||||
@@ -21,6 +21,7 @@ if TYPE_CHECKING:
|
|||||||
DEFAULT_USER_NAME = "local"
|
DEFAULT_USER_NAME = "local"
|
||||||
DEFAULT_USER_EMAIL = "local@localhost"
|
DEFAULT_USER_EMAIL = "local@localhost"
|
||||||
DEFAULT_USER_PASSWORD_HASH = ""
|
DEFAULT_USER_PASSWORD_HASH = ""
|
||||||
|
_SESSION_NAME_MAX = 64
|
||||||
|
|
||||||
|
|
||||||
def normalize_workspace(path: str | Path | None) -> str | None:
|
def normalize_workspace(path: str | Path | None) -> str | None:
|
||||||
@@ -175,6 +176,38 @@ class MemoryStore:
|
|||||||
await session.refresh(row)
|
await session.refresh(row)
|
||||||
return row
|
return row
|
||||||
|
|
||||||
|
async def rename_session(self, sid: int, name: str) -> Session:
|
||||||
|
"""Rename a session (max 64 characters, non-empty after strip)."""
|
||||||
|
cleaned = name.strip()
|
||||||
|
if not cleaned:
|
||||||
|
msg = "session name must be non-empty"
|
||||||
|
raise ValueError(msg)
|
||||||
|
if len(cleaned) > _SESSION_NAME_MAX:
|
||||||
|
msg = f"session name too long (max {_SESSION_NAME_MAX})"
|
||||||
|
raise ValueError(msg)
|
||||||
|
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.name = cleaned
|
||||||
|
row.updated_at = datetime.now(UTC)
|
||||||
|
await session.commit()
|
||||||
|
await session.refresh(row)
|
||||||
|
return row
|
||||||
|
|
||||||
|
async def delete_session(self, sid: int) -> bool:
|
||||||
|
"""Hard-delete a session and its messages. Returns False if missing."""
|
||||||
|
async with self._session_factory() as session:
|
||||||
|
row = await session.get(Session, sid)
|
||||||
|
if row is None:
|
||||||
|
return False
|
||||||
|
# Explicit message delete: SQLite FK cascade may be off without PRAGMA.
|
||||||
|
_ = await session.execute(delete(Message).where(Message.sid == sid))
|
||||||
|
await session.delete(row)
|
||||||
|
await session.commit()
|
||||||
|
return True
|
||||||
|
|
||||||
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)
|
||||||
|
|||||||
@@ -0,0 +1,45 @@
|
|||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import UTC, datetime
|
||||||
|
|
||||||
|
from plyngent.cli.export import (
|
||||||
|
encode_session_export_json,
|
||||||
|
format_session_export_md,
|
||||||
|
session_export_payload,
|
||||||
|
)
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||||
|
|
||||||
|
|
||||||
|
def test_format_session_export_md() -> None:
|
||||||
|
text = format_session_export_md(
|
||||||
|
[
|
||||||
|
UserChatMessage(content="hi"),
|
||||||
|
AssistantChatMessage(content="yo", reasoning_content="plan"),
|
||||||
|
],
|
||||||
|
sid=7,
|
||||||
|
name="demo",
|
||||||
|
workspace="/tmp/ws",
|
||||||
|
)
|
||||||
|
assert "# Session 7: demo" in text
|
||||||
|
assert "## user" in text
|
||||||
|
assert "hi" in text
|
||||||
|
assert "### reasoning" in text
|
||||||
|
assert "plan" in text
|
||||||
|
assert "yo" in text
|
||||||
|
|
||||||
|
|
||||||
|
def test_session_export_json_roundtrip() -> None:
|
||||||
|
messages = [UserChatMessage(content="a")]
|
||||||
|
now = datetime.now(UTC)
|
||||||
|
payload = session_export_payload(
|
||||||
|
sid=7,
|
||||||
|
name="demo",
|
||||||
|
workspace="/tmp/ws",
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
messages=messages,
|
||||||
|
)
|
||||||
|
assert isinstance(payload, dict)
|
||||||
|
raw = encode_session_export_json(payload)
|
||||||
|
assert "7" in raw
|
||||||
|
assert "demo" in raw
|
||||||
@@ -116,6 +116,64 @@ async def test_tools_toggle(state: ReplState) -> None:
|
|||||||
assert state.tools_enabled is False
|
assert state.tools_enabled is False
|
||||||
|
|
||||||
|
|
||||||
|
async def test_rename_slash(state: ReplState) -> None:
|
||||||
|
sid = state.session_id
|
||||||
|
assert sid is not None
|
||||||
|
assert await handle_slash(state, "/rename my-chat") is True
|
||||||
|
row = await state.memory.get_session(sid)
|
||||||
|
assert row is not None
|
||||||
|
assert row.name == "my-chat"
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_slash_confirm(
|
||||||
|
state: ReplState,
|
||||||
|
capsys: pytest.CaptureFixture[str],
|
||||||
|
) -> None:
|
||||||
|
from plyngent.prompting import temporary_backend
|
||||||
|
from tests.test_prompting import ScriptedBackend
|
||||||
|
|
||||||
|
# Delete a non-current session so SQLite cannot reuse the same sid as "current".
|
||||||
|
victim = state.session_id
|
||||||
|
assert victim is not None
|
||||||
|
assert await handle_slash(state, "/new keep") is True
|
||||||
|
current = state.session_id
|
||||||
|
assert current != victim
|
||||||
|
|
||||||
|
with temporary_backend(ScriptedBackend([], confirms=[False])):
|
||||||
|
assert await handle_slash(state, f"/delete {victim}") is True
|
||||||
|
assert await state.memory.get_session(victim) is not None
|
||||||
|
assert "cancelled" in capsys.readouterr().out
|
||||||
|
|
||||||
|
with temporary_backend(ScriptedBackend([], confirms=[True])):
|
||||||
|
assert await handle_slash(state, f"/delete {victim}") is True
|
||||||
|
assert await state.memory.get_session(victim) is None
|
||||||
|
assert state.session_id == current
|
||||||
|
out = capsys.readouterr().out
|
||||||
|
assert "deleted" in out
|
||||||
|
assert "new session" not in out
|
||||||
|
|
||||||
|
|
||||||
|
async def test_export_slash(state: ReplState, tmp_path: Path, capsys: pytest.CaptureFixture[str]) -> None:
|
||||||
|
from plyngent.lmproto.openai_compatible.model import AssistantChatMessage, UserChatMessage
|
||||||
|
|
||||||
|
assert state.session_id is not None
|
||||||
|
_ = await state.memory.append_message(state.session_id, UserChatMessage(content="hi"))
|
||||||
|
_ = await state.memory.append_message(state.session_id, AssistantChatMessage(content="yo"))
|
||||||
|
path = tmp_path / "out.md"
|
||||||
|
assert await handle_slash(state, f"/export md {path}") is True
|
||||||
|
text = path.read_text(encoding="utf-8")
|
||||||
|
assert "Session" in text
|
||||||
|
assert "hi" in text
|
||||||
|
assert "yo" in text
|
||||||
|
assert str(path.resolve()) in capsys.readouterr().out
|
||||||
|
|
||||||
|
jpath = tmp_path / "out.json"
|
||||||
|
assert await handle_slash(state, f"/export json {jpath}") is True
|
||||||
|
raw = jpath.read_text(encoding="utf-8")
|
||||||
|
assert '"session_id"' in raw
|
||||||
|
assert "hi" in raw
|
||||||
|
|
||||||
|
|
||||||
async def test_stream_toggle(state: ReplState) -> None:
|
async def test_stream_toggle(state: ReplState) -> None:
|
||||||
assert state.agent.stream is True
|
assert state.agent.stream is True
|
||||||
assert await handle_slash(state, "/stream off") is True
|
assert await handle_slash(state, "/stream off") is True
|
||||||
|
|||||||
@@ -98,6 +98,37 @@ 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_rename_session(store: MemoryStore) -> None:
|
||||||
|
session = await store.create_session(name="old")
|
||||||
|
updated = await store.rename_session(session.sid, " new name ")
|
||||||
|
assert updated.name == "new name"
|
||||||
|
again = await store.get_session(session.sid)
|
||||||
|
assert again is not None
|
||||||
|
assert again.name == "new name"
|
||||||
|
try:
|
||||||
|
await store.rename_session(session.sid, " ")
|
||||||
|
raise AssertionError("expected ValueError")
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "non-empty" in str(exc)
|
||||||
|
try:
|
||||||
|
await store.rename_session(999_999, "x")
|
||||||
|
raise AssertionError("expected ValueError")
|
||||||
|
except ValueError as exc:
|
||||||
|
assert "not found" in str(exc)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_delete_session_cascades_messages(store: MemoryStore) -> None:
|
||||||
|
from plyngent.lmproto.openai_compatible.model import UserChatMessage
|
||||||
|
|
||||||
|
session = await store.create_session(name="gone")
|
||||||
|
_ = await store.append_message(session.sid, UserChatMessage(content="a"))
|
||||||
|
_ = await store.append_message(session.sid, UserChatMessage(content="b"))
|
||||||
|
assert await store.delete_session(session.sid) is True
|
||||||
|
assert await store.get_session(session.sid) is None
|
||||||
|
assert await store.list_messages(session.sid) == []
|
||||||
|
assert await store.delete_session(session.sid) is False
|
||||||
|
|
||||||
|
|
||||||
async def test_update_session_workspace(store: MemoryStore, tmp_path: object) -> None:
|
async def test_update_session_workspace(store: MemoryStore, tmp_path: object) -> None:
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user