From 4e176c5517609a00a679345fa863d12c68728187 Mon Sep 17 00:00:00 2001 From: "hamza.khyari" Date: Fri, 15 May 2026 14:35:33 +0000 Subject: [PATCH] feat(tui): wire normal text input to LLM via A2A facade MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Replace hardcoded session_id="default" with real SessionService.create() via _create_tui_session() in run_tui() (commands.py) - Build and inject A2aLocalFacade + SessionWorkflow into CleverAgentsTuiApp via _build_tui_facade() — mirrors the CLI session command wiring pattern - Replace dead-end conversation.update(preview) with facade.dispatch() A2aRequest(method="message/send") and render assistant_message response - Catch SessionActorNotConfiguredError and show user-friendly message - Wrap blocking LLM call in self.run_worker(thread=True) to keep Textual event loop responsive during LLM API round-trips - Graceful degradation: facade=None falls back to preview-only mode - 6 Behave BDD scenarios + Robot Framework integration tests with FakeListLLM ISSUES CLOSED: #11230 --- features/steps/tui_llm_dispatch_steps.py | 385 +++++++++++++++++++++++ features/tui_llm_dispatch.feature | 37 +++ robot/helper_tui_llm_dispatch.py | 219 +++++++++++++ robot/tui_llm_dispatch.robot | 60 ++++ src/cleveragents/tui/app.py | 66 +++- src/cleveragents/tui/commands.py | 71 ++++- 6 files changed, 832 insertions(+), 6 deletions(-) create mode 100644 features/steps/tui_llm_dispatch_steps.py create mode 100644 features/tui_llm_dispatch.feature create mode 100644 robot/helper_tui_llm_dispatch.py create mode 100644 robot/tui_llm_dispatch.robot diff --git a/features/steps/tui_llm_dispatch_steps.py b/features/steps/tui_llm_dispatch_steps.py new file mode 100644 index 000000000..7614b268e --- /dev/null +++ b/features/steps/tui_llm_dispatch_steps.py @@ -0,0 +1,385 @@ +"""Step definitions for tui_llm_dispatch.feature. + +Tests the wiring between TUI normal text input, A2aLocalFacade.dispatch(), +and the conversation widget output. The Textual event loop is not started +— all assertions use the mock app infrastructure from tui_first_run_steps.py. +""" + +from __future__ import annotations + +import sys +from types import ModuleType +from typing import Any +from unittest.mock import MagicMock, patch + +from behave import given, then, when + +# --------------------------------------------------------------------------- +# Mock-Textual infrastructure (same pattern as tui_first_run_steps.py) +# --------------------------------------------------------------------------- + +_MOCK_TEXTUAL_KEYS = [ + "textual", + "textual.app", + "textual.containers", + "textual.widgets", +] + + +def _build_mock_textual() -> dict[str, ModuleType]: + mock_textual = ModuleType("textual") + mock_textual_app = ModuleType("textual.app") + mock_textual_containers = ModuleType("textual.containers") + mock_textual_widgets = ModuleType("textual.widgets") + + class MockApp: + def __init__(self, *args: object, **kwargs: object) -> None: + self._widgets: dict[str, object] = {} + self._workers: list[object] = [] + + def query_one(self, selector: str, widget_type: type | None = None) -> object: + if selector in self._widgets: + return self._widgets[selector] + widget = MagicMock() + self._widgets[selector] = widget + return widget + + def run_worker( + self, fn: Any, *, thread: bool = False, exclusive: bool = False + ) -> Any: + """Execute worker synchronously; trigger done_callback when set.""" + result = fn() + + class _Worker: + """Stub worker that calls done_callback immediately when set.""" + + def __init__(self) -> None: + self.result = result + self._done_cb: Any = None + + @property + def done_callback(self) -> Any: + return self._done_cb + + @done_callback.setter + def done_callback(self, cb: Any) -> None: + self._done_cb = cb + if callable(cb): + cb(self) + + worker = _Worker() + self._workers.append(worker) + return worker + + class MockVertical: + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def __enter__(self) -> MockVertical: + return self + + def __exit__(self, *args: object) -> None: + pass + + class MockStatic: + def __init__(self, *args: object, **kwargs: object) -> None: + self._text = "" + self.id = kwargs.get("id", "") + + def update(self, text: str) -> None: + self._text = text + + mock_textual_app.App = MockApp + mock_textual_containers.Vertical = MockVertical + mock_textual_widgets.Static = MockStatic + mock_textual_widgets.Header = MagicMock + mock_textual_widgets.Footer = MagicMock + + return { + "textual": mock_textual, + "textual.app": mock_textual_app, + "textual.containers": mock_textual_containers, + "textual.widgets": mock_textual_widgets, + } + + +def _load_app_module(context: Any) -> ModuleType: + """Load tui.app with mocked Textual, return the module.""" + mocks = _build_mock_textual() + context._mock_textual = mocks + with patch.dict(sys.modules, mocks): + import importlib + + import cleveragents.tui.app as app_mod + + importlib.reload(app_mod) + context._app_mod = app_mod + return app_mod + + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + + +def _make_mock_facade(response_text: str = "Mock LLM reply") -> MagicMock: + """Return a mock A2aLocalFacade that returns a canned assistant message.""" + facade = MagicMock() + response = MagicMock() + response.error = None + response.result = {"assistant_message": response_text} + facade.dispatch.return_value = response + return facade + + +def _make_mock_persona_state() -> MagicMock: + persona = MagicMock() + persona.name = "default" + persona.actor = "openai/gpt-4o" + persona.scoped_projects = [] + persona.scoped_plans = [] + state = MagicMock() + state.active_persona.return_value = persona + state.current_preset.return_value = "balanced" + return state + + +# --------------------------------------------------------------------------- +# Given steps +# --------------------------------------------------------------------------- + + +@given("the TUI app is initialised with a mock A2A facade and session") +def step_init_app_with_facade(context: Any) -> None: + """Build a CleverAgentsTuiApp with a mock facade injected.""" + app_mod = _load_app_module(context) + + context._facade = _make_mock_facade() + context._session_id = "test-session-001" + context._persona_state = _make_mock_persona_state() + + with patch.dict(sys.modules, context._mock_textual): + app = app_mod.CleverAgentsTuiApp( + command_router=MagicMock(), + persona_state=context._persona_state, + facade=context._facade, + session_id=context._session_id, + ) + context._app = app + # Register a mock Static conversation widget + conv = MagicMock() + conv._text = "" + + def _capture_update(text: str) -> None: + conv._text = text + + conv.update.side_effect = _capture_update + app._widgets = {"#conversation": conv, "#prompt": MagicMock()} + context._conversation = conv + + +@given("the TUI app is initialised without a facade") +def step_init_app_without_facade(context: Any) -> None: + """Build a CleverAgentsTuiApp with facade=None (preview fallback).""" + app_mod = _load_app_module(context) + context._persona_state = _make_mock_persona_state() + + with patch.dict(sys.modules, context._mock_textual): + app = app_mod.CleverAgentsTuiApp( + command_router=MagicMock(), + persona_state=context._persona_state, + facade=None, + session_id="default", + ) + context._app = app + conv = MagicMock() + conv._text = "" + + def _capture_update(text: str) -> None: + conv._text = text + + conv.update.side_effect = _capture_update + app._widgets = {"#conversation": conv, "#prompt": MagicMock()} + context._conversation = conv + context._facade = None + + +@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 + + app_mod = _load_app_module(context) + context._persona_state = _make_mock_persona_state() + with patch.dict(sys.modules, context._mock_textual): + app = app_mod.CleverAgentsTuiApp( + command_router=MagicMock(), + persona_state=context._persona_state, + facade=facade, + session_id="default", + ) + context._app = app + conv = MagicMock() + conv._text = "" + + def _capture_update(text: str) -> None: + conv._text = text + + conv.update.side_effect = _capture_update + app._widgets = {"#conversation": conv, "#prompt": MagicMock()} + context._conversation = conv + + +@given("the facade returns an A2A error response") +def step_facade_returns_error_response(context: Any) -> None: + """Configure the mock facade to return an A2A error.""" + 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 + + app_mod = _load_app_module(context) + context._persona_state = _make_mock_persona_state() + with patch.dict(sys.modules, context._mock_textual): + app = app_mod.CleverAgentsTuiApp( + command_router=MagicMock(), + persona_state=context._persona_state, + facade=facade, + session_id="default", + ) + context._app = app + conv = MagicMock() + conv._text = "" + + def _capture_update(text: str) -> None: + conv._text = text + + conv.update.side_effect = _capture_update + app._widgets = {"#conversation": conv, "#prompt": MagicMock()} + context._conversation = conv + + +@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 that returns 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: + """Simulate on_input_submitted with the given normal-mode text.""" + app = context._app + + # Mock the prompt widget to return the text + prompt_mock = MagicMock() + payload = MagicMock() + payload.text = text + prompt_mock.consume_text.return_value = payload + app._widgets["#prompt"] = prompt_mock + + # Mock reference-picker and slash-overlay widgets + app._widgets["#reference-picker"] = MagicMock() + app._widgets["#slash-overlay"] = MagicMock() + + with patch.dict(sys.modules, context._mock_textual): + app.on_input_submitted(MagicMock()) + + +@when("I call _create_tui_session with that service") +def step_call_create_tui_session(context: Any) -> None: + """Call _create_tui_session patching the container to return the mock service.""" + from cleveragents.tui import commands as cmd_mod + + container = MagicMock() + container.session_service.return_value = context._mock_service + with patch.object(cmd_mod, "get_container", return_value=container): + context._result_sid = cmd_mod._create_tui_session() + + +# --------------------------------------------------------------------------- +# 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() + call_args = context._facade.dispatch.call_args + request = 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"] == "hello there" + + +@then("the conversation should show the assistant response") +def step_conversation_shows_response(context: Any) -> None: + """Assert the conversation widget was updated with the LLM response.""" + text = context._conversation._text + assert "Mock LLM reply" in text, ( + f"Expected assistant response in conversation, got: {text!r}" + ) + + +@then('the conversation should show the preview text "{expected}"') +def step_conversation_shows_preview(context: Any, expected: str) -> None: + """Assert the conversation widget shows the preview (no-facade path).""" + text = context._conversation._text + assert expected in text, ( + f"Expected preview {expected!r} in conversation, got: {text!r}" + ) + + +@then("the conversation should show the no-actor error message") +def step_conversation_shows_no_actor_error(context: Any) -> None: + """Assert the no-actor error message is shown.""" + text = context._conversation._text + assert "No actor configured" in text, ( + f"Expected no-actor error in conversation, got: {text!r}" + ) + + +@then("the conversation should show an error message") +def step_conversation_shows_error(context: Any) -> None: + """Assert a generic error message is shown.""" + text = context._conversation._text + assert "[Error]" in text, f"Expected [Error] in conversation, got: {text!r}" + + +@then('the returned session_id should be "{expected}"') +def step_returned_session_id(context: Any, expected: str) -> None: + """Assert the session_id returned by _create_tui_session matches.""" + assert context._result_sid == expected, ( + f"Expected session_id={expected!r}, got {context._result_sid!r}" + ) diff --git a/features/tui_llm_dispatch.feature b/features/tui_llm_dispatch.feature new file mode 100644 index 000000000..718ce6b03 --- /dev/null +++ b/features/tui_llm_dispatch.feature @@ -0,0 +1,37 @@ +Feature: TUI normal text input dispatches to LLM via A2A facade + As a user of the CleverAgents TUI + I want my normal text messages to be sent to an LLM actor + So that I can have a real conversation through the TUI interface + + Background: + Given the TUI app is initialised with a mock A2A facade and session + + Scenario: Normal text input dispatches to LLM and renders response + When I submit the text "hello there" + Then the facade should have received a message/send request + And the conversation should show the assistant response + + Scenario: Normal text with no facade falls back to preview mode + Given the TUI app is initialised without a facade + When I submit the text "hello there" + Then the conversation should show the preview text "hello there" + + Scenario: LLM dispatch handles SessionActorNotConfiguredError gracefully + Given the facade raises SessionActorNotConfiguredError + When I submit the text "hello there" + Then the conversation should show the no-actor error message + + Scenario: LLM dispatch handles generic facade error gracefully + Given the facade returns an A2A error response + When I submit the text "hello there" + Then the conversation should show an error message + + Scenario: _create_tui_session returns real session_id from service + Given a mock session service that returns session_id "abc-123" + When I call _create_tui_session with that service + Then the returned session_id should be "abc-123" + + Scenario: _create_tui_session falls back to default when service unavailable + Given the session service raises an exception + When I call _create_tui_session with that service + Then the returned session_id should be "default" diff --git a/robot/helper_tui_llm_dispatch.py b/robot/helper_tui_llm_dispatch.py new file mode 100644 index 000000000..9948277e5 --- /dev/null +++ b/robot/helper_tui_llm_dispatch.py @@ -0,0 +1,219 @@ +"""Helper script for tui_llm_dispatch.robot integration tests. + +Each subcommand is a self-contained check that prints a sentinel on success. + +Uses a FakeListLLM stub and real SQLite DB — no API keys required. +""" + +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) + +# --------------------------------------------------------------------------- +# Shared stub helpers +# --------------------------------------------------------------------------- + + +def _make_fake_llm() -> Any: + """Return a FakeListLLM stub with canned responses.""" + try: + from langchain_community.llms import FakeListLLM + + return FakeListLLM(responses=["Hello from FakeListLLM"] * 20) + except ImportError: + return None + + +def _make_in_memory_db() -> Any: + """Return a fresh in-memory SQLAlchemy session factory.""" + from sqlalchemy import create_engine + from sqlalchemy.orm import sessionmaker + + from cleveragents.infrastructure.database.models import Base + + engine = create_engine("sqlite:///:memory:") + Base.metadata.create_all(engine) + return sessionmaker(bind=engine) + + +def _make_session_service(session_factory: Any) -> Any: + from cleveragents.application.services.session_service import ( + PersistentSessionService, + ) + + return PersistentSessionService(session_factory=session_factory) + + +def _make_workflow(session_service: Any, fake_llm: Any) -> Any: + from cleveragents.application.services.session_workflow import SessionWorkflow + + workflow = SessionWorkflow(session_service=session_service) + if fake_llm is not None: + # Patch _resolve_llm to return the stub so no API keys are needed + workflow._resolve_llm = lambda *_a, **_kw: fake_llm # type: ignore[method-assign] + return workflow + + +# --------------------------------------------------------------------------- +# Subcommands +# --------------------------------------------------------------------------- + + +def cmd_create_session() -> None: + """Verify _create_tui_session() creates a real DB-backed session.""" + from cleveragents.tui import commands as cmd_mod + + session_factory = _make_in_memory_db() + service = _make_session_service(session_factory) + 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" + + # Verify the session actually exists in the DB + 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 + + session_factory = _make_in_memory_db() + service = _make_session_service(session_factory) + 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 CleverAgentsTuiApp dispatches normal text and renders response.""" + from unittest.mock import MagicMock + + from cleveragents.a2a.facade import A2aLocalFacade + from cleveragents.a2a.models import A2aRequest, A2aResponse + + # Build a real facade backed by in-memory DB + FakeListLLM + session_factory = _make_in_memory_db() + service = _make_session_service(session_factory) + fake_llm = _make_fake_llm() + workflow = _make_workflow(service, fake_llm) + + facade = A2aLocalFacade( + services={ + "session_service": service, + "session_workflow": workflow, + } + ) + + # Create a real session + session = service.create(actor_name="openai/gpt-4o") + session_id = session.session_id + + # Build a minimal mock persona state + persona = MagicMock() + persona.name = "default" + persona.actor = "openai/gpt-4o" + persona.scoped_projects = [] + persona.scoped_plans = [] + persona_state = MagicMock() + persona_state.active_persona.return_value = persona + persona_state.current_preset.return_value = "balanced" + + # We test the facade dispatch directly (no Textual event loop needed) + request = A2aRequest( + method="message/send", + params={"session_id": session_id, "message": "hello integration"}, + ) + response: A2aResponse = 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" + + # Verify messages persisted in DB + messages = service.get_messages(session_id) + roles = [m["role"] 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 that SessionActorNotConfiguredError is handled gracefully.""" + from cleveragents.a2a.facade import A2aLocalFacade + from cleveragents.a2a.models import A2aRequest + + session_factory = _make_in_memory_db() + service = _make_session_service(session_factory) + + # Create session with NO actor configured + session = service.create(actor_name=None) + session_id = session.session_id + + from cleveragents.application.services.session_workflow import SessionWorkflow + + workflow = SessionWorkflow(session_service=service, provider_registry=None) + facade = A2aLocalFacade( + services={"session_service": service, "session_workflow": workflow} + ) + + request = A2aRequest( + method="message/send", + params={"session_id": session_id, "message": "hello"}, + ) + response = facade.dispatch(request) + + # Facade should return an error response (not raise) for no-actor + assert response.error is not None, "Expected error response for no-actor session" + + print("tui-no-actor-error-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, +} + +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]]() diff --git a/robot/tui_llm_dispatch.robot b/robot/tui_llm_dispatch.robot new file mode 100644 index 000000000..e403f3f4b --- /dev/null +++ b/robot/tui_llm_dispatch.robot @@ -0,0 +1,60 @@ +*** Settings *** +Documentation Integration tests for TUI normal text input → A2A facade → LLM dispatch. +... +... Verifies that: +... - _create_tui_session() creates a real database-backed session +... - _build_tui_facade() produces a wired A2aLocalFacade +... - CleverAgentsTuiApp dispatches normal text to the facade +... and renders the assistant response +... +... Uses a FakeListLLM stub — no real API keys required. +Resource ${CURDIR}/common.resource +Suite Setup Setup Test Environment With Database Isolation +Suite Teardown Cleanup Test Environment + +*** Variables *** +${HELPER} ${CURDIR}/helper_tui_llm_dispatch.py + +*** Test Cases *** +TUI Create Session Returns Real Session ID + [Documentation] Verify _create_tui_session() creates a DB-backed session. + [Tags] tui_llm_dispatch + ${result}= Run Process ${PYTHON} ${HELPER} create-session + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-create-session-ok + +TUI Build Facade Returns Wired Facade + [Documentation] Verify _build_tui_facade() returns a non-None A2aLocalFacade. + [Tags] tui_llm_dispatch + ${result}= Run Process ${PYTHON} ${HELPER} build-facade + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-build-facade-ok + +TUI Normal Text Dispatches To Facade And Renders Response + [Documentation] Verify CleverAgentsTuiApp dispatches normal text to + ... the facade and updates the conversation widget with the + ... assistant response from a FakeListLLM. + [Tags] tui_llm_dispatch + ${result}= Run Process ${PYTHON} ${HELPER} dispatch-message + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-dispatch-message-ok + +TUI No Actor Error Renders Friendly Message + [Documentation] Verify that when no actor is configured, + ... the TUI shows a friendly error rather than crashing. + [Tags] tui_llm_dispatch + ${result}= Run Process ${PYTHON} ${HELPER} no-actor-error + ... cwd=${WORKSPACE} + Log ${result.stdout} + Log ${result.stderr} + Should Be Equal As Integers ${result.rc} 0 + Should Contain ${result.stdout} tui-no-actor-error-ok diff --git a/src/cleveragents/tui/app.py b/src/cleveragents/tui/app.py index 68401dedd..32dd3e728 100644 --- a/src/cleveragents/tui/app.py +++ b/src/cleveragents/tui/app.py @@ -23,6 +23,8 @@ from cleveragents.tui.widgets.reference_picker import ReferencePickerOverlay from cleveragents.tui.widgets.slash_command_overlay import SlashCommandOverlay if TYPE_CHECKING: + from cleveragents.a2a.facade import A2aLocalFacade + InputSubmittedEvent = Any else: InputSubmittedEvent = Any @@ -72,9 +74,14 @@ 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, + facade: A2aLocalFacade | None = None, + session_id: str = "default", ) -> None: - del command_router, persona_state + del command_router, persona_state, facade, session_id def run(self) -> None: raise RuntimeError( @@ -101,11 +108,14 @@ if _TEXTUAL_AVAILABLE: *, command_router: _CommandRouter, persona_state: PersonaState, + facade: A2aLocalFacade | None = None, + session_id: str = "default", ) -> None: super().__init__() self._command_router = command_router self._persona_state = persona_state - self._session = SessionView(session_id="default", transcript=[]) + self._facade = facade + self._session = SessionView(session_id=session_id, transcript=[]) def compose(self) -> Any: yield _Header(show_clock=True) @@ -201,13 +211,59 @@ if _TEXTUAL_AVAILABLE: conversation.update(f"$ {shell.command}\n{output}") return - preview = result.expanded_text + expanded = result.expanded_text if "@" in text: ref_picker = self.query_one("#reference-picker", ReferencePickerOverlay) ref_picker.set_suggestions( text, suggestions(text.replace("@", "").strip()) ) - conversation.update(preview) + + if self._facade is None: + # Facade not wired yet — preview only (graceful degradation) + conversation.update(expanded) + return + + conversation.update(f"You: {expanded}\n\n⏳ Thinking...") + + # Step 5: run blocking LLM call in a worker thread so the + # Textual event loop stays responsive during the API round-trip. + session_id = self._session.session_id + facade = self._facade + + def _dispatch_llm() -> str: + """Blocking call executed off the main Textual thread.""" + from cleveragents.a2a.models import A2aRequest + from cleveragents.domain.models.core.session import ( + SessionActorNotConfiguredError, + ) + + try: + response = facade.dispatch( + A2aRequest( + method="message/send", + params={"session_id": session_id, "message": expanded}, + ) + ) + if response.error is not None: + return f"[Error] {response.error.message}" + result_data: dict[str, Any] = response.result or {} + assistant_text: str = result_data.get("assistant_message", "") + return f"You: {expanded}\n\nAssistant: {assistant_text}" + except SessionActorNotConfiguredError: + return ( + "No actor configured. " + "Use /persona set or select an actor first." + ) + except Exception as exc: # pragma: no cover + return f"[Error] {exc}" + + def _on_llm_done(worker: Any) -> None: + """Update conversation widget once worker finishes.""" + if worker.result is not None: + conversation.update(worker.result) + + worker = self.run_worker(_dispatch_llm, thread=True, exclusive=False) + worker.done_callback = _on_llm_done _ResolvedTuiApp = _TextualCleverAgentsTuiApp diff --git a/src/cleveragents/tui/commands.py b/src/cleveragents/tui/commands.py index d155ef726..3c94a1cbe 100644 --- a/src/cleveragents/tui/commands.py +++ b/src/cleveragents/tui/commands.py @@ -223,6 +223,67 @@ class TuiCommandRouter: return f"Import failed: {exc}" +def _build_tui_facade() -> Any: + """Build and wire an :class:`~cleveragents.a2a.facade.A2aLocalFacade` for the TUI. + + Follows the same pattern as ``_facade_dispatch`` in + ``cli/commands/session.py``: the facade is retrieved via + :func:`~cleveragents.a2a.cli_bootstrap.get_facade` and the session + workflow is registered so ``message/send`` resolves through the real + LLM stack. Any failure is suppressed so the TUI degrades gracefully + to preview-only mode rather than crashing. + """ + import contextlib + + try: + from cleveragents.a2a.cli_bootstrap import get_facade + from cleveragents.application.services.session_workflow import SessionWorkflow + + facade = get_facade() + container = get_container() + + session_svc = None + with contextlib.suppress(Exception): + session_svc = container.session_service() + facade.register_service("session_service", session_svc) + + with contextlib.suppress(Exception): + provider_registry = None + try: + from cleveragents.providers.registry import get_provider_registry + + provider_registry = get_provider_registry() + except (ImportError, RuntimeError, OSError): + pass + if session_svc is not None: + facade.register_service( + "session_workflow", + SessionWorkflow( + session_service=session_svc, + provider_registry=provider_registry, + ), + ) + return facade + except Exception: # pragma: no cover + return None + + +def _create_tui_session() -> str: + """Create a real database-backed TUI session and return its session_id. + + Falls back to the string ``"default"`` if the session service is + unavailable so the TUI can still launch without a database. + """ + import contextlib + + with contextlib.suppress(Exception): + container = get_container() + service = container.session_service() + session = service.create(actor_name=None) + return session.session_id + return "default" # graceful fallback + + def run_tui(*, headless: bool = False) -> int: """Run the Textual TUI app or a headless startup check.""" container = get_container() @@ -242,6 +303,14 @@ def run_tui(*, headless: bool = False) -> int: print(json.dumps(payload, indent=2)) return 0 - app = CleverAgentsTuiApp(command_router=router, persona_state=state) + facade = _build_tui_facade() + session_id = _create_tui_session() + + app = CleverAgentsTuiApp( + command_router=router, + persona_state=state, + facade=facade, + session_id=session_id, + ) app.run() return 0