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`).
|
- **`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`).
|
||||||
|
|||||||
@@ -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:
|
||||||
await state.resume_session(session_id)
|
try:
|
||||||
click.echo(f"resumed session {session_id} ({len(state.agent.messages)} messages)")
|
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:
|
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})")
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
+37
-15
@@ -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:
|
||||||
raise ValueError(msg)
|
choice = prompt_workspace_mismatch(session_id, row.workspace, current)
|
||||||
self._apply_session_workspace(row)
|
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.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"
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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}
|
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
|
||||||
|
|||||||
Reference in New Issue
Block a user