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
430 lines
17 KiB
Python
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)
|