652769c46c
CI / push-validation (pull_request) Successful in 31s
CI / helm (pull_request) Successful in 42s
CI / build (pull_request) Successful in 42s
CI / lint (pull_request) Successful in 1m6s
CI / quality (pull_request) Successful in 1m6s
CI / typecheck (pull_request) Successful in 1m12s
CI / security (pull_request) Successful in 1m25s
CI / integration_tests (pull_request) Successful in 3m17s
CI / unit_tests (pull_request) Successful in 4m37s
CI / docker (pull_request) Successful in 1m26s
CI / coverage (pull_request) Successful in 11m50s
CI / status-check (pull_request) Successful in 5s
Wires the TUI normal text input path to the LLM via A2aLocalFacade, closing the gap where typing a message produced no LLM call. All infrastructure (facade, SessionWorkflow, ProviderRegistry) already existed in the CLI; this PR adds the TUI wiring on top. Key changes: - _run_llm_dispatch(): module-level, Textual-free dispatch function covering all domain exception paths with user-friendly messages - _format_worker_outcome(): testable worker result/error translator - _create_tui_session(): DB-backed session with persona actor binding - _build_tui_facade(): wired A2aLocalFacade + SessionWorkflow singleton - run_worker(thread=True, exclusive=True): non-blocking, serialised - _dispatch_gen counter: prevents cancelled worker callbacks from corrupting the multi-turn transcript - SessionView.transcript: accumulated conversation history, pre-escaped - Markup escaping: _escape() applied before transcript storage - on_mount focus: prompt.focus() so keyboard input is received Tests: 18 BDD scenarios + 6 Robot Framework integration tests with FakeListLLM (no real API keys required). ISSUES CLOSED: #11230
286 lines
9.6 KiB
Python
286 lines
9.6 KiB
Python
"""Helper script for tui_llm_dispatch.robot integration tests.
|
|
|
|
Each subcommand is a self-contained check that prints a sentinel on success.
|
|
|
|
Uses a stub LLM and real SQLite DB — no API keys required.
|
|
Follows the same fixture pattern as helper_session_tell_llm.py.
|
|
"""
|
|
|
|
# ruff: noqa: E402
|
|
from __future__ import annotations
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
_SRC = str(Path(__file__).resolve().parents[1] / "src")
|
|
if _SRC not in sys.path:
|
|
sys.path.insert(0, _SRC)
|
|
|
|
from sqlalchemy import create_engine
|
|
from sqlalchemy.orm import sessionmaker
|
|
|
|
from cleveragents.application.services.session_service import PersistentSessionService
|
|
from cleveragents.application.services.session_workflow import SessionWorkflow
|
|
from cleveragents.infrastructure.database.models import Base
|
|
from cleveragents.infrastructure.database.repositories import (
|
|
SessionMessageRepository,
|
|
SessionRepository,
|
|
)
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Stub LLM — deterministic responses without real API calls
|
|
# ---------------------------------------------------------------------------
|
|
|
|
_STUB_TEXT = "Hello from the TUI integration stub LLM."
|
|
|
|
|
|
class _StubLLM:
|
|
"""Minimal LLM stub compatible with LangChain chat model interface."""
|
|
|
|
def invoke(self, messages: Any, **kwargs: Any) -> Any:
|
|
class _Resp:
|
|
content = _STUB_TEXT
|
|
|
|
def __init__(self) -> None:
|
|
self.tool_calls: list[Any] = []
|
|
self.response_metadata: dict[str, Any] = {
|
|
"usage": {"input_tokens": 5, "output_tokens": 10}
|
|
}
|
|
|
|
return _Resp()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixture helpers — mirrors helper_session_tell_llm.py pattern
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def _make_db() -> tuple[Any, Any]:
|
|
"""Create in-memory SQLite DB; return (session_factory, get_db callable)."""
|
|
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_db() -> Any:
|
|
return db_session
|
|
|
|
return factory, get_db
|
|
|
|
|
|
def _make_service(get_db: Any) -> PersistentSessionService:
|
|
"""Build a PersistentSessionService from repository instances."""
|
|
return PersistentSessionService(
|
|
session_repo=SessionRepository(get_db),
|
|
message_repo=SessionMessageRepository(get_db),
|
|
)
|
|
|
|
|
|
def _make_workflow(
|
|
service: PersistentSessionService,
|
|
) -> SessionWorkflow:
|
|
"""Build a SessionWorkflow using the stub LLM via llm_factory injection."""
|
|
stub = _StubLLM()
|
|
return SessionWorkflow(
|
|
session_service=service,
|
|
llm_factory=lambda _actor_name: stub,
|
|
)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Subcommands
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
def cmd_create_session() -> None:
|
|
"""Verify _create_tui_session() creates a real DB-backed session."""
|
|
from cleveragents.tui import commands as cmd_mod
|
|
|
|
_, get_db = _make_db()
|
|
service = _make_service(get_db)
|
|
container = MagicMock()
|
|
container.session_service.return_value = service
|
|
|
|
with patch.object(cmd_mod, "get_container", return_value=container):
|
|
sid = cmd_mod._create_tui_session()
|
|
|
|
assert sid != "default", "Expected a real session ID, got 'default'"
|
|
assert len(sid) > 0, "session_id must not be empty"
|
|
|
|
session_obj = service.get(sid)
|
|
assert session_obj is not None, "Session not found in database"
|
|
assert session_obj.session_id == sid
|
|
|
|
print("tui-create-session-ok")
|
|
|
|
|
|
def cmd_build_facade() -> None:
|
|
"""Verify _build_tui_facade() returns a non-None A2aLocalFacade."""
|
|
from cleveragents.a2a.facade import A2aLocalFacade
|
|
from cleveragents.tui import commands as cmd_mod
|
|
|
|
_, get_db = _make_db()
|
|
service = _make_service(get_db)
|
|
container = MagicMock()
|
|
container.session_service.return_value = service
|
|
container.plan_lifecycle_service.side_effect = RuntimeError("not needed")
|
|
container.resource_registry_service.side_effect = RuntimeError("not needed")
|
|
container.tool_registry.side_effect = RuntimeError("not needed")
|
|
|
|
with (
|
|
patch.object(cmd_mod, "get_container", return_value=container),
|
|
patch("cleveragents.a2a.cli_bootstrap._facade_instance", None),
|
|
):
|
|
facade = cmd_mod._build_tui_facade()
|
|
|
|
assert facade is not None, "_build_tui_facade() must return a facade"
|
|
assert isinstance(facade, A2aLocalFacade), (
|
|
f"Expected A2aLocalFacade, got {type(facade)}"
|
|
)
|
|
print("tui-build-facade-ok")
|
|
|
|
|
|
def cmd_dispatch_message() -> None:
|
|
"""Verify facade.dispatch(message/send) with stub LLM persists and responds."""
|
|
from cleveragents.a2a.facade import A2aLocalFacade
|
|
from cleveragents.a2a.models import A2aRequest
|
|
|
|
_, get_db = _make_db()
|
|
service = _make_service(get_db)
|
|
workflow = _make_workflow(service)
|
|
|
|
facade = A2aLocalFacade(
|
|
services={"session_service": service, "session_workflow": workflow}
|
|
)
|
|
|
|
session = service.create(actor_name="openai/gpt-4o")
|
|
session_id = session.session_id
|
|
|
|
request = A2aRequest(
|
|
method="message/send",
|
|
params={"session_id": session_id, "message": "hello integration"},
|
|
)
|
|
response = facade.dispatch(request)
|
|
|
|
assert response.error is None, f"Unexpected error: {response.error}"
|
|
assert response.result is not None, "Expected result dict"
|
|
assistant_msg: str = response.result.get("assistant_message", "")
|
|
assert len(assistant_msg) > 0, "Expected non-empty assistant message"
|
|
assert _STUB_TEXT in assistant_msg, (
|
|
f"Expected stub text {_STUB_TEXT!r} in response, got: {assistant_msg!r}"
|
|
)
|
|
|
|
messages = service.get_messages(session_id)
|
|
# get_messages() returns SessionMessage objects — access .role attribute
|
|
roles = [m.role.value for m in messages]
|
|
assert "user" in roles, "User message not persisted"
|
|
assert "assistant" in roles, "Assistant message not persisted"
|
|
|
|
print("tui-dispatch-message-ok")
|
|
|
|
|
|
def cmd_no_actor_error() -> None:
|
|
"""Verify SessionActorNotConfiguredError is raised when no actor configured.
|
|
|
|
The A2A facade re-raises domain exceptions (SessionActorNotConfiguredError,
|
|
SessionNotFoundError, DatabaseError) so callers can handle them by type.
|
|
This mirrors the CLI pattern in _facade_dispatch() in session.py.
|
|
"""
|
|
from cleveragents.a2a.facade import A2aLocalFacade
|
|
from cleveragents.a2a.models import A2aRequest
|
|
from cleveragents.domain.models.core.session import (
|
|
SessionActorNotConfiguredError,
|
|
)
|
|
|
|
_, get_db = _make_db()
|
|
service = _make_service(get_db)
|
|
workflow = SessionWorkflow(session_service=service)
|
|
|
|
facade = A2aLocalFacade(
|
|
services={"session_service": service, "session_workflow": workflow}
|
|
)
|
|
|
|
session = service.create(actor_name=None)
|
|
session_id = session.session_id
|
|
|
|
request = A2aRequest(
|
|
method="message/send",
|
|
params={"session_id": session_id, "message": "hello"},
|
|
)
|
|
|
|
try:
|
|
facade.dispatch(request)
|
|
raise AssertionError("Expected SessionActorNotConfiguredError to be raised")
|
|
except SessionActorNotConfiguredError:
|
|
pass # expected — facade re-raises domain exceptions
|
|
|
|
print("tui-no-actor-error-ok")
|
|
|
|
|
|
def cmd_database_error() -> None:
|
|
"""Verify _run_llm_dispatch returns a friendly message on DatabaseError.
|
|
|
|
Simulates a DatabaseError raised by the facade and confirms the TUI
|
|
dispatch function converts it to a user-readable error string rather
|
|
than propagating the exception.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
from cleveragents.core.exceptions import DatabaseError
|
|
from cleveragents.tui.app import _run_llm_dispatch
|
|
|
|
facade = MagicMock()
|
|
facade.dispatch.side_effect = DatabaseError("disk full")
|
|
|
|
result = _run_llm_dispatch(facade, "test-session", "hello")
|
|
|
|
assert "Database error" in result, (
|
|
f"Expected 'Database error' in result, got: {result!r}"
|
|
)
|
|
print("tui-database-error-ok")
|
|
|
|
|
|
def cmd_session_not_found() -> None:
|
|
"""Verify _run_llm_dispatch returns a friendly message on SessionNotFoundError.
|
|
|
|
Simulates the session being deleted between creation and dispatch (e.g.
|
|
concurrent process or manual DB edit) and confirms the TUI surfaces a
|
|
clear error message rather than crashing.
|
|
"""
|
|
from unittest.mock import MagicMock
|
|
|
|
from cleveragents.domain.models.core.session import SessionNotFoundError
|
|
from cleveragents.tui.app import _run_llm_dispatch
|
|
|
|
facade = MagicMock()
|
|
facade.dispatch.side_effect = SessionNotFoundError("session gone")
|
|
|
|
result = _run_llm_dispatch(facade, "test-session", "hello")
|
|
|
|
assert "Session not found" in result, (
|
|
f"Expected 'Session not found' in result, got: {result!r}"
|
|
)
|
|
print("tui-session-not-found-ok")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Dispatch
|
|
# ---------------------------------------------------------------------------
|
|
|
|
COMMANDS = {
|
|
"create-session": cmd_create_session,
|
|
"build-facade": cmd_build_facade,
|
|
"dispatch-message": cmd_dispatch_message,
|
|
"no-actor-error": cmd_no_actor_error,
|
|
"database-error": cmd_database_error,
|
|
"session-not-found": cmd_session_not_found,
|
|
}
|
|
|
|
if __name__ == "__main__":
|
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|
|
print(f"Usage: {sys.argv[0]} [{' | '.join(COMMANDS)}]", file=sys.stderr)
|
|
sys.exit(1)
|
|
COMMANDS[sys.argv[1]]()
|