From 5d479c4924d72774a435905d1ad0112da813d241 Mon Sep 17 00:00:00 2001 From: HAL9000 Date: Sun, 19 Apr 2026 00:36:51 +0000 Subject: [PATCH 1/7] feat(tui): implement SQLite session persistence at ~/.local/state/cleveragents/tui.db --- .../steps/tui_session_persistence_steps.py | 200 ++++++++++++++++++ features/tui_session_persistence.feature | 47 ++++ src/cleveragents/tui/persistence/__init__.py | 5 + src/cleveragents/tui/persistence/store.py | 152 +++++++++++++ 4 files changed, 404 insertions(+) create mode 100644 features/steps/tui_session_persistence_steps.py create mode 100644 features/tui_session_persistence.feature create mode 100644 src/cleveragents/tui/persistence/__init__.py create mode 100644 src/cleveragents/tui/persistence/store.py diff --git a/features/steps/tui_session_persistence_steps.py b/features/steps/tui_session_persistence_steps.py new file mode 100644 index 000000000..02327311a --- /dev/null +++ b/features/steps/tui_session_persistence_steps.py @@ -0,0 +1,200 @@ +"""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 diff --git a/features/tui_session_persistence.feature b/features/tui_session_persistence.feature new file mode 100644 index 000000000..9042fb89a --- /dev/null +++ b/features/tui_session_persistence.feature @@ -0,0 +1,47 @@ +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 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..81a8afa2c --- /dev/null +++ b/src/cleveragents/tui/persistence/store.py @@ -0,0 +1,152 @@ +"""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: + 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() -- 2.52.0 From a03dcbfa18790d12ecbd45e3a19e60688fe797ac Mon Sep 17 00:00:00 2001 From: controller-ci-rerun Date: Thu, 11 Jun 2026 00:08:17 -0400 Subject: [PATCH 2/7] chore: re-trigger CI [controller] -- 2.52.0 From e2d302dcaf27190123d2a7066fca104d56487540 Mon Sep 17 00:00:00 2001 From: CleverThis Date: Thu, 11 Jun 2026 19:56:14 -0400 Subject: [PATCH 3/7] fix(tui): resolve format, AmbiguousStep, and coverage gaps in session persistence - Apply ruff format to store.py (split multi-arg conn.execute calls) and steps file (collapse single-arg execute to one line) - Fix AmbiguousStep: add literal quotes to @given/@then patterns so 'a session with id "{session_id}"' is unambiguous vs the 'in the database' variant; update all dependent then-steps consistently - Mark if TYPE_CHECKING block with # pragma: no cover (never executed) - Add scenario + step for default db_path to cover store.py lines 32-34 (uses unittest.mock.patch on Path.home to avoid touching real homedir) ISSUES CLOSED: #10648 --- .../steps/tui_session_persistence_steps.py | 28 ++++++++++++----- features/tui_session_persistence.feature | 4 +++ src/cleveragents/tui/persistence/store.py | 31 +++++++++++++------ 3 files changed, 46 insertions(+), 17 deletions(-) diff --git a/features/steps/tui_session_persistence_steps.py b/features/steps/tui_session_persistence_steps.py index 02327311a..f9c32f8a3 100644 --- a/features/steps/tui_session_persistence_steps.py +++ b/features/steps/tui_session_persistence_steps.py @@ -42,15 +42,13 @@ def step_database_has_tables(context: object) -> None: import sqlite3 with sqlite3.connect(context.db_path) as conn: # type: ignore - cursor = conn.execute( - "SELECT name FROM sqlite_master WHERE type='table'" - ) + 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}") +@given('a session with id "{session_id}" and transcript {transcript}') def step_create_session_with_transcript( context: object, session_id: str, transcript: str ) -> None: @@ -63,7 +61,7 @@ def step_create_session_with_transcript( context.session = session # type: ignore -@given("a session with id {session_id}") +@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=[]) @@ -97,7 +95,7 @@ def step_set_active_session(context: object) -> None: context.store.set_active_session(context.session.session_id) # type: ignore -@then("the active session id is {session_id}") +@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 @@ -130,7 +128,7 @@ def step_verify_list_sessions(context: object) -> None: assert set(listed) == expected_ids -@given("a session with id {session_id} in the database") +@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=[]) @@ -184,7 +182,7 @@ def step_create_new_store_instance(context: object) -> None: context.new_store = TuiSessionStore(db_path=context.db_path) # type: ignore -@then("the active session is {session_id}") +@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 @@ -198,3 +196,17 @@ def step_verify_session_restored(context: object) -> None: 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 index 9042fb89a..6beff0e1a 100644 --- a/features/tui_session_persistence.feature +++ b/features/tui_session_persistence.feature @@ -45,3 +45,7 @@ Feature: TUI Session Persistence 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/store.py b/src/cleveragents/tui/persistence/store.py index 81a8afa2c..2241db63f 100644 --- a/src/cleveragents/tui/persistence/store.py +++ b/src/cleveragents/tui/persistence/store.py @@ -8,7 +8,7 @@ from dataclasses import dataclass from pathlib import Path from typing import TYPE_CHECKING -if TYPE_CHECKING: +if TYPE_CHECKING: # pragma: no cover from cleveragents.tui.app import SessionView @@ -64,10 +64,13 @@ class TuiSessionStore: """ with sqlite3.connect(self.db_path) as conn: transcript_json = json.dumps(session.transcript) - conn.execute(""" + conn.execute( + """ INSERT OR REPLACE INTO sessions (session_id, transcript, updated_at) VALUES (?, ?, CURRENT_TIMESTAMP) - """, (session.session_id, transcript_json)) + """, + (session.session_id, transcript_json), + ) conn.commit() def load_session(self, session_id: str) -> SessionView | None: @@ -80,9 +83,12 @@ class TuiSessionStore: The SessionView if found, None otherwise """ with sqlite3.connect(self.db_path) as conn: - cursor = conn.execute(""" + cursor = conn.execute( + """ SELECT session_id, transcript FROM sessions WHERE session_id = ? - """, (session_id,)) + """, + (session_id,), + ) row = cursor.fetchone() if row is None: @@ -92,6 +98,7 @@ class TuiSessionStore: 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: @@ -101,10 +108,13 @@ class TuiSessionStore: session_id: The ID of the session to set as active """ with sqlite3.connect(self.db_path) as conn: - conn.execute(""" + conn.execute( + """ INSERT OR REPLACE INTO active_session (id, session_id, updated_at) VALUES (1, ?, CURRENT_TIMESTAMP) - """, (session_id,)) + """, + (session_id,), + ) conn.commit() def get_active_session(self) -> str | None: @@ -139,9 +149,12 @@ class TuiSessionStore: session_id: The ID of the session to delete """ with sqlite3.connect(self.db_path) as conn: - conn.execute(""" + conn.execute( + """ DELETE FROM sessions WHERE session_id = ? - """, (session_id,)) + """, + (session_id,), + ) conn.commit() def clear_all(self) -> None: -- 2.52.0 From b13b2d26ba64f7a30a4b910b742b235b152952bd Mon Sep 17 00:00:00 2001 From: controller-ci-rerun Date: Sat, 13 Jun 2026 09:33:53 -0400 Subject: [PATCH 4/7] chore: re-trigger CI [controller] -- 2.52.0 From 0a6386e50381352a54d97518208f46764a3ed357 Mon Sep 17 00:00:00 2001 From: controller-ci-rerun Date: Sat, 13 Jun 2026 10:29:16 -0400 Subject: [PATCH 5/7] chore: re-trigger CI [controller] -- 2.52.0 From d6f01afd84ed35253fe597a278415add0093c8c3 Mon Sep 17 00:00:00 2001 From: controller-ci-rerun Date: Sat, 13 Jun 2026 10:35:40 -0400 Subject: [PATCH 6/7] chore: re-trigger CI [controller] -- 2.52.0 From 75cadad62cdc1a5aad38e39b8dd3e1997f117552 Mon Sep 17 00:00:00 2001 From: controller-ci-rerun Date: Sat, 13 Jun 2026 13:37:34 -0400 Subject: [PATCH 7/7] chore: re-trigger CI [controller] -- 2.52.0