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:
@@ -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}"
|
||||
)
|
||||
@@ -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"
|
||||
@@ -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]]()
|
||||
@@ -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
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user