core/memory+cli: remember session LLM; readline prompts

Store provider_name/model on sessions (SQLite migrate); restore on
resume and persist on create//model//provider. Startup prefers session
selection over re-asking. ask/choose and provider/model pick use
readline with Tab completion when available.
This commit is contained in:
2026-07-15 14:12:10 +08:00
parent cc298c6cd2
commit 64d288a9b1
15 changed files with 349 additions and 53 deletions
+4
View File
@@ -38,7 +38,11 @@ def test_session_export_json_roundtrip() -> None:
created_at=now,
updated_at=now,
messages=messages,
provider_name="local",
model="tiny",
)
assert payload.get("provider") == "local"
assert payload.get("model") == "tiny"
assert isinstance(payload, dict)
raw = encode_session_export_json(payload)
assert "7" in raw
+17
View File
@@ -173,6 +173,23 @@ async def test_rename_slash(state: ReplState) -> None:
assert row.name == "my-chat"
async def test_model_switch_persists(state: ReplState) -> None:
from plyngent.config.models import ModelConfig
state.provider.models = {
"m1": ModelConfig(),
"m2": ModelConfig(),
}
state.model = "m1"
await state.persist_llm_selection()
assert await handle_slash(state, "/model m2") is True
assert state.model == "m2"
assert state.session_id is not None
row = await state.memory.get_session(state.session_id)
assert row is not None
assert row.model == "m2"
async def test_delete_slash_confirm(
state: ReplState,
capsys: pytest.CaptureFixture[str],
+17 -5
View File
@@ -38,11 +38,23 @@ def test_select_model_from_list() -> None:
assert select_model(provider, preferred="m1") == "m1"
def test_select_model_prompt_when_empty(monkeypatch: pytest.MonkeyPatch) -> None:
def test_select_model_prompt_when_empty() -> None:
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
provider = OpenAIProvider(access_key_or_token="sk")
with temporary_backend(ScriptedBackend(["gpt-test"])):
assert select_model(provider) == "gpt-test"
def _prompt(*_args: object, **_kwargs: object) -> str:
return "gpt-test"
monkeypatch.setattr("click.prompt", _prompt)
assert select_model(provider) == "gpt-test"
def test_select_provider_interactive_choose() -> None:
from plyngent.prompting import temporary_backend
from tests.test_prompting import ScriptedBackend
providers = {
"a": OpenAIProvider(access_key_or_token="sk"),
"b": OpenAICompatibleProvider(access_key_or_token="sk", url="https://x/v1"),
}
with temporary_backend(ScriptedBackend(["2"])):
name, _ = select_provider(providers)
assert name == "b"
+18
View File
@@ -129,6 +129,24 @@ async def test_delete_session_cascades_messages(store: MemoryStore) -> None:
assert await store.delete_session(session.sid) is False
async def test_session_llm_remembered(store: MemoryStore) -> None:
session = await store.create_session(
name="llm",
provider_name="deepseek",
model="deepseek-v4-flash",
)
row = await store.get_session(session.sid)
assert row is not None
assert row.provider_name == "deepseek"
assert row.model == "deepseek-v4-flash"
updated = await store.update_session_llm(session.sid, model="deepseek-v4-pro")
assert updated.provider_name == "deepseek"
assert updated.model == "deepseek-v4-pro"
again = await store.update_session_llm(session.sid, provider_name="other")
assert again.provider_name == "other"
assert again.model == "deepseek-v4-pro"
async def test_update_session_workspace(store: MemoryStore, tmp_path: object) -> None:
from pathlib import Path
+8 -2
View File
@@ -27,8 +27,14 @@ class ScriptedBackend:
def is_interactive(self) -> bool:
return self.interactive
def read_line(self, prompt: str, *, default: str | None = None) -> str:
del prompt
def read_line(
self,
prompt: str,
*,
default: str | None = None,
completions: object = None,
) -> str:
del prompt, completions
if self.lines:
return self.lines.pop(0)
if default is not None: