Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ff0454d5e |
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user