Files
cleveragents-core/features/steps/tui_llm_dispatch_steps.py
T
hamza.khyari 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
feat(tui): wire normal text input to LLM via A2A facade
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
2026-05-22 15:12:04 +00:00

430 lines
17 KiB
Python

"""Step definitions for tui_llm_dispatch.feature.
Tests the TUI LLM dispatch wiring without requiring Textual or importlib.reload.
The core dispatch logic lives in the module-level ``_run_llm_dispatch()``
function in ``tui/app.py``. This function is Textual-free and can be
imported and tested directly.
Session creation and facade building helpers (``_create_tui_session`` and
``_build_tui_facade`` in ``tui/commands.py``) are also tested directly.
"""
from __future__ import annotations
from typing import Any
from unittest.mock import MagicMock, patch
from behave import given, then, when
from cleveragents.tui.app import _format_worker_outcome, _run_llm_dispatch
# ---------------------------------------------------------------------------
# Shared helpers
# ---------------------------------------------------------------------------
def _make_mock_facade(response_text: str = "Mock LLM reply") -> MagicMock:
"""Return a mock A2aLocalFacade returning a canned assistant message."""
facade = MagicMock()
response = MagicMock()
response.error = None
response.result = {"assistant_message": response_text}
facade.dispatch.return_value = response
return facade
# ---------------------------------------------------------------------------
# Given steps
# ---------------------------------------------------------------------------
@given("the TUI app is initialised with a mock A2A facade and session")
def step_init_with_facade(context: Any) -> None:
"""Set up a mock facade and session_id on the context."""
context._facade = _make_mock_facade()
context._session_id = "test-session-001"
@given("the TUI app is initialised with a facade that returns empty response")
def step_init_with_empty_response_facade(context: Any) -> None:
"""Set up a mock facade that returns empty assistant_message."""
context._facade = _make_mock_facade(response_text="")
context._session_id = "test-session-001"
@given("the TUI app is initialised without a facade")
def step_init_without_facade(context: Any) -> None:
"""Simulate facade=None (preview fallback path)."""
context._facade = None
context._session_id = "default"
@given("the facade raises SessionActorNotConfiguredError")
def step_facade_raises_no_actor(context: Any) -> None:
"""Configure the mock facade to raise SessionActorNotConfiguredError."""
from cleveragents.domain.models.core.session import SessionActorNotConfiguredError
facade = MagicMock()
facade.dispatch.side_effect = SessionActorNotConfiguredError("no actor")
context._facade = facade
context._session_id = "default"
@given("the facade raises SessionNotFoundError")
def step_facade_raises_session_not_found(context: Any) -> None:
"""Configure the mock facade to raise SessionNotFoundError."""
from cleveragents.domain.models.core.session import SessionNotFoundError
facade = MagicMock()
facade.dispatch.side_effect = SessionNotFoundError("session not found")
context._facade = facade
context._session_id = "default"
@given("the facade raises DatabaseError")
def step_facade_raises_database_error(context: Any) -> None:
"""Configure the mock facade to raise DatabaseError."""
from cleveragents.core.exceptions import DatabaseError
facade = MagicMock()
facade.dispatch.side_effect = DatabaseError("db locked")
context._facade = facade
context._session_id = "default"
@given("the facade returns an A2A error response")
def step_facade_returns_error(context: Any) -> None:
"""Configure the mock facade to return an A2A error response."""
facade = MagicMock()
error = MagicMock()
error.message = "internal server error"
response = MagicMock()
response.error = error
response.result = None
facade.dispatch.return_value = response
context._facade = facade
context._session_id = "default"
@given('a mock session service that returns session_id "{sid}"')
def step_mock_session_service(context: Any, sid: str) -> None:
"""Set up a mock session service returning the given session_id."""
session = MagicMock()
session.session_id = sid
service = MagicMock()
service.create.return_value = session
context._mock_service = service
context._expected_sid = sid
@given("the session service raises an exception")
def step_session_service_raises(context: Any) -> None:
"""Set up a mock session service that raises on create()."""
service = MagicMock()
service.create.side_effect = RuntimeError("db unavailable")
context._mock_service = service
context._expected_sid = "default"
# ---------------------------------------------------------------------------
# When steps
# ---------------------------------------------------------------------------
@when('I submit the text "{text}"')
def step_submit_text(context: Any, text: str) -> None:
"""Invoke _run_llm_dispatch directly with the given text."""
context._submitted_text = text # store for parameterized assertions
if context._facade is None:
# Preview fallback path — no dispatch, just echo
context._result_text = text
context._app_transcript = []
else:
context._result_text = _run_llm_dispatch(
context._facade, context._session_id, text
)
# Simulate what on_input_submitted does: pre-escape and store in transcript
from rich.markup import escape as _esc
context._app_transcript = [_esc(f"You: {text}")]
@when('I dispatch "{text}" overriding actor with "{actor}"')
def step_dispatch_text_with_actor(context: Any, text: str, actor: str) -> None:
"""Invoke _run_llm_dispatch with an explicit actor_override."""
context._submitted_text = text
context._result_text = _run_llm_dispatch(
context._facade, context._session_id, text, actor_override=actor
)
@when("the worker completes with a KeyboardInterrupt error")
def step_worker_keyboard_interrupt(context: Any) -> None:
"""Simulate a worker that completed with a KeyboardInterrupt."""
context._worker_error = KeyboardInterrupt("user interrupt")
@when("the worker completes with a SystemExit error")
def step_worker_system_exit(context: Any) -> None:
"""Simulate a worker that completed with a SystemExit."""
context._worker_error = SystemExit(1)
@when("the worker completes with neither result nor error")
def step_worker_no_result_no_error(context: Any) -> None:
"""Simulate a worker that completed with both result and error as None."""
context._worker_outcome = _format_worker_outcome(result=None, error=None)
@when("_build_tui_facade is called with a broken container")
def step_build_tui_facade_broken_container(context: Any) -> None:
"""Call _build_tui_facade with a container that always raises."""
from cleveragents.tui import commands as cmd_mod
cmd_mod._tui_session_service = None
broken = MagicMock()
broken.session_service.side_effect = RuntimeError("no db")
with patch("cleveragents.a2a.cli_bootstrap.get_facade") as mock_facade:
mock_facade.side_effect = ImportError("no a2a")
context._facade_result = cmd_mod._build_tui_facade()
cmd_mod._tui_session_service = None
@when('the worker completes with error "{error_msg}"')
def step_worker_completes_with_error(context: Any, error_msg: str) -> None:
"""Simulate a Textual worker that failed with the given exception."""
context._worker_outcome = _format_worker_outcome(
result=None, error=RuntimeError(error_msg)
)
@when('the worker completes with result "{result}"')
def step_worker_completes_with_result(context: Any, result: str) -> None:
"""Simulate a Textual worker that succeeded with the given result."""
context._worker_outcome = _format_worker_outcome(result=result, error=None)
@given('a facade returning assistant text "{reply}"')
def step_facade_returning_specific_reply(context: Any, reply: str) -> None:
"""Set up a mock facade with a specific assistant reply."""
context._facade = _make_mock_facade(response_text=reply)
context._session_id = "test-session-001"
@given('a persona state with active actor "{actor}"')
def step_persona_state_with_actor(context: Any, actor: str) -> None:
"""Set up a mock PersonaState whose active persona has the given actor."""
persona = MagicMock()
persona.actor = actor
state = MagicMock()
state.active_persona.return_value = persona
context._mock_persona_state = state
@when("I call _create_tui_session with that service")
def step_call_create_tui_session(context: Any) -> None:
"""Call _create_tui_session patching get_container, no persona state."""
from cleveragents.tui import commands as cmd_mod
container = MagicMock()
container.session_service.return_value = context._mock_service
# Reset singleton so the patched container is used on this call.
cmd_mod._tui_session_service = None
with patch.object(cmd_mod, "get_container", return_value=container):
context._result_sid = cmd_mod._create_tui_session()
cmd_mod._tui_session_service = None # clean up after test
@when("I call _create_tui_session with that service and persona state")
def step_call_create_tui_session_with_persona(context: Any) -> None:
"""Call _create_tui_session with a persona state so actor is bound."""
from cleveragents.tui import commands as cmd_mod
container = MagicMock()
container.session_service.return_value = context._mock_service
# Reset singleton so the patched container is used on this call.
cmd_mod._tui_session_service = None
with patch.object(cmd_mod, "get_container", return_value=container):
context._result_sid = cmd_mod._create_tui_session(
persona_state=context._mock_persona_state
)
cmd_mod._tui_session_service = None # clean up after test
# ---------------------------------------------------------------------------
# Then steps
# ---------------------------------------------------------------------------
@then("the facade should have received a message/send request")
def step_facade_received_request(context: Any) -> None:
"""Assert facade.dispatch was called with method=message/send."""
context._facade.dispatch.assert_called_once()
request = context._facade.dispatch.call_args[0][0]
assert request.method == "message/send", (
f"Expected method='message/send', got {request.method!r}"
)
assert request.params["session_id"] == context._session_id
assert request.params["message"] == context._submitted_text, (
f"Expected message={context._submitted_text!r}, "
f"got {request.params['message']!r}"
)
@then("the conversation should show the assistant response")
def step_conversation_shows_response(context: Any) -> None:
"""Assert the result contains the assistant response."""
assert "Mock LLM reply" in context._result_text, (
f"Expected assistant response in result, got: {context._result_text!r}"
)
@then('the conversation should show the preview text "{expected}"')
def step_conversation_shows_preview(context: Any, expected: str) -> None:
"""Assert the result is just the echoed input text (no-facade path)."""
assert expected in context._result_text, (
f"Expected {expected!r} in result, got: {context._result_text!r}"
)
@then("the conversation should show the no-actor error message")
def step_conversation_shows_no_actor(context: Any) -> None:
"""Assert the no-actor friendly message is returned."""
assert "No actor configured" in context._result_text, (
f"Expected no-actor message in result, got: {context._result_text!r}"
)
@then("the conversation should show an error message")
def step_conversation_shows_error(context: Any) -> None:
"""Assert an [Error] tag is in the result."""
assert "[Error]" in context._result_text, (
f"Expected [Error] in result, got: {context._result_text!r}"
)
@then('the returned session_id should be "{expected}"')
def step_returned_session_id(context: Any, expected: str) -> None:
"""Assert the session_id from _create_tui_session matches."""
assert context._result_sid == expected, (
f"Expected session_id={expected!r}, got {context._result_sid!r}"
)
@then('the facade should have received a message/send request with actor "{actor}"')
def step_facade_received_request_with_actor(context: Any, actor: str) -> None:
"""Assert the dispatch request included the given actor override."""
context._facade.dispatch.assert_called_once()
request = context._facade.dispatch.call_args[0][0]
assert request.method == "message/send", (
f"Expected method='message/send', got {request.method!r}"
)
assert request.params.get("actor") == actor, (
f"Expected actor={actor!r} in params, got {request.params.get('actor')!r}"
)
@then("the conversation should show a session-not-found error message")
def step_conversation_shows_session_not_found(context: Any) -> None:
"""Assert the session-not-found friendly message is returned."""
assert "Session not found" in context._result_text, (
f"Expected session-not-found message, got: {context._result_text!r}"
)
@then("the conversation should show a database error message")
def step_conversation_shows_database_error(context: Any) -> None:
"""Assert the database error message is returned."""
assert "Database error" in context._result_text, (
f"Expected database error message, got: {context._result_text!r}"
)
@then("the formatted outcome should show an error message")
def step_worker_outcome_shows_error(context: Any) -> None:
"""Assert _format_worker_outcome produced an [Error] string."""
assert context._worker_outcome is not None, "Expected non-None outcome"
assert "[Error]" in context._worker_outcome, (
f"Expected [Error] in outcome, got: {context._worker_outcome!r}"
)
@then('the formatted outcome should be "{expected}"')
def step_worker_outcome_equals(context: Any, expected: str) -> None:
"""Assert _format_worker_outcome returned the expected string."""
assert context._worker_outcome == expected, (
f"Expected {expected!r}, got {context._worker_outcome!r}"
)
@then('the conversation should show "(no response)"')
def step_conversation_shows_no_response(context: Any) -> None:
"""Assert the empty-response fallback text is present."""
assert "(no response)" in context._result_text, (
f"Expected '(no response)' in result, got: {context._result_text!r}"
)
@then("the transcript entry should have markup escaped")
def step_transcript_entry_escaped(context: Any) -> None:
"""Assert the transcript entry stored by on_input_submitted has escaped markup.
Verifies that _render_transcript receives pre-escaped entries — i.e. that
on_input_submitted calls _escape() before storing in the transcript, not
just at render time. This replaces the previous test that only validated
escape() in isolation without checking the storage path.
"""
import re
transcript = context._app_transcript
assert transcript, "Expected at least one transcript entry after submit"
entry = transcript[-1] # the most recently appended entry
# The stored entry must NOT contain unescaped Rich markup tags.
assert not re.search(r"(?<!\\)\[/?[a-zA-Z][^\]]*\]", entry), (
f"Bug: transcript entry was stored WITHOUT escaping markup: {entry!r}"
)
# The text content must still be present after escaping.
assert "attack" in entry, (
f"Message content 'attack' must survive markup escaping: {entry!r}"
)
@then("_format_worker_outcome should re-raise it")
def step_format_worker_outcome_reraises(context: Any) -> None:
"""Assert _format_worker_outcome re-raises BaseException subclasses."""
raised = False
err = context._worker_error
exc_type = type(err)
try:
_format_worker_outcome(result=None, error=err)
except exc_type:
raised = True
assert raised, f"_format_worker_outcome must re-raise {exc_type.__name__}"
@then("the formatted outcome should be None")
def step_formatted_outcome_none(context: Any) -> None:
"""Assert _format_worker_outcome returns None when both inputs are None."""
assert context._worker_outcome is None, (
f"Expected None from _format_worker_outcome(None, None), "
f"got: {context._worker_outcome!r}"
)
@then("the returned facade should be None")
def step_returned_facade_none(context: Any) -> None:
"""Assert _build_tui_facade returns None when construction fails."""
assert context._facade_result is None, (
f"Expected _build_tui_facade to return None on failure, "
f"got: {context._facade_result!r}"
)
@then('the session should have been created with actor "{actor}"')
def step_session_created_with_actor(context: Any, actor: str) -> None:
"""Assert service.create() was called with the persona's actor."""
context._mock_service.create.assert_called_once_with(actor_name=actor)