feat(tui): wire normal text input to LLM via A2A facade

- 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
This commit is contained in:
2026-05-15 14:35:33 +00:00
parent 0c5724c2f6
commit 4e176c5517
6 changed files with 832 additions and 6 deletions
+385
View File
@@ -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}"
)
+37
View File
@@ -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"
+219
View File
@@ -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]]()
+60
View File
@@ -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
+61 -5
View File
@@ -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 <name> 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
+70 -1
View File
@@ -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