Compare commits

...

1 Commits

Author SHA1 Message Date
freemo 2ff0454d5e feat(tui): session-driven interactive editing
CI / lint (pull_request) Successful in 21s
CI / unit_tests (pull_request) Failing after 19s
CI / build (pull_request) Failing after 2s
CI / helm (pull_request) Failing after 1s
CI / quality (pull_request) Successful in 44s
CI / typecheck (pull_request) Successful in 3m58s
CI / security (pull_request) Successful in 4m4s
CI / docker (pull_request) Has been skipped
CI / coverage (pull_request) Successful in 9m31s
CI / e2e_tests (pull_request) Successful in 19m28s
CI / integration_tests (pull_request) Successful in 21m51s
CI / status-check (pull_request) Failing after 1s
CI / benchmark-publish (pull_request) Has been skipped
CI / benchmark-regression (pull_request) Successful in 55m4s
Implement session-driven interactive editing with SQLite persistence,
multi-turn context carry-over, and real-time edit reflection.

ISSUES CLOSED: #860
2026-04-02 09:47:04 +00:00
6 changed files with 1424 additions and 5 deletions
+650
View File
@@ -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
+198
View File
@@ -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
+39 -4
View File
@@ -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
+17 -1
View File
@@ -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
+414
View File
@@ -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()
@@ -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