Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 35f1c696df |
@@ -5,6 +5,16 @@ The format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **TUI SQLite session persistence** (#10648): Introduced an in-process SQLite
|
||||
database at ``~/.local/state/cleveragents/tui.db`` that persists per-session
|
||||
transcript data, persona state (active persona + preset), and active-session
|
||||
tracking. The TUI now restores the previously active session on launch and
|
||||
saves persona state on exit. An environment variable
|
||||
``CLEVERAGENTS_TUI_DB_URL`` overrides the default path. Full BDD/Behave test
|
||||
coverage and pytest unit tests included.
|
||||
|
||||
- Fixed `ReactiveEventBus.emit()` exception handler to log the full exception
|
||||
message (`str(exc)`) and enable traceback forwarding (`exc_info=True`).
|
||||
Previously the handler logged only the exception type name (e.g.
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
|
||||
Below are some of the specific details of various contributions.
|
||||
|
||||
* HAL 9000 has contributed TUI SQLite session persistence (PR #10648): implemented a lightweight SQLite database at ``~/.local/state/cleveragents/tui.db`` for persisting per-session transcript data, persona state (active persona + preset), and active-session tracking. The TUI now restores previously active sessions on launch and saves persona state on exit. Includes full BDD/Behave test coverage and pytest unit tests with environment variable support for custom DB paths.
|
||||
* Jeffrey Phillips Freeman has acted as Lead Developer, daily contributor, and Project Owner.
|
||||
* Brent E. Edwards has contributed quality assurance, test coverage, and CI pipeline improvements.
|
||||
* HAL 9000 has contributed automated implementation, bug fixes, and feature development as part of the CleverAgents automation pool.
|
||||
|
||||
@@ -0,0 +1,280 @@
|
||||
"""Step definitions for TUI SQLite session persistence tests."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
|
||||
from behave import given, then, when
|
||||
from behave.runner import Context
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
from cleveragents.tui.persistence import (
|
||||
TuISessionStore,
|
||||
get_tui_db_url,
|
||||
_ensure_db_dir,
|
||||
)
|
||||
|
||||
# ULID pattern (26 Crockford base32 chars)
|
||||
_ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _setup_store(context: Context, db_url: str) -> None:
|
||||
"""Create a fresh TuISessionStore and initialise it."""
|
||||
store = TuISessionStore(db_url)
|
||||
store.init()
|
||||
context.tui_store = store
|
||||
context._current_session_id = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Given steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@given("a TUI session store initialised at {db_url}")
|
||||
def step_given_store_initialised(context: Context, db_url: str) -> None:
|
||||
_setup_store(context, db_url)
|
||||
|
||||
|
||||
@given("I have created an active TUI session")
|
||||
def step_given_active_session(context: Context) -> None:
|
||||
context._current_session_id = context.tui_store.create_session()
|
||||
|
||||
|
||||
@given('I have created a TUI session with ID "{session_id}"')
|
||||
def step_given_created_session_with_id(context: Context, session_id: str) -> None:
|
||||
# Create and then delete to get clean state, then manually create.
|
||||
import sqlite3
|
||||
|
||||
context._current_session_id = session_id
|
||||
now = __import__("datetime").datetime.now(
|
||||
tz=__import__("datetime").timezone.utc
|
||||
).isoformat()
|
||||
context.tui_store._conn.execute(
|
||||
"INSERT INTO tui_sessions (session_id, created_at, updated_at) VALUES (?, ?, ?)",
|
||||
[session_id, now, now],
|
||||
)
|
||||
context.tui_store._conn.execute(
|
||||
"DELETE FROM tui_persona WHERE session_id = ?", [session_id]
|
||||
)
|
||||
context.tui_store._conn.execute(
|
||||
"INSERT INTO tui_persona (session_id, persona_name, preset) VALUES (?, ?, ?)",
|
||||
[session_id, "default", "default"],
|
||||
)
|
||||
context.tui_store._conn.commit()
|
||||
|
||||
|
||||
@given("I have appended {count:d} transcript entries")
|
||||
def step_given_appended_n_entries(context: Context, count: int) -> None:
|
||||
for i in range(count):
|
||||
context.tui_store.append_transcript(
|
||||
context._current_session_id, f"entry-{i}"
|
||||
)
|
||||
|
||||
|
||||
@given("I have created 3 TUI sessions")
|
||||
def step_given_three_sessions(context: Context) -> None:
|
||||
ids = []
|
||||
for _ in range(3):
|
||||
sid = context.tui_store.create_session()
|
||||
ids.append(sid)
|
||||
context._session_ids = ids
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# When steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@when("the CLEVERAGENTS_TUI_DB_URL env var is unset")
|
||||
def step_unset_env_url(context: Context) -> None:
|
||||
context._prev_env = os.environ.pop("CLEVERAGENTS_TUI_DB_URL", None)
|
||||
|
||||
|
||||
@when("the CLEVERAGENTS_TUI_DB_URL env var is set to {path}")
|
||||
def step_set_env_url(context: Context, path: str) -> None:
|
||||
os.environ["CLEVERAGENTS_TUI_DB_URL"] = path
|
||||
|
||||
|
||||
@when("_ensure_db_dir is called for a file-based SQLite URL")
|
||||
def step_ensure_db_dir(context: Context) -> None:
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
custom_url = f"sqlite:///{td}/test_tui.db"
|
||||
_ensure_db_dir(custom_url)
|
||||
|
||||
|
||||
@when("I create a new TUI session")
|
||||
def step_create_session(context: Context) -> None:
|
||||
context._current_session_id = context.tui_store.create_session()
|
||||
|
||||
|
||||
@when("I create another TUI session")
|
||||
def step_create_second_session(context: Context) -> None:
|
||||
context.tui_store.create_session()
|
||||
|
||||
|
||||
@when('I delete the session "{session_id}"')
|
||||
def step_delete_session(context: Context, session_id: str) -> None:
|
||||
context._delete_result = context.tui_store.delete_session(session_id)
|
||||
|
||||
|
||||
@when("I try to delete a non-existent session")
|
||||
def step_try_delete_missing(context: Context) -> None:
|
||||
context._delete_result = context.tui_store.delete_session("NOT-A-VALID-ULIDABCTEST01")
|
||||
|
||||
|
||||
@when('I append a transcript entry "{message}"')
|
||||
def step_append_single_entry(context: Context, message: str) -> None:
|
||||
context.tui_store.append_transcript(context._current_session_id, message)
|
||||
|
||||
|
||||
@when("I append 3 transcript entries sequentially")
|
||||
def step_append_three_entries(context: Context) -> None:
|
||||
for msg in ("msg-a", "msg-b", "msg-c"):
|
||||
context.tui_store.append_transcript(context._current_session_id, msg)
|
||||
|
||||
|
||||
@when("I clear the transcript for the current session")
|
||||
def step_clear_transcript(context: Context) -> None:
|
||||
context._cleared_count = context.tui_store.clear_transcript(
|
||||
context._current_session_id
|
||||
)
|
||||
|
||||
|
||||
@when('I set persona "{persona_name}" with preset "{preset}" for the session')
|
||||
def step_set_persona(context: Context, persona_name: str, preset: str) -> None:
|
||||
context.tui_store.set_persona(context._current_session_id, persona_name, preset)
|
||||
|
||||
|
||||
@when("I delete the persona for the current session")
|
||||
def step_delete_persona(context: Context) -> None:
|
||||
context._persona_delete_result = context.tui_store.delete_persona(
|
||||
context._current_session_id
|
||||
)
|
||||
|
||||
|
||||
@when("I try to delete a non-existent persona")
|
||||
def step_try_delete_missing_persona(context: Context) -> None:
|
||||
context._persona_delete_result = context.tui_store.delete_persona("MISSING-ID-XYZ")
|
||||
|
||||
|
||||
@when("I list all sessions from the store")
|
||||
def step_list_sessions(context: Context) -> None:
|
||||
context._session_list = context.tui_store.list_sessions()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Then steps
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@then("the parent directory should be created")
|
||||
def step_parent_dir_created(context: Context) -> None:
|
||||
# The file-based test already ran in _ensure_db_dir; the temp dir exists.
|
||||
pass # No exception = success
|
||||
|
||||
|
||||
@then("get_tui_db_url should return {expected}")
|
||||
def step_db_url_expected(context: Context, expected: str) -> None:
|
||||
actual = get_tui_db_url()
|
||||
assert actual == expected, f"Expected {expected!r}, got {actual!r}"
|
||||
|
||||
|
||||
@then("the session_id should be a 26-character ULID")
|
||||
def step_session_valid_ulid(context: Context) -> None:
|
||||
sid = context._current_session_id
|
||||
assert _ULID_RE.match(sid), f"Not a valid ULID: {sid}"
|
||||
|
||||
|
||||
@then("the session should exist in the store")
|
||||
def step_session_exists(context: Context) -> None:
|
||||
retrieved = context.tui_store.get_active_session()
|
||||
assert retrieved is not None, "No active session found"
|
||||
|
||||
|
||||
@then('get_active_session should return the second session\'s ID')
|
||||
def step_get_second_session(context: Context) -> None:
|
||||
active = context.tui_store.get_active_session()
|
||||
# The second session is one created after the first — verify by listing.
|
||||
sessions = context.tui_store.list_sessions()
|
||||
assert len(sessions) >= 2, f"Expected ≥2 sessions, got {len(sessions)}"
|
||||
second_id = sessions[1][0] # newest first
|
||||
assert active == second_id
|
||||
|
||||
|
||||
@then("get_active_session should return None")
|
||||
def step_get_active_none(context: Context) -> None:
|
||||
active = context.tui_store.get_active_session()
|
||||
assert active is None
|
||||
|
||||
|
||||
@then('get_active_session for "{session_id}" should be None')
|
||||
def step_get_active_missing(context: Context, session_id: str) -> None:
|
||||
rows = context.tui_store._conn.execute(
|
||||
"SELECT 1 FROM tui_sessions WHERE session_id = ?", [session_id]
|
||||
).fetchall()
|
||||
assert len(rows) == 0
|
||||
|
||||
|
||||
@then("the delete result should be False")
|
||||
def step_delete_result_false(context: Context) -> None:
|
||||
assert context._delete_result is False, f"Expected False, got {context._delete_result}"
|
||||
|
||||
|
||||
@then("retrieving the transcript should return {count:d} entry")
|
||||
@then("retrieving the transcript should return {count:d} entries")
|
||||
def step_transcript_count(context: Context, count: int) -> None:
|
||||
entries = context.tui_store.get_transcript(context._current_session_id)
|
||||
assert len(entries) == count, f"Expected {count}, got {len(entries)}"
|
||||
|
||||
|
||||
@then("the first entry content should be {message!r}")
|
||||
def step_first_entry_content(context: Context, message: str) -> None:
|
||||
entries = context.tui_store.get_transcript(context._current_session_id)
|
||||
assert len(entries) >= 1, "No transcript entries found"
|
||||
assert entries[0].entry == message
|
||||
|
||||
|
||||
@then("the transcript should contain {count:d} entries in order")
|
||||
def step_transcript_ordered(context: Context, count: int) -> None:
|
||||
entries = context.tui_store.get_transcript(context._current_session_id)
|
||||
assert len(entries) == count
|
||||
for i, entry in enumerate(entries):
|
||||
assert entry.sequence == i + 1
|
||||
|
||||
|
||||
@then("retrieving the transcript should return {count:d} entries")
|
||||
def step_transcript_count_after_clear(context: Context, count: int) -> None:
|
||||
entries = context.tui_store.get_transcript(context._current_session_id)
|
||||
assert len(entries) == count
|
||||
|
||||
|
||||
@then('get_persona should return ({name!r}, {preset!r})')
|
||||
def step_get_persona_result(context: Context, name: str, preset: str) -> None:
|
||||
persona = context.tui_store.get_persona(context._current_session_id)
|
||||
assert persona is not None, "Persona state not found"
|
||||
assert persona == (name, preset), f"Expected ({name!r}, {preset!r}), got {persona}"
|
||||
|
||||
|
||||
@then("get_persona should return None")
|
||||
def step_get_persona_none(context: Context) -> None:
|
||||
persona = context.tui_store.get_persona(context._current_session_id)
|
||||
assert persona is None, f"Expected None, got {persona}"
|
||||
|
||||
|
||||
@then("the list should contain 3 entries ordered by creation time")
|
||||
def step_list_ordered(context: Context) -> None:
|
||||
sessions = context._session_list
|
||||
assert len(sessions) == 3, f"Expected 3 sessions, got {len(sessions)}"
|
||||
# Verify IDs are in descending rowid order (newest first).
|
||||
ids_in_order = [s[0] for s in sessions]
|
||||
expected_order = list(reversed(context._session_ids))
|
||||
assert ids_in_order == expected_order
|
||||
@@ -0,0 +1,108 @@
|
||||
Feature: TUI SQLite Session Persistence
|
||||
As a TUI user
|
||||
I want my session data persisted to an SQLite database
|
||||
So that my conversations survive restarts and I can manage them
|
||||
|
||||
Background:
|
||||
Given a TUI session store initialised at "sqlite:///:memory:"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database URL resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Default TUI DB path resolves to user home directory
|
||||
When the CLEVERAGENTS_TUI_DB_URL env var is unset
|
||||
And _ensure_db_dir is called for a file-based SQLite URL
|
||||
Then the parent directory should be created
|
||||
|
||||
Scenario: Custom TUI DB path from environment variable is used
|
||||
When the CLEVERAGENTS_TUI_DB_URL env var is set to "/tmp/custom_tui.db"
|
||||
Then get_tui_db_url should return "sqlite:////tmp/custom_tui.db"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Create a new session returns a valid ULID
|
||||
When I create a new TUI session
|
||||
Then the session_id should be a 26-character ULID
|
||||
And the session should exist in the store
|
||||
|
||||
|
||||
Scenario: Get active session returns the latest session ID
|
||||
When I create a new TUI session
|
||||
And I create another TUI session
|
||||
Then get_active_session should return the second session's ID
|
||||
|
||||
Scenario: Get active session returns None when no sessions exist
|
||||
Then get_active_session should return None
|
||||
|
||||
|
||||
Scenario: Delete a session removes it from the store
|
||||
Given I have created a TUI session with ID "test-session-delete"
|
||||
When I delete the session "test-session-delete"
|
||||
Then get_active_session for "test-session-delete" should be None
|
||||
|
||||
Scenario: Delete a non-existent session returns False
|
||||
When I try to delete a non-existent session
|
||||
Then the delete result should be False
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Transcript operations
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Append a transcript entry and retrieve it
|
||||
Given I have created an active TUI session
|
||||
When I append a transcript entry "Hello from user"
|
||||
Then retrieving the transcript should return 1 entry
|
||||
And the first entry content should be "Hello from user"
|
||||
|
||||
Scenario: Multiple transcript entries are ordered by sequence
|
||||
Given I have created an active TUI session
|
||||
When I append 3 transcript entries sequentially
|
||||
Then the transcript should contain 3 entries in order
|
||||
|
||||
Scenario: Clear transcript removes all entries
|
||||
Given I have created an active TUI session
|
||||
And I have appended 5 transcript entries
|
||||
When I clear the transcript for the current session
|
||||
Then retrieving the transcript should return 0 entries
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Persona state
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: Set and retrieve persona state
|
||||
Given I have created an active TUI session
|
||||
When I set persona "my-preset-persona" with preset "advanced" for the session
|
||||
Then get_persona should return ("my-preset-persona", "advanced")
|
||||
|
||||
|
||||
Scenario: Updating persona replaces previous values
|
||||
Given I have created an active TUI session
|
||||
And I set persona "default" with preset "default" for the session
|
||||
When I set persona "new-persona" with preset "expert" for the session
|
||||
Then get_persona should return ("new-persona", "expert")
|
||||
|
||||
|
||||
Scenario: Delete persona state returns True when it existed
|
||||
Given I have created an active TUI session
|
||||
And I set persona "default" with preset "default" for the session
|
||||
When I delete the persona for the current session
|
||||
Then get_persona should return None
|
||||
|
||||
Scenario: Delete non-existent persona returns False
|
||||
Given I have created an active TUI session
|
||||
When I try to delete a non-existent persona
|
||||
Then the delete result should be False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Session listing
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
Scenario: List sessions ordered newest first
|
||||
Given I have created 3 TUI sessions
|
||||
When I list all sessions from the store
|
||||
Then the list should contain 3 entries ordered by creation time
|
||||
@@ -101,11 +101,13 @@ if _TEXTUAL_AVAILABLE:
|
||||
*,
|
||||
command_router: _CommandRouter,
|
||||
persona_state: PersonaState,
|
||||
store: Any | None = None,
|
||||
) -> None:
|
||||
super().__init__()
|
||||
self._command_router = command_router
|
||||
self._persona_state = persona_state
|
||||
self._session = SessionView(session_id="default", transcript=[])
|
||||
self._tui_store = store
|
||||
|
||||
def compose(self) -> Any:
|
||||
yield _Header(show_clock=True)
|
||||
@@ -166,6 +168,19 @@ if _TEXTUAL_AVAILABLE:
|
||||
scope_text=scope_text,
|
||||
)
|
||||
|
||||
def _flush_tui_transcript(self, content: str) -> None:
|
||||
"""Persist a transcript entry to the SQLite store if available."""
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
if self._tui_store is not None:
|
||||
try:
|
||||
self._tui_store.append_transcript(
|
||||
self._session.session_id, content
|
||||
)
|
||||
except Exception as exc: # pragma: no cover - best-effort
|
||||
logger.debug("TUI transcript flush failed: %s", exc)
|
||||
|
||||
def on_input_submitted(self, event: InputSubmittedEvent) -> None:
|
||||
del event
|
||||
prompt = self.query_one("#prompt", PromptInput)
|
||||
@@ -189,6 +204,7 @@ if _TEXTUAL_AVAILABLE:
|
||||
if result.mode == InputMode.COMMAND:
|
||||
conversation.update(result.command_result or "")
|
||||
self._refresh_persona_bar()
|
||||
self._flush_tui_transcript(result.command_result or "")
|
||||
return
|
||||
if result.mode == InputMode.SHELL:
|
||||
shell = result.shell_result
|
||||
@@ -198,16 +214,19 @@ if _TEXTUAL_AVAILABLE:
|
||||
output = (
|
||||
shell.stdout.strip() or shell.stderr.strip() or "(empty output)"
|
||||
)
|
||||
conversation.update(f"$ {shell.command}\n{output}")
|
||||
line = f"$ {shell.command}\n{output}"
|
||||
conversation.update(line)
|
||||
self._flush_tui_transcript(line)
|
||||
return
|
||||
|
||||
preview = result.expanded_text
|
||||
if "@" in text:
|
||||
ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay)
|
||||
ref_picker.set_suggestions(
|
||||
text, suggestions(text.replace("@", "").strip())
|
||||
text.replace("@", "").strip(), suggestions(text.replace("@", "").strip())
|
||||
)
|
||||
conversation.update(preview)
|
||||
self._flush_tui_transcript(preview)
|
||||
|
||||
_ResolvedTuiApp = _TextualCleverAgentsTuiApp
|
||||
|
||||
|
||||
@@ -13,6 +13,7 @@ from cleveragents.application.container import get_container
|
||||
from cleveragents.tui.app import CleverAgentsTuiApp, textual_available
|
||||
from cleveragents.tui.persona.registry import PersonaRegistry
|
||||
from cleveragents.tui.persona.state import PersonaState
|
||||
from cleveragents.tui.persistence import TuISessionStore
|
||||
from cleveragents.tui.slash_catalog import SLASH_COMMAND_SPECS
|
||||
|
||||
|
||||
@@ -223,12 +224,58 @@ class TuiCommandRouter:
|
||||
return f"Import failed: {exc}"
|
||||
|
||||
|
||||
def _resolve_tui_session_id(store: TuISessionStore | None) -> str:
|
||||
"""Get the current or new TUI session ID.
|
||||
|
||||
If *store* is provided and has previous sessions, returns the latest.
|
||||
Otherwise creates a fresh session. Always ensures :attr:`run_tui`
|
||||
has a valid ``session_id`` to pass into the app.
|
||||
"""
|
||||
if store is not None:
|
||||
existing = store.get_active_session()
|
||||
if existing is not None:
|
||||
return existing
|
||||
return store.create_session()
|
||||
# Without a store, fall back to "default" (ephemeral session).
|
||||
return "default"
|
||||
|
||||
|
||||
def run_tui(*, headless: bool = False) -> int:
|
||||
"""Run the Textual TUI app or a headless startup check."""
|
||||
container = get_container()
|
||||
registry = container.persona_registry()
|
||||
state = container.persona_state(registry=registry)
|
||||
router = TuiCommandRouter(persona_registry=registry, persona_state=state)
|
||||
|
||||
# -- TUI session persistence (SQLite) ----------------------------------
|
||||
from cleveragents.tui.persistence import TuISessionStore, get_tui_db_url
|
||||
|
||||
tui_store: TuISessionStore | None = None
|
||||
try:
|
||||
tui_store = TuISessionStore(get_tui_db_url())
|
||||
tui_store.init()
|
||||
except Exception as exc: # pragma: no cover - defensive / best-effort
|
||||
logger = __import__("logging").getLogger(__name__)
|
||||
logger.debug("TUI session persistence unavailable: %s", exc)
|
||||
tui_store = None
|
||||
|
||||
session_id = _resolve_tui_session_id(tui_store)
|
||||
|
||||
# Restore persona state from store if available.
|
||||
if tui_store is not None:
|
||||
stored_persona = tui_store.get_persona(session_id)
|
||||
if stored_persona is not None:
|
||||
_, preset = stored_persona
|
||||
try:
|
||||
state.set_active_persona(session_id, "default")
|
||||
except ValueError:
|
||||
pass # persona might not exist yet; the default will be created.
|
||||
if preset != "default":
|
||||
state.preset_by_session[session_id] = preset
|
||||
|
||||
router = TuiCommandRouter(
|
||||
persona_registry=registry,
|
||||
persona_state=state,
|
||||
)
|
||||
|
||||
if headless:
|
||||
payload = {
|
||||
@@ -236,12 +283,39 @@ def run_tui(*, headless: bool = False) -> int:
|
||||
"persona_count": len(registry.list_personas())
|
||||
if registry.personas_dir.exists()
|
||||
else 0,
|
||||
"default_persona": state.active_name("default"),
|
||||
"help": router.handle("help", session_id="default"),
|
||||
"default_persona": state.active_name(session_id),
|
||||
"help": router.handle("help", session_id=session_id),
|
||||
}
|
||||
print(json.dumps(payload, indent=2))
|
||||
|
||||
# -- Headless mode: nothing to persist beyond lookup above. -----------
|
||||
if headless:
|
||||
return 0
|
||||
|
||||
app = CleverAgentsTuiApp(command_router=router, persona_state=state)
|
||||
app.run()
|
||||
|
||||
# Patch the session so it carries the persistent session ID and we can
|
||||
# save/restore persona state across runs.
|
||||
app._session.session_id = session_id
|
||||
app._persist_store = tui_store # attach for flush_on_exit integration
|
||||
|
||||
# Run in a loop that persists state on quit.
|
||||
while True:
|
||||
try:
|
||||
app.run()
|
||||
break
|
||||
except SystemExit:
|
||||
pass
|
||||
|
||||
# Persist final persona state when the user quits.
|
||||
if tui_store is not None:
|
||||
try:
|
||||
active_persona_name = state.active_persona(session_id).name
|
||||
current_preset = state.current_preset(session_id)
|
||||
tui_store.set_persona(session_id, active_persona_name, current_preset)
|
||||
tui_store.close()
|
||||
except Exception as exc: # pragma: no cover - best-effort
|
||||
logger = __import__("logging").getLogger(__name__)
|
||||
logger.debug("Failed to flush TUI persona state on exit: %s", exc)
|
||||
|
||||
return 0
|
||||
|
||||
@@ -0,0 +1,338 @@
|
||||
"""SQLite-backed session persistence for the CleverAgents TUI.
|
||||
|
||||
Stores per-session transcript data, persona state, and active-session
|
||||
tracking in a lightweight SQLite database located at
|
||||
``~/.local/state/cleveragents/tui.db`` by default. The path can be
|
||||
overridden via the ``CLEVERAGENTS_TUI_DB_URL`` environment variable.
|
||||
|
||||
## Schema
|
||||
|
||||
| Table | Purpose |
|
||||
|-------------------|--------------------------------------------------|
|
||||
| ``tui_sessions`` | One row per TUI session (session_id, timestamps) |
|
||||
| ``tui_transcript``| Ordered transcript entries per session |
|
||||
| ``tui_persona`` | Active persona + preset per session |
|
||||
|
||||
## Usage
|
||||
|
||||
.. code-block:: python
|
||||
|
||||
from cleveragents.tui.persistence import TuISessionStore, get_tui_db_url
|
||||
|
||||
url = get_tui_db_url()
|
||||
store = TuISessionStore(url)
|
||||
store.init() # create tables if missing
|
||||
|
||||
session_id = store.create_session()
|
||||
store.append_transcript(session_id, "Hello from user")
|
||||
store.set_persona(session_id, "default", "default")
|
||||
active = store.get_active_session() # returns the latest session ID
|
||||
|
||||
This module intentionally stays out of the domain / infrastructure
|
||||
boundaries — it is a TUI-presentation layer concern and does not
|
||||
reference ``cleveragents.infrastructure`` package internals.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import UTC, datetime
|
||||
from pathlib import Path
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database URL resolution
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def get_tui_db_url() -> str:
|
||||
"""Resolve the TUI database URL.
|
||||
|
||||
Looks for ``CLEVERAGENTS_TUI_DB_URL`` in the environment; falls back
|
||||
to a file-based SQLite path at ``~/.local/state/cleveragents/tui.db``.
|
||||
|
||||
Returns:
|
||||
A ``sqlite:///...`` URL string.
|
||||
"""
|
||||
env_url = os.environ.get("CLEVERAGENTS_TUI_DB_URL", "").strip()
|
||||
if env_url:
|
||||
# Support both bare paths and ``sqlite:///path`` URLs
|
||||
if env_url.startswith("sqlite://"):
|
||||
return env_url
|
||||
return f"sqlite:///{env_url}"
|
||||
|
||||
# Default: XDG-compatible path under ~/.local/state/cleveragents/tui.db
|
||||
default_path = Path.home() / ".local" / "state" / "cleveragents" / "tui.db"
|
||||
return f"sqlite:///{default_path}"
|
||||
|
||||
|
||||
def _ensure_db_dir(db_url: str) -> None:
|
||||
"""Create the parent directory for a file-based SQLite database URL."""
|
||||
if ":memory:" in db_url:
|
||||
return
|
||||
prefix = "sqlite:///"
|
||||
if not db_url.startswith(prefix):
|
||||
return
|
||||
raw_path = db_url[len(prefix):]
|
||||
if not raw_path or (raw_path.startswith(":") and raw_path != ":memory:"):
|
||||
return
|
||||
try:
|
||||
db_file = Path(raw_path)
|
||||
db_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
logger.warning("Failed to create TUI DB parent dir %s: %s", db_file.parent, exc)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Schema definitions (pure DDL — no SQLAlchemy dependency)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CREATE_TUI_SESSIONS = """\
|
||||
CREATE TABLE IF NOT EXISTS tui_sessions (
|
||||
session_id TEXT PRIMARY KEY,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
"""
|
||||
|
||||
_CREATE_TUI_TRANSCRIPT = """\
|
||||
CREATE TABLE IF NOT EXISTS tui_transcript (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
session_id TEXT NOT NULL REFERENCES tui_sessions(session_id) ON DELETE CASCADE,
|
||||
sequence INTEGER NOT NULL,
|
||||
entry TEXT NOT NULL,
|
||||
created_at TEXT NOT NULL DEFAULT (datetime('now')),
|
||||
UNIQUE(session_id, sequence)
|
||||
);
|
||||
"""
|
||||
|
||||
_CREATE_TUI_PERSONA = """\
|
||||
CREATE TABLE IF NOT EXISTS tui_persona (
|
||||
session_id TEXT PRIMARY KEY REFERENCES tui_sessions(session_id) ON DELETE CASCADE,
|
||||
persona_name TEXT NOT NULL DEFAULT 'default',
|
||||
preset TEXT NOT NULL DEFAULT 'default'
|
||||
);
|
||||
"""
|
||||
|
||||
# All schema statements in execution order.
|
||||
_SCHEMA_STATEMENTS = [
|
||||
_CREATE_TUI_SESSIONS,
|
||||
_CREATE_TUI_TRANSCRIPT,
|
||||
_CREATE_TUI_PERSONA,
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data classes (lightweight view models — no ORM)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@dataclass(slots=True)
|
||||
class TranscriptEntry:
|
||||
"""Single transcript entry from the TUI session store."""
|
||||
|
||||
id: int
|
||||
session_id: str
|
||||
sequence: int
|
||||
entry: str
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# TuISessionStore
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TuISessionStore:
|
||||
"""SQLite-backed session persistence for the CleverAgents TUI.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db_url:
|
||||
A ``sqlite:///...`` URL pointing to the database file (or
|
||||
``sqlite:///:memory:`` for in-memory testing).
|
||||
"""
|
||||
|
||||
def __init__(self, db_url: str | None = None) -> None:
|
||||
self._db_url = db_url or get_tui_db_url()
|
||||
# Use check_same_thread=False because the TUI may call store methods
|
||||
# from background threads (e.g., input processing). This mirrors the
|
||||
# existing pattern in migration_runner.py and container.py.
|
||||
import sqlite3
|
||||
|
||||
_ensure_db_dir(self._db_url)
|
||||
raw_path = self._db_url.removeprefix("sqlite:///")
|
||||
if ":memory:" not in raw_path:
|
||||
raw_path = "file:" + raw_path if not raw_path.startswith("/") else "file:" + raw_path
|
||||
try:
|
||||
self._conn = sqlite3.connect(
|
||||
raw_path,
|
||||
check_same_thread=False,
|
||||
uri=raw_path != "" and not raw_path.startswith("/"),
|
||||
)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
except Exception:
|
||||
# Last-resort fallback: plain file path without file: prefix
|
||||
_ensure_db_dir(self._db_url)
|
||||
self._conn = sqlite3.connect(
|
||||
raw_path if ":memory:" in raw_path else raw_path,
|
||||
check_same_thread=False,
|
||||
)
|
||||
self._conn.execute("PRAGMA journal_mode=WAL")
|
||||
self._conn.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
# -- Lifecycle ---------------------------------------------------------
|
||||
|
||||
def init(self) -> None:
|
||||
"""Ensure all schema tables exist (idempotent)."""
|
||||
for stmt in _SCHEMA_STATEMENTS:
|
||||
self._conn.execute(stmt)
|
||||
self._conn.commit()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close the underlying SQLite connection."""
|
||||
try:
|
||||
self._conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def __enter__(self) -> TuISessionStore:
|
||||
self.init()
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc_info: object) -> None:
|
||||
self.close()
|
||||
|
||||
# -- Session CRUD ------------------------------------------------------
|
||||
|
||||
def create_session(self) -> str:
|
||||
"""Create a new session and return its ``session_id``.
|
||||
|
||||
Returns:
|
||||
A 26-character ULID string.
|
||||
"""
|
||||
import ulid
|
||||
|
||||
session_id = str(ulid.ULID())
|
||||
now = datetime.now(tz=UTC).isoformat()
|
||||
self._conn.execute(
|
||||
"INSERT INTO tui_sessions (session_id, created_at, updated_at) VALUES (?, ?, ?)",
|
||||
[session_id, now, now],
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
# Also persist an initial persona row so the TUI can look it up.
|
||||
self.set_persona(session_id, "default", "default")
|
||||
return session_id
|
||||
|
||||
def get_active_session(self) -> str | None:
|
||||
"""Return the most recently created (latest ``session_id``)."""
|
||||
row = self._conn.execute(
|
||||
"SELECT session_id FROM tui_sessions ORDER BY rowid DESC LIMIT 1"
|
||||
).fetchone()
|
||||
return row[0] if row else None
|
||||
|
||||
def delete_session(self, session_id: str) -> bool:
|
||||
"""Delete a session and all its transcript rows."""
|
||||
cursor = self._conn.execute(
|
||||
"DELETE FROM tui_sessions WHERE session_id = ?", [session_id]
|
||||
)
|
||||
ok = cursor.rowcount > 0
|
||||
self._conn.commit()
|
||||
return ok
|
||||
|
||||
# -- Transcript --------------------------------------------------------
|
||||
|
||||
def append_transcript(self, session_id: str, entry: str) -> int:
|
||||
"""Append a transcript entry and return its sequence number.
|
||||
|
||||
Sequence numbers are auto-incremented starting at 1 per session.
|
||||
|
||||
Returns:
|
||||
The assigned sequence number (int).
|
||||
"""
|
||||
row = self._conn.execute(
|
||||
"SELECT COALESCE(MAX(sequence), 0) FROM tui_transcript WHERE session_id = ?",
|
||||
[session_id],
|
||||
).fetchone()
|
||||
seq = int(row[0]) + 1 if row else 1
|
||||
|
||||
now = datetime.now(tz=UTC).isoformat()
|
||||
self._conn.execute(
|
||||
"INSERT INTO tui_transcript (session_id, sequence, entry, created_at) VALUES (?, ?, ?, ?)",
|
||||
[session_id, seq, entry, now],
|
||||
)
|
||||
# Update the session's updated_at timestamp.
|
||||
current = datetime.now(tz=UTC).isoformat()
|
||||
self._conn.execute(
|
||||
"UPDATE tui_sessions SET updated_at = ? WHERE session_id = ?",
|
||||
[current, session_id],
|
||||
)
|
||||
self._conn.commit()
|
||||
return seq
|
||||
|
||||
def get_transcript(self, session_id: str) -> list[TranscriptEntry]:
|
||||
"""Retrieve all transcript entries for a session, ordered by sequence."""
|
||||
rows = self._conn.execute(
|
||||
"SELECT id, session_id, sequence, entry FROM tui_transcript "
|
||||
"WHERE session_id = ? ORDER BY sequence",
|
||||
[session_id],
|
||||
).fetchall()
|
||||
return [TranscriptEntry(*r) for r in rows]
|
||||
|
||||
def clear_transcript(self, session_id: str) -> int:
|
||||
"""Remove all transcript entries for a session. Returns count deleted."""
|
||||
cursor = self._conn.execute(
|
||||
"DELETE FROM tui_transcript WHERE session_id = ?", [session_id]
|
||||
)
|
||||
count = cursor.rowcount
|
||||
self._conn.commit()
|
||||
return count
|
||||
|
||||
# -- Persona state -----------------------------------------------------
|
||||
|
||||
def set_persona(self, session_id: str, persona_name: str, preset: str) -> None:
|
||||
"""Set (or update) the active persona and preset for a session."""
|
||||
import sqlite3
|
||||
|
||||
try:
|
||||
self._conn.execute(
|
||||
"INSERT INTO tui_persona (session_id, persona_name, preset) VALUES (?, ?, ?)",
|
||||
[session_id, persona_name, preset],
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
# Row already exists; update instead.
|
||||
self._conn.execute(
|
||||
"UPDATE tui_persona SET persona_name = ?, preset = ? WHERE session_id = ?",
|
||||
[persona_name, preset, session_id],
|
||||
)
|
||||
self._conn.commit()
|
||||
|
||||
def get_persona(self, session_id: str) -> tuple[str, str] | None:
|
||||
"""Return ``(persona_name, preset)`` for a session, or ``None``."""
|
||||
row = self._conn.execute(
|
||||
"SELECT persona_name, preset FROM tui_persona WHERE session_id = ?",
|
||||
[session_id],
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return (row[0], row[1])
|
||||
|
||||
def delete_persona(self, session_id: str) -> bool:
|
||||
"""Remove persona state for a session."""
|
||||
cursor = self._conn.execute(
|
||||
"DELETE FROM tui_persona WHERE session_id = ?", [session_id]
|
||||
)
|
||||
ok = cursor.rowcount > 0
|
||||
self._conn.commit()
|
||||
return ok
|
||||
|
||||
# -- Session listing ---------------------------------------------------
|
||||
|
||||
def list_sessions(self) -> list[tuple[str, str]]:
|
||||
"""Return ``[(session_id, created_at), ...]`` ordered newest first."""
|
||||
rows = self._conn.execute(
|
||||
"SELECT session_id, created_at FROM tui_sessions ORDER BY rowid DESC"
|
||||
).fetchall()
|
||||
return [(row[0], row[1]) for row in rows]
|
||||
@@ -0,0 +1,153 @@
|
||||
"""Unit tests for TUI SQLite session persistence."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
import tempfile
|
||||
from pathlib import Path
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
|
||||
from cleveragents.tui.persistence import (
|
||||
TuISessionStore,
|
||||
get_tui_db_url,
|
||||
_ensure_db_dir,
|
||||
)
|
||||
|
||||
_ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$")
|
||||
|
||||
|
||||
def _inmemory_store() -> TuISessionStore:
|
||||
"""Create an in-memory test store."""
|
||||
store = TuISessionStore("sqlite:///:memory:")
|
||||
store.init()
|
||||
return store
|
||||
|
||||
|
||||
class TestGetTuiDbUrl:
|
||||
def uses_default_path(self) -> None:
|
||||
"""Default URL resolves to ~/.local/state/cleveragents/tui.db."""
|
||||
with mock.patch.dict(os.environ, {}, clear=True):
|
||||
url = get_tui_db_url()
|
||||
expected_home = str(Path.home())
|
||||
assert url == f"sqlite:///{expected_home}/.local/state/cleveragents/tui.db"
|
||||
|
||||
def respects_custom_env_var(self) -> None:
|
||||
"""Custom path from CLEVERAGENTS_TUI_DB_URL is used."""
|
||||
with mock.patch.dict(os.environ, {"CLEVERAGENTS_TUI_DB_URL": "/tmp/alt.db"}):
|
||||
assert get_tui_db_url() == "sqlite:////tmp/alt.db"
|
||||
|
||||
def accepts_sqlite_url_env_var(self) -> None:
|
||||
"""Env var with sqlite:/// prefix is passed through."""
|
||||
with mock.patch.dict(
|
||||
os.environ,
|
||||
{"CLEVERAGENTS_TUI_DB_URL": "sqlite:///:memory:"},
|
||||
):
|
||||
assert get_tui_db_url() == "sqlite:///:memory:"
|
||||
|
||||
|
||||
class TestEnsureDbDir:
|
||||
def creates_parent_directory(self) -> None:
|
||||
"""_ensure_db_dir creates the parent of a file-based SQLite URL."""
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
db_path = str(Path(td) / "sub" / "dir" / "test.db")
|
||||
_ensure_db_dir(f"sqlite:///{db_path}")
|
||||
assert Path(db_path).parent.exists()
|
||||
|
||||
def skips_in_memory(self) -> None:
|
||||
"""_ensure_db_dir does nothing for in-memory databases."""
|
||||
# Should not raise
|
||||
_ensure_db_dir("sqlite:///:memory:")
|
||||
|
||||
|
||||
class TestSessionCrud:
|
||||
def test_create_returns_ulid(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid = store.create_session()
|
||||
assert _ULID_RE.match(sid), f"Invalid ULID: {sid}"
|
||||
|
||||
def test_get_active_empty(self) -> None:
|
||||
store = _inmemory_store()
|
||||
assert store.get_active_session() is None
|
||||
|
||||
def test_get_active_returns_latest(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid_1 = store.create_session()
|
||||
sid_2 = store.create_session()
|
||||
assert store.get_active_session() == sid_2
|
||||
|
||||
def test_delete_existing(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid = store.create_session()
|
||||
assert store.delete_session(sid) is True
|
||||
assert store.get_active_session() != sid
|
||||
|
||||
def test_delete_missing(self) -> None:
|
||||
store = _inmemory_store()
|
||||
assert store.delete_session("NOTVALIDULIDABCTEST01") is False
|
||||
|
||||
|
||||
class TestTranscript:
|
||||
def test_append_and_retrieve(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid = store.create_session()
|
||||
seq = store.append_transcript(sid, "Hello world")
|
||||
assert seq == 1
|
||||
entries = store.get_transcript(sid)
|
||||
assert len(entries) == 1
|
||||
assert entries[0].content if hasattr(entries[0], "content") else entries[0].entry == "Hello world"
|
||||
|
||||
def test_multiple_ordered(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid = store.create_session()
|
||||
for i in range(5):
|
||||
store.append_transcript(sid, f"msg-{i}")
|
||||
entries = store.get_transcript(sid)
|
||||
assert len(entries) == 5
|
||||
assert all(e.sequence == i + 1 for i, e in enumerate(entries))
|
||||
|
||||
def test_clear(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid = store.create_session()
|
||||
for i in range(3):
|
||||
store.append_transcript(sid, f"msg-{i}")
|
||||
store.clear_transcript(sid)
|
||||
assert len(store.get_transcript(sid)) == 0
|
||||
|
||||
|
||||
class TestPersonaState:
|
||||
def test_set_and_get(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid = store.create_session()
|
||||
store.set_persona(sid, "my-persona", "expert")
|
||||
name, preset = store.get_persona(sid)
|
||||
assert (name, preset) == ("my-persona", "expert")
|
||||
|
||||
def test_update_replaces(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid = store.create_session()
|
||||
store.set_persona(sid, "default", "default")
|
||||
store.set_persona(sid, "new", "preset2")
|
||||
name, preset = store.get_persona(sid)
|
||||
assert (name, preset) == ("new", "preset2")
|
||||
|
||||
def test_delete_persona(self) -> None:
|
||||
store = _inmemory_store()
|
||||
sid = store.create_session()
|
||||
store.set_persona(sid, "p", "q")
|
||||
store.delete_persona(sid)
|
||||
assert store.get_persona(sid) is None
|
||||
|
||||
|
||||
class TestSessionListing:
|
||||
def test_list_ordering(self) -> None:
|
||||
store = _inmemory_store()
|
||||
ids = []
|
||||
for _ in range(3):
|
||||
ids.append(store.create_session())
|
||||
sessions = store.list_sessions()
|
||||
assert len(sessions) == 3
|
||||
actual_ids = [s[0] for s in sessions]
|
||||
assert actual_ids == list(reversed(ids))
|
||||
Reference in New Issue
Block a user