diff --git a/features/steps/tui_session_persistence_steps.py b/features/steps/tui_session_persistence_steps.py new file mode 100644 index 000000000..f9c32f8a3 --- /dev/null +++ b/features/steps/tui_session_persistence_steps.py @@ -0,0 +1,212 @@ +"""Step definitions for TUI session persistence.""" + +from __future__ import annotations + +import tempfile +from pathlib import Path + +from behave import given, then, when + +from cleveragents.tui.app import SessionView +from cleveragents.tui.persistence import TuiSessionStore + + +@given("a clean TUI session database") +def step_clean_database(context: object) -> None: + """Create a clean temporary database for testing.""" + # Create a temporary directory for the test database + temp_dir = tempfile.mkdtemp() + db_path = Path(temp_dir) / "tui.db" + + # Store in context for later use + context.temp_dir = temp_dir # type: ignore + context.db_path = db_path # type: ignore + context.store = TuiSessionStore(db_path=db_path) # type: ignore + + +@when("the TUI session store is initialized") +def step_initialize_store(context: object) -> None: + """Initialize the session store (already done in background).""" + pass + + +@then("the SQLite database file exists at ~/.local/state/cleveragents/tui.db") +def step_database_exists(context: object) -> None: + """Verify the database file exists.""" + assert context.db_path.exists() # type: ignore + + +@then("the database has sessions and active_session tables") +def step_database_has_tables(context: object) -> None: + """Verify the database has the required tables.""" + import sqlite3 + + with sqlite3.connect(context.db_path) as conn: # type: ignore + cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table'") + tables = {row[0] for row in cursor.fetchall()} + assert "sessions" in tables + assert "active_session" in tables + + +@given('a session with id "{session_id}" and transcript {transcript}') +def step_create_session_with_transcript( + context: object, session_id: str, transcript: str +) -> None: + """Create a session with the given id and transcript.""" + # Parse the transcript list from the string + import ast + + transcript_list = ast.literal_eval(transcript) + session = SessionView(session_id=session_id, transcript=transcript_list) + context.session = session # type: ignore + + +@given('a session with id "{session_id}"') +def step_create_session(context: object, session_id: str) -> None: + """Create a session with the given id.""" + session = SessionView(session_id=session_id, transcript=[]) + context.session = session # type: ignore + + +@when("I save the session to the database") +def step_save_session(context: object) -> None: + """Save the session to the database.""" + context.store.save_session(context.session) # type: ignore + + +@then("the session can be loaded from the database") +def step_load_session(context: object) -> None: + """Load the session from the database.""" + loaded = context.store.load_session(context.session.session_id) # type: ignore + assert loaded is not None + context.loaded_session = loaded # type: ignore + + +@then("the loaded session has the same id and transcript") +def step_verify_session_data(context: object) -> None: + """Verify the loaded session matches the original.""" + assert context.loaded_session.session_id == context.session.session_id # type: ignore + assert context.loaded_session.transcript == context.session.transcript # type: ignore + + +@when("I set it as the active session") +def step_set_active_session(context: object) -> None: + """Set the session as active.""" + context.store.set_active_session(context.session.session_id) # type: ignore + + +@then('the active session id is "{session_id}"') +def step_verify_active_session(context: object, session_id: str) -> None: + """Verify the active session id.""" + active = context.store.get_active_session() # type: ignore + assert active == session_id + + +@given("sessions with ids {session_ids}") +def step_create_multiple_sessions(context: object, session_ids: str) -> None: + """Create multiple sessions.""" + import ast + + ids = ast.literal_eval(session_ids) + context.sessions = [ # type: ignore + SessionView(session_id=sid, transcript=[]) for sid in ids + ] + + +@when("I save all sessions to the database") +def step_save_all_sessions(context: object) -> None: + """Save all sessions to the database.""" + for session in context.sessions: # type: ignore + context.store.save_session(session) # type: ignore + + +@then("listing sessions returns all three session ids") +def step_verify_list_sessions(context: object) -> None: + """Verify all sessions are listed.""" + listed = context.store.list_sessions() # type: ignore + expected_ids = {s.session_id for s in context.sessions} # type: ignore + assert set(listed) == expected_ids + + +@given('a session with id "{session_id}" in the database') +def step_create_and_save_session(context: object, session_id: str) -> None: + """Create and save a session.""" + session = SessionView(session_id=session_id, transcript=[]) + context.store.save_session(session) # type: ignore + context.session = session # type: ignore + + +@when("I delete the session") +def step_delete_session(context: object) -> None: + """Delete the session.""" + context.store.delete_session(context.session.session_id) # type: ignore + + +@then("the session can no longer be loaded from the database") +def step_verify_session_deleted(context: object) -> None: + """Verify the session is deleted.""" + loaded = context.store.load_session(context.session.session_id) # type: ignore + assert loaded is None + + +@when("I clear all sessions") +def step_clear_all_sessions(context: object) -> None: + """Clear all sessions.""" + context.store.clear_all() # type: ignore + + +@then("the database has no sessions") +def step_verify_no_sessions(context: object) -> None: + """Verify there are no sessions.""" + listed = context.store.list_sessions() # type: ignore + assert len(listed) == 0 + + +@then("there is no active session") +def step_verify_no_active_session(context: object) -> None: + """Verify there is no active session.""" + active = context.store.get_active_session() # type: ignore + assert active is None + + +@when("I save the session and set it as active") +def step_save_and_set_active(context: object) -> None: + """Save the session and set it as active.""" + context.store.save_session(context.session) # type: ignore + context.store.set_active_session(context.session.session_id) # type: ignore + + +@when("I create a new session store instance") +def step_create_new_store_instance(context: object) -> None: + """Create a new session store instance.""" + context.new_store = TuiSessionStore(db_path=context.db_path) # type: ignore + + +@then('the active session is "{session_id}"') +def step_verify_new_store_active_session(context: object, session_id: str) -> None: + """Verify the active session in the new store.""" + active = context.new_store.get_active_session() # type: ignore + assert active == session_id + + +@then("the session data is restored correctly") +def step_verify_session_restored(context: object) -> None: + """Verify the session data is restored correctly.""" + loaded = context.new_store.load_session(context.session.session_id) # type: ignore + assert loaded is not None + assert loaded.session_id == context.session.session_id # type: ignore + assert loaded.transcript == context.session.transcript # type: ignore + + +@given("the TUI session store is created with default database path") +def step_default_db_path(context: object) -> None: + """Initialize store using the default path, patching home to a temp dir.""" + from unittest.mock import patch + + temp_dir = tempfile.mkdtemp() + temp_home = Path(temp_dir) + with patch.object(Path, "home", return_value=temp_home): + context.store = TuiSessionStore() # type: ignore + context.db_path = ( # type: ignore + temp_home / ".local" / "state" / "cleveragents" / "tui.db" + ) diff --git a/features/tui_session_persistence.feature b/features/tui_session_persistence.feature new file mode 100644 index 000000000..6beff0e1a --- /dev/null +++ b/features/tui_session_persistence.feature @@ -0,0 +1,51 @@ +Feature: TUI Session Persistence + As a user + I want my TUI session state to be persisted to SQLite + So that I can resume my work when I restart the TUI + + Background: + Given a clean TUI session database + + Scenario: Database is created on first launch + When the TUI session store is initialized + Then the SQLite database file exists at ~/.local/state/cleveragents/tui.db + And the database has sessions and active_session tables + + Scenario: Save and load a session + Given a session with id "test-session" and transcript ["hello", "world"] + When I save the session to the database + Then the session can be loaded from the database + And the loaded session has the same id and transcript + + Scenario: Set and get active session + Given a session with id "session-1" + When I set it as the active session + Then the active session id is "session-1" + + Scenario: List all sessions + Given sessions with ids ["session-1", "session-2", "session-3"] + When I save all sessions to the database + Then listing sessions returns all three session ids + + Scenario: Delete a session + Given a session with id "to-delete" in the database + When I delete the session + Then the session can no longer be loaded from the database + + Scenario: Clear all sessions + Given sessions with ids ["session-1", "session-2"] + When I save all sessions to the database + And I clear all sessions + Then the database has no sessions + And there is no active session + + Scenario: Restore last active session on startup + Given a session with id "last-session" and transcript ["msg1", "msg2"] + When I save the session and set it as active + And I create a new session store instance + Then the active session is "last-session" + And the session data is restored correctly + + Scenario: Store uses default database path when none is specified + Given the TUI session store is created with default database path + Then the SQLite database file exists at ~/.local/state/cleveragents/tui.db diff --git a/src/cleveragents/tui/persistence/__init__.py b/src/cleveragents/tui/persistence/__init__.py new file mode 100644 index 000000000..cc9b3f6ad --- /dev/null +++ b/src/cleveragents/tui/persistence/__init__.py @@ -0,0 +1,5 @@ +"""TUI session persistence module using SQLite.""" + +from cleveragents.tui.persistence.store import TuiSessionStore + +__all__ = ["TuiSessionStore"] diff --git a/src/cleveragents/tui/persistence/store.py b/src/cleveragents/tui/persistence/store.py new file mode 100644 index 000000000..2241db63f --- /dev/null +++ b/src/cleveragents/tui/persistence/store.py @@ -0,0 +1,165 @@ +"""SQLite-backed session store for TUI persistence.""" + +from __future__ import annotations + +import json +import sqlite3 +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: # pragma: no cover + from cleveragents.tui.app import SessionView + + +@dataclass(slots=True) +class TuiSessionStore: + """Manages TUI session persistence using SQLite. + + Database location: ~/.local/state/cleveragents/tui.db + """ + + db_path: Path + + def __init__(self, db_path: Path | None = None) -> None: + """Initialize the session store. + + Args: + db_path: Path to the SQLite database. + Defaults to ~/.local/state/cleveragents/tui.db + """ + if db_path is None: + state_dir = Path.home() / ".local" / "state" / "cleveragents" + state_dir.mkdir(parents=True, exist_ok=True) + db_path = state_dir / "tui.db" + + self.db_path = db_path + self._initialize_db() + + def _initialize_db(self) -> None: + """Initialize the SQLite database schema.""" + with sqlite3.connect(self.db_path) as conn: + conn.execute(""" + CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + transcript TEXT NOT NULL, + created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.execute(""" + CREATE TABLE IF NOT EXISTS active_session ( + id INTEGER PRIMARY KEY CHECK (id = 1), + session_id TEXT NOT NULL, + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ) + """) + conn.commit() + + def save_session(self, session: SessionView) -> None: + """Save a session to the database. + + Args: + session: The SessionView to save + """ + with sqlite3.connect(self.db_path) as conn: + transcript_json = json.dumps(session.transcript) + conn.execute( + """ + INSERT OR REPLACE INTO sessions (session_id, transcript, updated_at) + VALUES (?, ?, CURRENT_TIMESTAMP) + """, + (session.session_id, transcript_json), + ) + conn.commit() + + def load_session(self, session_id: str) -> SessionView | None: + """Load a session from the database. + + Args: + session_id: The ID of the session to load + + Returns: + The SessionView if found, None otherwise + """ + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute( + """ + SELECT session_id, transcript FROM sessions WHERE session_id = ? + """, + (session_id,), + ) + row = cursor.fetchone() + + if row is None: + return None + + session_id_result, transcript_json = row + transcript = json.loads(transcript_json) + # Import here to avoid circular imports + from cleveragents.tui.app import SessionView + + return SessionView(session_id=session_id_result, transcript=transcript) + + def set_active_session(self, session_id: str) -> None: + """Set the active session. + + Args: + session_id: The ID of the session to set as active + """ + with sqlite3.connect(self.db_path) as conn: + conn.execute( + """ + INSERT OR REPLACE INTO active_session (id, session_id, updated_at) + VALUES (1, ?, CURRENT_TIMESTAMP) + """, + (session_id,), + ) + conn.commit() + + def get_active_session(self) -> str | None: + """Get the ID of the active session. + + Returns: + The session ID if one is set, None otherwise + """ + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute(""" + SELECT session_id FROM active_session WHERE id = 1 + """) + row = cursor.fetchone() + return row[0] if row else None + + def list_sessions(self) -> list[str]: + """List all session IDs. + + Returns: + A list of all session IDs in the database + """ + with sqlite3.connect(self.db_path) as conn: + cursor = conn.execute(""" + SELECT session_id FROM sessions ORDER BY updated_at DESC + """) + return [row[0] for row in cursor.fetchall()] + + def delete_session(self, session_id: str) -> None: + """Delete a session from the database. + + Args: + session_id: The ID of the session to delete + """ + with sqlite3.connect(self.db_path) as conn: + conn.execute( + """ + DELETE FROM sessions WHERE session_id = ? + """, + (session_id,), + ) + conn.commit() + + def clear_all(self) -> None: + """Clear all sessions from the database.""" + with sqlite3.connect(self.db_path) as conn: + conn.execute("DELETE FROM sessions") + conn.execute("DELETE FROM active_session") + conn.commit()