From 2ff0454d5ed1a8324306cdb66bbb30bdccfc720e Mon Sep 17 00:00:00 2001 From: Jeffrey Phillips Freeman Date: Thu, 2 Apr 2026 09:47:04 +0000 Subject: [PATCH] feat(tui): session-driven interactive editing Implement session-driven interactive editing with SQLite persistence, multi-turn context carry-over, and real-time edit reflection. ISSUES CLOSED: #860 --- features/steps/tui_session_editing_steps.py | 650 +++++++++++++++++++ features/tui_session_editing.feature | 198 ++++++ src/cleveragents/tui/app.py | 43 +- src/cleveragents/tui/commands.py | 18 +- src/cleveragents/tui/session_editing.py | 414 ++++++++++++ src/cleveragents/tui/widgets/conversation.py | 106 +++ 6 files changed, 1424 insertions(+), 5 deletions(-) create mode 100644 features/steps/tui_session_editing_steps.py create mode 100644 features/tui_session_editing.feature create mode 100644 src/cleveragents/tui/session_editing.py create mode 100644 src/cleveragents/tui/widgets/conversation.py diff --git a/features/steps/tui_session_editing_steps.py b/features/steps/tui_session_editing_steps.py new file mode 100644 index 00000000..2388936d --- /dev/null +++ b/features/steps/tui_session_editing_steps.py @@ -0,0 +1,650 @@ +"""Step definitions for tui_session_editing.feature. + +Tests the TUI session-driven interactive editing service: +- Session lifecycle (create, resume, save) +- SQLite persistence across restarts +- Context carry-over for multi-turn conversations +- Real-time edit reflection +""" + +from __future__ import annotations + +import re +import tempfile +from pathlib import Path + +from behave import given, then, when +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker + +from cleveragents.application.services.session_service import PersistentSessionService +from cleveragents.domain.models.core.session import SessionNotFoundError +from cleveragents.infrastructure.database.models import Base +from cleveragents.infrastructure.database.repositories import ( + SessionMessageRepository, + SessionRepository, +) +from cleveragents.tui.session_editing import TuiSessionEditingService + +_ULID_RE = re.compile(r"^[0-9A-HJKMNP-TV-Z]{26}$") + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _make_in_memory_service( + context_window: int = 20, +) -> TuiSessionEditingService: + """Build a TuiSessionEditingService backed by an in-memory SQLite DB.""" + engine = create_engine("sqlite:///:memory:", echo=False) + Base.metadata.create_all(engine) + factory = sessionmaker(bind=engine, expire_on_commit=False) + db_session = factory() + + def get_session(): # type: ignore[return] + return db_session + + session_repo = SessionRepository(get_session) + message_repo = SessionMessageRepository(get_session) + svc = PersistentSessionService(session_repo, message_repo) + return TuiSessionEditingService(svc, context_window=context_window) + + +def _make_file_service( + db_path: Path, + context_window: int = 20, +) -> TuiSessionEditingService: + """Build a TuiSessionEditingService backed by a file-based SQLite DB. + + Uses ``auto_commit=True`` so each operation is committed immediately, + allowing a second service instance to see the data. + """ + from sqlalchemy import inspect as sa_inspect + + url = f"sqlite:///{db_path.absolute()}" + engine = create_engine(url, echo=False) + inspector = sa_inspect(engine) + existing = set(inspector.get_table_names()) + from cleveragents.infrastructure.database.models import ( + SessionMessageModel, + SessionModel, + ) + + tables_to_create = [ + m.__table__ + for m in (SessionModel, SessionMessageModel) + if m.__tablename__ not in existing + ] + if tables_to_create: + Base.metadata.create_all(engine, tables=tables_to_create) + + factory = sessionmaker(bind=engine, expire_on_commit=False) + # Use auto_commit=True so each operation is committed to the file + session_repo = SessionRepository(session_factory=factory, auto_commit=True) + message_repo = SessionMessageRepository(session_factory=factory, auto_commit=True) + svc = PersistentSessionService(session_repo, message_repo) + return TuiSessionEditingService(svc, context_window=context_window) + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given("a TUI session editing service is initialised") +def step_tui_editing_service_init(context): + context.editing_service = _make_in_memory_service() + + +@given("a TUI session editing service with context window {window:d} is initialised") +def step_tui_editing_service_init_window(context, window): + context.editing_service = _make_in_memory_service(context_window=window) + + +@given("a TUI session editing service is initialised with a named database") +def step_tui_editing_service_named_db(context): + context._tmp_dir = tempfile.mkdtemp() + context._db_path = Path(context._tmp_dir) / "test_tui.db" + context.editing_service = _make_file_service(context._db_path) + + +@given("I create a new TUI session") +def step_given_create_session(context): + context.created_session = context.editing_service.create_session() + + +@given('I record an edit with user "{user_text}" and assistant "{assistant_text}"') +def step_given_record_edit(context, user_text, assistant_text): + context.last_edit_session = context.editing_service.record_edit( + user_text=user_text, + assistant_response=assistant_text, + ) + + +@given("I record {count:d} edits") +def step_given_record_n_edits(context, count): + for i in range(count): + context.editing_service.record_edit( + user_text=f"user message {i}", + assistant_response=f"assistant response {i}", + ) + + +# --------------------------------------------------------------------------- +# When steps +# --------------------------------------------------------------------------- + + +@when("I create a new TUI session") +def step_when_create_session(context): + context.created_session = context.editing_service.create_session() + + +@when('I create a new TUI session with actor "{actor_name}"') +def step_when_create_session_with_actor(context, actor_name): + context.created_session = context.editing_service.create_session( + actor_name=actor_name + ) + + +@when("I resume the session by its ID using a fresh service") +def step_when_resume_session_fresh(context): + # Create a fresh service pointing to the same in-memory DB is not possible + # (in-memory DBs are per-connection), so we use the same service instance + # but create a new TuiSessionEditingService wrapping the same underlying svc. + fresh_editing = TuiSessionEditingService( + context.editing_service._svc, + context_window=20, + ) + context.resumed_session = fresh_editing.resume_session( + context.created_session.session_id + ) + context.editing_service = fresh_editing + + +@when("I resume the session by its ID using the fresh service") +def step_when_resume_session_fresh_named(context): + context.resumed_session = context.fresh_editing_service.resume_session( + context.created_session.session_id + ) + + +@when("I save the active session") +def step_when_save_session(context): + context.save_result = context.editing_service.save_session() + + +@when("I call get_or_create_session with no ID") +def step_when_get_or_create_no_id(context): + context.created_session = context.editing_service.get_or_create_session() + + +@when("I call get_or_create_session with the existing session ID") +def step_when_get_or_create_existing(context): + original_id = context.created_session.session_id + context.original_session_id = original_id + context.created_session = context.editing_service.get_or_create_session( + session_id=original_id + ) + + +@when("I call get_or_create_session with a non-existent ID") +def step_when_get_or_create_nonexistent(context): + context.created_session = context.editing_service.get_or_create_session( + session_id="01AAAAAAAAAAAAAAAAAAAAAAAAA" + ) + + +@when("I create a fresh service pointing to the same database") +def step_when_fresh_service_same_db(context): + context.fresh_editing_service = _make_file_service(context._db_path) + + +@when("I get the context window") +def step_when_get_context_window(context): + context.context_window = context.editing_service.get_context_window() + + +@when("I get the context window with no active session") +def step_when_get_context_window_no_session(context): + context.context_window = context.editing_service.get_context_window() + + +@when('I record an edit with user "{user_text}" and assistant "{assistant_text}"') +def step_when_record_edit(context, user_text, assistant_text): + context.last_edit_session = context.editing_service.record_edit( + user_text=user_text, + assistant_response=assistant_text, + ) + + +@when("I try to record an edit without an active session") +def step_when_record_edit_no_session(context): + try: + context.editing_service.record_edit( + user_text="test", + assistant_response="response", + ) + context.raised_error = None + except SessionNotFoundError as exc: + context.raised_error = exc + + +@when('I append a TUI user message "{content}"') +def step_when_append_tui_user_message(context, content): + context.appended_message = context.editing_service.append_user_message(content) + + +@when("I try to append a TUI user message without an active session") +def step_when_append_tui_user_no_session(context): + try: + context.editing_service.append_user_message("test") + context.raised_error = None + except SessionNotFoundError as exc: + context.raised_error = exc + + +@when("I list all TUI sessions") +def step_when_list_sessions(context): + context.session_list = context.editing_service.list_sessions() + + +# --------------------------------------------------------------------------- +# Then steps +# --------------------------------------------------------------------------- + + +@then("the active session should have a valid ULID") +def step_then_active_session_valid_ulid(context): + sid = context.editing_service.active_session_id + assert sid is not None, "Expected active session ID to be set" + assert _ULID_RE.match(sid), f"Expected ULID format, got: {sid!r}" + + +@then("the active session should have no messages") +def step_then_active_session_no_messages(context): + session = context.editing_service.active_session + assert session is not None + assert len(session.messages) == 0, ( + f"Expected 0 messages, got {len(session.messages)}" + ) + + +@then('the active session actor_name should be "{actor_name}"') +def step_then_active_session_actor(context, actor_name): + session = context.editing_service.active_session + assert session is not None + assert session.actor_name == actor_name, ( + f"Expected actor_name {actor_name!r}, got {session.actor_name!r}" + ) + + +@then("the resumed session should have {count:d} messages") +def step_then_resumed_session_messages(context, count): + session = context.resumed_session + assert len(session.messages) == count, ( + f"Expected {count} messages, got {len(session.messages)}" + ) + + +@then("the save should succeed without error") +def step_then_save_succeeds(context): + assert context.save_result is not None, "Expected save to return the session" + + +@then("the active session ID should match the original") +def step_then_active_session_id_matches(context): + assert context.editing_service.active_session_id == context.original_session_id, ( + f"Expected {context.original_session_id!r}, " + f"got {context.editing_service.active_session_id!r}" + ) + + +@then("the context window should have {count:d} entries") +def step_then_context_window_count(context, count): + assert len(context.context_window) == count, ( + f"Expected {count} context entries, got {len(context.context_window)}" + ) + + +@then("the context window should be empty") +def step_then_context_window_empty(context): + assert context.context_window == [], ( + f"Expected empty context window, got {context.context_window!r}" + ) + + +@then("each context entry should have role and content keys") +def step_then_context_entries_have_keys(context): + for entry in context.context_window: + assert "role" in entry, f"Missing 'role' key in {entry!r}" + assert "content" in entry, f"Missing 'content' key in {entry!r}" + + +@then("the active session should have {count:d} messages") +def step_then_active_session_message_count(context, count): + session = context.editing_service.active_session + assert session is not None + assert len(session.messages) == count, ( + f"Expected {count} messages, got {len(session.messages)}" + ) + + +@then('the first message role should be "{role}"') +def step_then_first_message_role(context, role): + session = context.editing_service.active_session + assert session is not None + assert len(session.messages) >= 1 + assert session.messages[0].role.value == role, ( + f"Expected role {role!r}, got {session.messages[0].role.value!r}" + ) + + +@then('the second message role should be "{role}"') +def step_then_second_message_role(context, role): + session = context.editing_service.active_session + assert session is not None + assert len(session.messages) >= 2 + assert session.messages[1].role.value == role, ( + f"Expected role {role!r}, got {session.messages[1].role.value!r}" + ) + + +@then("the returned session should have {count:d} messages") +def step_then_returned_session_messages(context, count): + session = context.last_edit_session + assert len(session.messages) == count, ( + f"Expected {count} messages, got {len(session.messages)}" + ) + + +@then("a SessionNotFoundError should be raised") +def step_then_session_not_found_error(context): + assert isinstance(context.raised_error, SessionNotFoundError), ( + f"Expected SessionNotFoundError, got {type(context.raised_error)!r}" + ) + + +@then("the active session should have 1 message") +def step_then_active_session_one_message(context): + session = context.editing_service.active_session + assert session is not None + assert len(session.messages) == 1, ( + f"Expected 1 message, got {len(session.messages)}" + ) + + +@then('the first message content should be "{content}"') +def step_then_first_message_content(context, content): + session = context.editing_service.active_session + if session is None: + # For resumed session checks + session = context.resumed_session + assert session is not None + assert len(session.messages) >= 1 + assert session.messages[0].content == content, ( + f"Expected content {content!r}, got {session.messages[0].content!r}" + ) + + +@then("the session list should have at least {count:d} entries") +def step_then_session_list_at_least(context, count): + assert len(context.session_list) >= count, ( + f"Expected at least {count} sessions, got {len(context.session_list)}" + ) + + +@then("the active session should be None") +def step_then_active_session_none(context): + assert context.editing_service.active_session is None, ( + "Expected active_session to be None" + ) + + +@then("the active session ID should be None") +def step_then_active_session_id_none(context): + assert context.editing_service.active_session_id is None, ( + "Expected active_session_id to be None" + ) + + +@then("the active session ID should not be None") +def step_then_active_session_id_not_none(context): + assert context.editing_service.active_session_id is not None, ( + "Expected active_session_id to be set" + ) + + +# --------------------------------------------------------------------------- +# Infrastructure helper steps +# --------------------------------------------------------------------------- + + +@when("I call get_tui_database_url with a temp path") +def step_when_get_tui_database_url(context): + from cleveragents.tui.session_editing import get_tui_database_url + + tmp_dir = tempfile.mkdtemp() + db_path = Path(tmp_dir) / "test_tui.db" + context.tui_db_url = get_tui_database_url(db_path) + + +@then("the tui db url should start with sqlite") +def step_then_tui_db_url_is_sqlite(context): + assert context.tui_db_url.startswith("sqlite:///"), ( + f"Expected sqlite:/// URL, got {context.tui_db_url!r}" + ) + + +@when("I call build_tui_session_service with a temp path") +def step_when_build_tui_session_service(context): + from cleveragents.tui.session_editing import build_tui_session_service + + tmp_dir = tempfile.mkdtemp() + db_path = Path(tmp_dir) / "test_tui.db" + context.built_service = build_tui_session_service(db_path=db_path) + + +@then("the built service should be able to create a session") +def step_then_built_service_creates_session(context): + from cleveragents.tui.session_editing import TuiSessionEditingService + + editing = TuiSessionEditingService(context.built_service) + session = editing.create_session() + assert session is not None + assert editing.active_session_id is not None + + +# --------------------------------------------------------------------------- +# ConversationWidget steps +# --------------------------------------------------------------------------- + + +@given("a fallback ConversationWidget is created") +def step_given_fallback_conversation_widget(context): + from cleveragents.tui.widgets.conversation import _FallbackConversationWidget + + context.widget = _FallbackConversationWidget(session_id="test-session") + + +@when("I call reflect_session on it with a session containing messages") +def step_when_reflect_session_with_messages(context): + from ulid import ULID + + from cleveragents.domain.models.core.session import ( + MessageRole, + Session, + SessionMessage, + ) + + session = Session( + session_id=str(ULID()), + messages=[ + SessionMessage( + message_id=str(ULID()), + role=MessageRole.USER, + content="hello", + sequence=0, + ), + SessionMessage( + message_id=str(ULID()), + role=MessageRole.ASSISTANT, + content="world", + sequence=1, + ), + ], + ) + context.widget.reflect_session(session) + + +@when('I call update on it with "hello world"') +def step_when_update_widget(context): + context.widget.update("hello world") + + +@when("I call reflect_session on it with an empty session") +def step_when_reflect_empty_session(context): + from ulid import ULID + + from cleveragents.domain.models.core.session import Session + + session = Session(session_id=str(ULID())) + context.widget.reflect_session(session) + + +@then("the widget text should contain the message content") +def step_then_widget_text_contains_content(context): + assert "hello" in context.widget.text, ( + f"Expected 'hello' in widget text, got {context.widget.text!r}" + ) + assert "world" in context.widget.text, ( + f"Expected 'world' in widget text, got {context.widget.text!r}" + ) + + +@then('the widget text should be "hello world"') +def step_then_widget_text_is_hello_world(context): + assert context.widget.text == "hello world", ( + f"Expected 'hello world', got {context.widget.text!r}" + ) + + +@then('the widget text should be "(no messages yet)"') +def step_then_widget_text_is_placeholder(context): + assert context.widget.text == "(no messages yet)", ( + f"Expected '(no messages yet)', got {context.widget.text!r}" + ) + + +@when("I render a transcript with 2 messages") +def step_when_render_transcript(context): + from ulid import ULID + + from cleveragents.domain.models.core.session import ( + MessageRole, + Session, + SessionMessage, + ) + from cleveragents.tui.widgets.conversation import _render_transcript + + session = Session( + session_id=str(ULID()), + messages=[ + SessionMessage( + message_id=str(ULID()), + role=MessageRole.USER, + content="ping", + sequence=0, + ), + SessionMessage( + message_id=str(ULID()), + role=MessageRole.ASSISTANT, + content="pong", + sequence=1, + ), + ], + ) + context.rendered_text = _render_transcript(session) + + +@then("the rendered text should contain role labels") +def step_then_rendered_text_has_role_labels(context): + assert "[USER]" in context.rendered_text, ( + f"Expected '[USER]' in rendered text, got {context.rendered_text!r}" + ) + assert "[ASSISTANT]" in context.rendered_text, ( + f"Expected '[ASSISTANT]' in rendered text, got {context.rendered_text!r}" + ) + + +# --------------------------------------------------------------------------- +# Edge case steps for coverage +# --------------------------------------------------------------------------- + + +@when("I record an edit with explicit session_id") +def step_when_record_edit_explicit_session_id(context): + """Record an edit using an explicit session_id (not the active session).""" + # Create a second session to use as the explicit target + second_session = context.editing_service._svc.create() + context.explicit_session_id = second_session.session_id + context.explicit_edit_result = context.editing_service.record_edit( + user_text="explicit user", + assistant_response="explicit assistant", + session_id=second_session.session_id, + ) + + +@then("the edit should succeed and return a session") +def step_then_edit_succeeds(context): + assert context.explicit_edit_result is not None + + +@when("I append a TUI user message with explicit session_id") +def step_when_append_tui_user_message_explicit(context): + """Append a user message using an explicit session_id.""" + second_session = context.editing_service._svc.create() + context.explicit_session_id = second_session.session_id + context.explicit_msg = context.editing_service.append_user_message( + "explicit message", + session_id=second_session.session_id, + ) + + +@then("the message should be appended successfully") +def step_then_message_appended(context): + assert context.explicit_msg is not None + assert context.explicit_msg.content == "explicit message" + + +@when("I get the context window for a different session") +def step_when_get_context_window_different_session(context): + """Get context window for a session that is not the active session.""" + # Create a new service with no active session, then get context for a specific ID + fresh_editing = TuiSessionEditingService( + context.editing_service._svc, + context_window=20, + ) + # Use a non-existent session ID to trigger the message_repo path + context.context_window = fresh_editing.get_context_window( + session_id="01AAAAAAAAAAAAAAAAAAAAAAAAA" + ) + + +@when("I call build_tui_session_service twice with the same path") +def step_when_build_service_twice(context): + from cleveragents.tui.session_editing import build_tui_session_service + + tmp_dir = tempfile.mkdtemp() + db_path = Path(tmp_dir) / "test_tui2.db" + context.svc_first = build_tui_session_service(db_path=db_path) + # Second call should work even though tables already exist + context.svc_second = build_tui_session_service(db_path=db_path) + + +@then("both calls should succeed") +def step_then_both_calls_succeed(context): + assert context.svc_first is not None + assert context.svc_second is not None diff --git a/features/tui_session_editing.feature b/features/tui_session_editing.feature new file mode 100644 index 00000000..9e212afd --- /dev/null +++ b/features/tui_session_editing.feature @@ -0,0 +1,198 @@ +Feature: TUI Session-Driven Interactive Editing + As a TUI user + I want sessions to persist across restarts and carry context forward + So that I can have coherent multi-turn conversations + + # ---- Session Lifecycle ---- + + Scenario: Create a new TUI editing session + Given a TUI session editing service is initialised + When I create a new TUI session + Then the active session should have a valid ULID + And the active session should have no messages + + Scenario: Create a session bound to an actor + Given a TUI session editing service is initialised + When I create a new TUI session with actor "local/orchestrator" + Then the active session actor_name should be "local/orchestrator" + + Scenario: Resume an existing session by ID + Given a TUI session editing service is initialised + And I create a new TUI session + And I record an edit with user "hello" and assistant "world" + When I resume the session by its ID using a fresh service + Then the resumed session should have 2 messages + + Scenario: Save the active session + Given a TUI session editing service is initialised + And I create a new TUI session + When I save the active session + Then the save should succeed without error + + Scenario: get_or_create_session creates when no ID given + Given a TUI session editing service is initialised + When I call get_or_create_session with no ID + Then the active session should have a valid ULID + + Scenario: get_or_create_session resumes when valid ID given + Given a TUI session editing service is initialised + And I create a new TUI session + When I call get_or_create_session with the existing session ID + Then the active session ID should match the original + + Scenario: get_or_create_session creates new when ID not found + Given a TUI session editing service is initialised + When I call get_or_create_session with a non-existent ID + Then the active session should have a valid ULID + + # ---- Persistence Across Restarts ---- + + Scenario: Session persists across service restarts + Given a TUI session editing service is initialised with a named database + And I create a new TUI session + And I record an edit with user "first message" and assistant "first response" + When I create a fresh service pointing to the same database + And I resume the session by its ID using the fresh service + Then the resumed session should have 2 messages + And the first message content should be "first message" + + # ---- Context Carry-Over ---- + + Scenario: Context window returns recent messages + Given a TUI session editing service is initialised + And I create a new TUI session + And I record 5 edits + When I get the context window + Then the context window should have 10 entries + + Scenario: Context window respects the window size limit + Given a TUI session editing service with context window 4 is initialised + And I create a new TUI session + And I record 5 edits + When I get the context window + Then the context window should have 4 entries + + Scenario: Context window entries have role and content keys + Given a TUI session editing service is initialised + And I create a new TUI session + And I record an edit with user "ping" and assistant "pong" + When I get the context window + Then each context entry should have role and content keys + + Scenario: Context window is empty when no session is active + Given a TUI session editing service is initialised + When I get the context window with no active session + Then the context window should be empty + + # ---- Real-Time Edit Reflection ---- + + Scenario: Record an edit appends user and assistant messages + Given a TUI session editing service is initialised + And I create a new TUI session + When I record an edit with user "hello" and assistant "world" + Then the active session should have 2 messages + And the first message role should be "user" + And the second message role should be "assistant" + + Scenario: Record an edit returns the updated session + Given a TUI session editing service is initialised + And I create a new TUI session + When I record an edit with user "test" and assistant "response" + Then the returned session should have 2 messages + + Scenario: Record edit without active session raises error + Given a TUI session editing service is initialised + When I try to record an edit without an active session + Then a SessionNotFoundError should be raised + + Scenario: Append user message updates active session + Given a TUI session editing service is initialised + And I create a new TUI session + When I append a TUI user message "standalone input" + Then the active session should have 1 message + And the first message content should be "standalone input" + + Scenario: Append user message without active session raises error + Given a TUI session editing service is initialised + When I try to append a TUI user message without an active session + Then a SessionNotFoundError should be raised + + # ---- Session Listing ---- + + Scenario: List sessions returns all created sessions + Given a TUI session editing service is initialised + And I create a new TUI session + And I create a new TUI session + When I list all TUI sessions + Then the session list should have at least 2 entries + + # ---- Active Session Accessors ---- + + Scenario: active_session is None before any session is created + Given a TUI session editing service is initialised + Then the active session should be None + + Scenario: active_session_id is None before any session is created + Given a TUI session editing service is initialised + Then the active session ID should be None + + Scenario: active_session_id matches created session + Given a TUI session editing service is initialised + When I create a new TUI session + Then the active session ID should not be None + + # ---- Infrastructure helpers ---- + + Scenario: get_tui_database_url returns a sqlite URL + When I call get_tui_database_url with a temp path + Then the tui db url should start with sqlite + + Scenario: build_tui_session_service creates a working service + When I call build_tui_session_service with a temp path + Then the built service should be able to create a session + + # ---- ConversationWidget ---- + + Scenario: ConversationWidget fallback renders transcript + Given a fallback ConversationWidget is created + When I call reflect_session on it with a session containing messages + Then the widget text should contain the message content + + Scenario: ConversationWidget fallback update sets text directly + Given a fallback ConversationWidget is created + When I call update on it with "hello world" + Then the widget text should be "hello world" + + Scenario: ConversationWidget fallback with empty session shows placeholder + Given a fallback ConversationWidget is created + When I call reflect_session on it with an empty session + Then the widget text should be "(no messages yet)" + + Scenario: render_transcript with messages returns formatted text + When I render a transcript with 2 messages + Then the rendered text should contain role labels + + # ---- Edge cases for coverage ---- + + Scenario: record_edit with explicit session_id not matching active session + Given a TUI session editing service is initialised + And I create a new TUI session + When I record an edit with explicit session_id + Then the edit should succeed and return a session + + Scenario: append_user_message with explicit session_id not matching active session + Given a TUI session editing service is initialised + And I create a new TUI session + When I append a TUI user message with explicit session_id + Then the message should be appended successfully + + Scenario: get_context_window for non-active session + Given a TUI session editing service is initialised + And I create a new TUI session + And I record an edit with user "test" and assistant "response" + When I get the context window for a different session + Then the context window should be empty + + Scenario: build_tui_session_service with existing tables + When I call build_tui_session_service twice with the same path + Then both calls should succeed diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 516a9cb4..e14c45bf 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -2,6 +2,7 @@ from __future__ import annotations +import contextlib import importlib import os from dataclasses import dataclass @@ -10,6 +11,7 @@ from typing import TYPE_CHECKING, Any, ClassVar, Protocol from cleveragents.tui.input.modes import InputMode, InputModeRouter from cleveragents.tui.input.reference_parser import suggestions from cleveragents.tui.persona.state import PersonaState +from cleveragents.tui.session_editing import TuiSessionEditingService from cleveragents.tui.slash_catalog import slash_command_names from cleveragents.tui.widgets.help_panel_overlay import ( HelpPanelOverlay, @@ -70,9 +72,13 @@ class _FallbackCleverAgentsTuiApp: # pragma: no cover """Fallback app that raises actionable dependency error.""" def __init__( - self, *, command_router: _CommandRouter, persona_state: PersonaState + self, + *, + command_router: _CommandRouter, + persona_state: PersonaState, + session_editing_service: TuiSessionEditingService | None = None, ) -> None: - del command_router, persona_state + del command_router, persona_state, session_editing_service def run(self) -> None: raise RuntimeError( @@ -98,11 +104,17 @@ if _TEXTUAL_AVAILABLE: *, command_router: _CommandRouter, persona_state: PersonaState, + session_editing_service: TuiSessionEditingService | None = None, ) -> None: super().__init__() self._command_router = command_router self._persona_state = persona_state + self._session_editing_service = session_editing_service self._session = SessionView(session_id="default", transcript=[]) + # Initialise or resume the active editing session. + if self._session_editing_service is not None: + active = self._session_editing_service.get_or_create_session() + self._session = SessionView(session_id=active.session_id, transcript=[]) def compose(self) -> Any: yield _Header(show_clock=True) @@ -170,7 +182,15 @@ if _TEXTUAL_AVAILABLE: conversation = self.query_one("#conversation", _Static) if result.mode == InputMode.COMMAND: - conversation.update(result.command_result or "") + command_output = result.command_result or "" + conversation.update(command_output) + # Record command interactions in the session. + if self._session_editing_service is not None: + with contextlib.suppress(Exception): # pragma: no cover + self._session_editing_service.record_edit( + user_text=text, + assistant_response=command_output, + ) self._refresh_persona_bar() return if result.mode == InputMode.SHELL: @@ -181,7 +201,15 @@ if _TEXTUAL_AVAILABLE: output = ( shell.stdout.strip() or shell.stderr.strip() or "(empty output)" ) - conversation.update(f"$ {shell.command}\n{output}") + shell_display = f"$ {shell.command}\n{output}" + conversation.update(shell_display) + # Record shell interactions in the session. + if self._session_editing_service is not None: + with contextlib.suppress(Exception): # pragma: no cover + self._session_editing_service.record_edit( + user_text=text, + assistant_response=shell_display, + ) return preview = result.expanded_text @@ -191,6 +219,13 @@ if _TEXTUAL_AVAILABLE: text, suggestions(text.replace("@", "").strip()) ) conversation.update(preview) + # Record normal text interactions in the session. + if self._session_editing_service is not None: + with contextlib.suppress(Exception): # pragma: no cover + self._session_editing_service.record_edit( + user_text=text, + assistant_response=preview, + ) _ResolvedTuiApp = _TextualCleverAgentsTuiApp diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index 1134e995..9eec7399 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -4,11 +4,19 @@ from __future__ import annotations import json from dataclasses import dataclass +from typing import TYPE_CHECKING 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.session_editing import ( + TuiSessionEditingService, + build_tui_session_service, +) + +if TYPE_CHECKING: + pass @dataclass(slots=True) @@ -55,6 +63,10 @@ def run_tui(*, headless: bool = False) -> int: state = container.persona_state(registry=registry) router = TuiCommandRouter(persona_registry=registry, persona_state=state) + # Build the TUI session editing service backed by the TUI SQLite database. + session_svc = build_tui_session_service() + editing_service = TuiSessionEditingService(session_svc) + if headless: payload = { "textual_available": textual_available(), @@ -67,6 +79,10 @@ def run_tui(*, headless: bool = False) -> int: print(json.dumps(payload, indent=2)) return 0 - app = CleverAgentsTuiApp(command_router=router, persona_state=state) + app = CleverAgentsTuiApp( + command_router=router, + persona_state=state, + session_editing_service=editing_service, + ) app.run() return 0 diff --git a/src/cleveragents/tui/session_editing.py b/src/cleveragents/tui/session_editing.py new file mode 100644 index 00000000..38045904 --- /dev/null +++ b/src/cleveragents/tui/session_editing.py @@ -0,0 +1,414 @@ +"""Session-driven interactive editing service for the TUI. + +Provides a lightweight application-layer service that coordinates: + +- Session lifecycle (create, resume, save) backed by SQLite via + ``PersistentSessionService``. +- Multi-turn context carry-over: the full message history is kept in + memory and appended to the database on each turn. +- Real-time edit reflection: callers receive the updated ``Session`` + object after every append so the TUI can re-render immediately. + +## Design + +``TuiSessionEditingService`` wraps ``PersistentSessionService`` and adds +TUI-specific concerns: + +- **Active session tracking**: one session is "active" at a time per TUI + instance. The active session is loaded into memory on resume. +- **Context carry-over**: ``get_context_window()`` returns the last *N* + messages suitable for passing to an LLM as conversation history. +- **Edit operations**: ``record_edit()`` appends a user message and an + assistant response in a single atomic call, updating the DB and + returning the refreshed session. + +## Database + +Sessions are stored in the same SQLite database used by the rest of +CleverAgents (``~/.local/state/cleveragents/tui.db`` by default, or +whatever ``database_url`` is injected). The ``PersistentSessionService`` +handles table creation and schema migration. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import structlog + +from cleveragents.domain.models.core.session import ( + MessageRole, + Session, + SessionMessage, + SessionNotFoundError, +) + +if TYPE_CHECKING: + from cleveragents.application.services.session_service import ( + PersistentSessionService, + ) + +_logger = structlog.get_logger(__name__) + +# Default number of messages to include in the context window. +DEFAULT_CONTEXT_WINDOW = 20 + +# Default path for the TUI-specific SQLite database. +DEFAULT_TUI_DB_PATH = Path.home() / ".local" / "state" / "cleveragents" / "tui.db" + + +def get_tui_database_url(db_path: Path | None = None) -> str: + """Return the SQLite URL for the TUI session database. + + Args: + db_path: Override path. Defaults to + ``~/.local/state/cleveragents/tui.db``. + + Returns: + A ``sqlite:///`` URL string. + """ + resolved = db_path or DEFAULT_TUI_DB_PATH + resolved.parent.mkdir(parents=True, exist_ok=True) + return f"sqlite:///{resolved.absolute()}" + + +def build_tui_session_service( + db_path: Path | None = None, +) -> PersistentSessionService: + """Build a ``PersistentSessionService`` backed by the TUI SQLite database. + + Creates the session tables if they do not yet exist. + + Args: + db_path: Override path for the TUI database. + + Returns: + A ready-to-use ``PersistentSessionService``. + """ + from sqlalchemy import create_engine + from sqlalchemy import inspect as sa_inspect + from sqlalchemy.orm import sessionmaker + + from cleveragents.application.services.session_service import ( + PersistentSessionService, + ) + from cleveragents.infrastructure.database.models import ( + Base, + SessionMessageModel, + SessionModel, + ) + from cleveragents.infrastructure.database.repositories import ( + SessionMessageRepository, + SessionRepository, + ) + + url = get_tui_database_url(db_path) + engine = create_engine(url, echo=False) + + # Targeted table creation — only session tables. + inspector = sa_inspect(engine) + existing = set(inspector.get_table_names()) + tables_to_create = [ + m.__table__ + for m in (SessionModel, SessionMessageModel) + if m.__tablename__ not in existing + ] + if tables_to_create: + Base.metadata.create_all(engine, tables=tables_to_create) + + factory = sessionmaker(bind=engine, expire_on_commit=False) + session_repo = SessionRepository(session_factory=factory, auto_commit=True) + message_repo = SessionMessageRepository(session_factory=factory, auto_commit=True) + return PersistentSessionService(session_repo, message_repo) + + +class TuiSessionEditingService: + """Application-layer service for TUI session-driven interactive editing. + + Wraps ``PersistentSessionService`` with TUI-specific concerns: + active session tracking, context carry-over, and real-time edit + reflection. + + Args: + session_service: The underlying persistence service. + context_window: Number of recent messages to include in the + context window returned by ``get_context_window()``. + """ + + def __init__( + self, + session_service: PersistentSessionService, + *, + context_window: int = DEFAULT_CONTEXT_WINDOW, + ) -> None: + self._svc = session_service + self._context_window = context_window + self._active_session: Session | None = None + + # ------------------------------------------------------------------ + # Session lifecycle + # ------------------------------------------------------------------ + + def create_session(self, actor_name: str | None = None) -> Session: + """Create a new session and make it active. + + Args: + actor_name: Optional namespaced actor to bind. + + Returns: + The newly created ``Session``. + """ + session = self._svc.create(actor_name=actor_name) + self._active_session = session + _logger.info("tui_session_created", session_id=session.session_id) + return session + + def resume_session(self, session_id: str) -> Session: + """Load an existing session and make it active. + + Loads the full message history into the in-memory session object + so that ``get_context_window()`` works correctly. + + Args: + session_id: The ULID of the session to resume. + + Returns: + The loaded ``Session`` with full message history. + + Raises: + SessionNotFoundError: If the session does not exist. + """ + session = self._svc.get(session_id) + # Load messages into the in-memory object via the message repository. + # We access the private repo here because PersistentSessionService + # does not expose a public "load messages into session" method. + # The alternative would be to use export_session() + reconstruct, + # but that is heavier and loses the domain object identity. + message_repo = getattr(self._svc, "_message_repo", None) + if message_repo is not None: + messages = message_repo.get_for_session(session_id) + session.messages = messages + self._active_session = session + _logger.info( + "tui_session_resumed", + session_id=session_id, + message_count=len(session.messages), + ) + return session + + def save_session(self) -> Session | None: + """Persist the current state of the active session. + + Returns: + The active ``Session`` after saving, or ``None`` if no session + is active. + """ + if self._active_session is None: + return None + session_repo = getattr(self._svc, "_session_repo", None) + if session_repo is not None: + session_repo.update(self._active_session) + _logger.info("tui_session_saved", session_id=self._active_session.session_id) + return self._active_session + + def get_or_create_session( + self, + session_id: str | None = None, + actor_name: str | None = None, + ) -> Session: + """Return the active session, resuming or creating one as needed. + + If *session_id* is provided and the session exists, it is resumed. + Otherwise a new session is created. + + Args: + session_id: Optional ULID to resume. + actor_name: Actor name for new sessions. + + Returns: + The active ``Session``. + """ + if session_id is not None: + try: + return self.resume_session(session_id) + except SessionNotFoundError: + _logger.warning( + "tui_session_not_found_creating_new", + session_id=session_id, + ) + return self.create_session(actor_name=actor_name) + + # ------------------------------------------------------------------ + # Active session accessors + # ------------------------------------------------------------------ + + @property + def active_session(self) -> Session | None: + """Return the currently active session, or ``None``.""" + return self._active_session + + @property + def active_session_id(self) -> str | None: + """Return the ID of the active session, or ``None``.""" + return ( + self._active_session.session_id + if self._active_session is not None + else None + ) + + # ------------------------------------------------------------------ + # Context carry-over + # ------------------------------------------------------------------ + + def get_context_window( + self, + session_id: str | None = None, + ) -> list[dict[str, Any]]: + """Return the last N messages as a context window for LLM calls. + + Each entry is a ``{"role": str, "content": str}`` dict compatible + with standard chat-model APIs. + + Args: + session_id: Override session ID. Defaults to the active + session. + + Returns: + List of message dicts, oldest first, up to ``context_window`` + entries. + """ + sid = session_id or self.active_session_id + if sid is None: + return [] + + # Prefer in-memory messages for the active session. + if self._active_session is not None and self._active_session.session_id == sid: + messages = self._active_session.messages[-self._context_window :] + else: + message_repo = getattr(self._svc, "_message_repo", None) + if message_repo is None: + return [] + total = message_repo.count_for_session(sid) + messages = message_repo.get_for_session( + sid, + limit=self._context_window, + offset=max(0, total - self._context_window), + ) + + return [{"role": m.role.value, "content": m.content} for m in messages] + + # ------------------------------------------------------------------ + # Edit operations + # ------------------------------------------------------------------ + + def record_edit( + self, + user_text: str, + assistant_response: str, + *, + session_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> Session: + """Record a user→assistant exchange and persist both messages. + + Appends the user message and the assistant response to the session + in a single logical operation. The in-memory ``_active_session`` + is updated so callers can immediately reflect the new state. + + Args: + user_text: The user's input text. + assistant_response: The assistant's response text. + session_id: Override session ID. Defaults to the active + session. + metadata: Optional metadata to attach to both messages. + + Returns: + The updated ``Session`` with both new messages appended. + + Raises: + SessionNotFoundError: If no active session and no *session_id* + is provided, or if the given *session_id* does not exist. + """ + sid = session_id or self.active_session_id + if sid is None: + raise SessionNotFoundError( + "No active session. Call create_session() or resume_session() first." + ) + + user_msg = self._svc.append_message( + sid, + role=MessageRole.USER, + content=user_text, + metadata=metadata, + ) + assistant_msg = self._svc.append_message( + sid, + role=MessageRole.ASSISTANT, + content=assistant_response, + metadata=metadata, + ) + + # Keep in-memory session in sync. + if self._active_session is not None and self._active_session.session_id == sid: + self._active_session.messages.append(user_msg) + self._active_session.messages.append(assistant_msg) + self._active_session.updated_at = assistant_msg.timestamp + + _logger.debug( + "tui_edit_recorded", + session_id=sid, + user_seq=user_msg.sequence, + assistant_seq=assistant_msg.sequence, + ) + + # Return the refreshed session. + if self._active_session is not None and self._active_session.session_id == sid: + return self._active_session + + return self._svc.get(sid) + + def append_user_message( + self, + content: str, + *, + session_id: str | None = None, + metadata: dict[str, Any] | None = None, + ) -> SessionMessage: + """Append a single user message to the active session. + + Args: + content: The message content. + session_id: Override session ID. + metadata: Optional metadata. + + Returns: + The newly created ``SessionMessage``. + + Raises: + SessionNotFoundError: If no active session. + """ + sid = session_id or self.active_session_id + if sid is None: + raise SessionNotFoundError("No active session.") + + msg = self._svc.append_message( + sid, + role=MessageRole.USER, + content=content, + metadata=metadata, + ) + + if self._active_session is not None and self._active_session.session_id == sid: + self._active_session.messages.append(msg) + self._active_session.updated_at = msg.timestamp + + return msg + + def list_sessions(self) -> list[Session]: + """List all persisted sessions. + + Returns: + All sessions ordered by creation time. + """ + return self._svc.list() diff --git a/src/cleveragents/tui/widgets/conversation.py b/src/cleveragents/tui/widgets/conversation.py new file mode 100644 index 00000000..25f91ca6 --- /dev/null +++ b/src/cleveragents/tui/widgets/conversation.py @@ -0,0 +1,106 @@ +"""Conversation widget for the TUI. + +Displays the session transcript and reflects edits in real time. + +The widget wraps a Textual ``Static`` widget (or a plain fallback when +Textual is not installed) and exposes a ``reflect_session()`` method that +re-renders the transcript from a ``Session`` domain object. +""" + +from __future__ import annotations + +import importlib +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from cleveragents.domain.models.core.session import Session + +_TEXTUAL_AVAILABLE = False +_Static: type[Any] = object + +try: # pragma: no branch - import gate for optional dependency + _textual_widgets = importlib.import_module("textual.widgets") + _Static = _textual_widgets.Static + _TEXTUAL_AVAILABLE = True +except Exception: # pragma: no cover + _TEXTUAL_AVAILABLE = False + + +def _render_transcript(session: Session) -> str: + """Render a session's message history as a plain-text transcript. + + Args: + session: The session whose messages should be rendered. + + Returns: + A multi-line string with ``[role] content`` lines. + """ + if not session.messages: + return "(no messages yet)" + lines: list[str] = [] + for msg in session.messages: + role_label = msg.role.value.upper() + lines.append(f"[{role_label}] {msg.content}") + return "\n".join(lines) + + +class _FallbackConversationWidget: + """Fallback when Textual is not installed.""" + + def __init__(self, *, session_id: str = "default") -> None: + self._session_id = session_id + self._text = "(no messages yet)" + + @property + def text(self) -> str: + """Return the current rendered text.""" + return self._text + + def reflect_session(self, session: Session) -> None: + """Update the rendered text from a session. + + Args: + session: The session to render. + """ + self._text = _render_transcript(session) + + def update(self, text: str) -> None: + """Update the displayed text directly. + + Args: + text: New text to display. + """ + self._text = text + + +_ResolvedConversationWidget: type[Any] = _FallbackConversationWidget + +if _TEXTUAL_AVAILABLE: # pragma: no branch - import gate for optional dependency + + class _TextualConversationWidget(_Static): # pragma: no cover + """Textual-based conversation widget with session reflection.""" + + DEFAULT_CSS = """ + _TextualConversationWidget { + height: 1fr; + overflow-y: auto; + padding: 1 2; + } + """ + + def __init__(self, *, session_id: str = "default", **kwargs: Any) -> None: + super().__init__("(no messages yet)", id="conversation", **kwargs) + self._session_id = session_id + + def reflect_session(self, session: Session) -> None: + """Re-render the transcript from the given session. + + Args: + session: The session whose messages should be displayed. + """ + self.update(_render_transcript(session)) + + _ResolvedConversationWidget = _TextualConversationWidget + + +ConversationWidget = _ResolvedConversationWidget