diff --git a/features/steps/tui_materializer_a2a_integration_steps.py b/features/steps/tui_materializer_a2a_integration_steps.py new file mode 100644 index 000000000..b3464d759 --- /dev/null +++ b/features/steps/tui_materializer_a2a_integration_steps.py @@ -0,0 +1,474 @@ +"""Step definitions for TUI Materializer A2A Integration tests.""" + +from __future__ import annotations + +from typing import Any +from unittest.mock import Mock + +from behave import given, then, when + +from cleveragents.cli.output.handles import ( + ElementClosed, + ElementCreated, + ElementUpdated, + SessionEnd, + TextBlock, +) +from cleveragents.tui.a2a_integration import TuiA2aAdapter, TuiA2aIntegration +from cleveragents.tui.materializer import TuiMaterializer + + +@given("I have a TUI materializer instance") +def step_create_materializer(context: Any) -> None: + """Create a TUI materializer instance.""" + context.materializer = TuiMaterializer() + + +@given("I have a TUI materializer instance with recorded events") +def step_create_materializer_with_events(context: Any) -> None: + """Create a TUI materializer with some recorded events.""" + context.materializer = TuiMaterializer() + # Simulate some events + context.materializer._events = [ + {"type": "session_begin", "session_id": "test-session"}, + {"type": "element_created", "element_id": "elem-1", "element_type": "text"}, + ] + + +@when("I check the materializer state") +def step_check_materializer_state(context: Any) -> None: + """Check the current state of the materializer.""" + context.session_active = context.materializer._session_active + context.events_count = len(context.materializer._events) + + +@then("the materializer should be initialized with empty events") +def step_verify_empty_events(context: Any) -> None: + """Verify the materializer has empty events.""" + assert context.events_count == 0, "Events should be empty" + + +@then("the session should be inactive") +def step_verify_session_inactive(context: Any) -> None: + """Verify the session is inactive.""" + assert context.session_active is False, "Session should be inactive" + + +@when("I call on_session_begin with a session") +def step_call_session_begin(context: Any) -> None: + """Call on_session_begin with a mock session.""" + mock_session = Mock() + mock_session.id = "test-session" + mock_session.timestamp = "2026-04-20T00:00:00Z" + context.materializer.on_session_begin(mock_session) + + +@then("the materializer should record a session_begin event") +def step_verify_session_begin_event(context: Any) -> None: + """Verify a session_begin event was recorded.""" + events = context.materializer.get_events() + assert any(e["type"] == "session_begin" for e in events) + + +@then("the session should be marked as active") +def step_verify_session_active(context: Any) -> None: + """Verify the session is marked as active.""" + assert context.materializer._session_active is True + + +@when("I call on_element_created with an element") +def step_call_element_created(context: Any) -> None: + """Call on_element_created with a mock element.""" + event = ElementCreated( + event_type="element_created", + handle_id="elem-1", + element_kind="text", + declaration_index=0, + initial_state=TextBlock(content="Test content"), + ) + context.materializer.on_element_created(event) + + +@then("the materializer should record an element_created event") +def step_verify_element_created_event(context: Any) -> None: + """Verify an element_created event was recorded.""" + events = context.materializer.get_events() + assert any(e["type"] == "element_created" for e in events) + + +@then("the event should contain the element ID and type") +def step_verify_element_event_content(context: Any) -> None: + """Verify the element event contains ID and type.""" + events = context.materializer.get_events() + element_event = next(e for e in events if e["type"] == "element_created") + assert element_event["element_id"] == "elem-1" + assert element_event["element_type"] == "text" + + +@when("I call on_element_updated with an element") +def step_call_element_updated(context: Any) -> None: + """Call on_element_updated with a mock element.""" + event = ElementUpdated( + event_type="element_updated", + handle_id="elem-1", + element_kind="text", + element_snapshot=TextBlock(content="Updated content"), + ) + context.materializer.on_element_updated(event) + + +@then("the materializer should record an element_updated event") +def step_verify_element_updated_event(context: Any) -> None: + """Verify an element_updated event was recorded.""" + events = context.materializer.get_events() + assert any(e["type"] == "element_updated" for e in events) + + +@then("the event should contain the updated element data") +def step_verify_updated_element_data(context: Any) -> None: + """Verify the updated element event contains correct data.""" + events = context.materializer.get_events() + element_event = next(e for e in events if e["type"] == "element_updated") + assert element_event["element_id"] == "elem-1" + + +@when("I call on_element_closed with an element ID") +def step_call_element_closed(context: Any) -> None: + """Call on_element_closed with an element ID.""" + event = ElementClosed( + event_type="element_closed", + handle_id="elem-1", + element_kind="text", + final_state=TextBlock(content="Final content"), + ) + context.materializer.on_element_closed(event) + + +@then("the materializer should record an element_closed event") +def step_verify_element_closed_event(context: Any) -> None: + """Verify an element_closed event was recorded.""" + events = context.materializer.get_events() + assert any(e["type"] == "element_closed" for e in events) + + +@then("the event should contain the element ID") +def step_verify_element_closed_id(context: Any) -> None: + """Verify the element_closed event contains the element ID.""" + events = context.materializer.get_events() + element_event = next(e for e in events if e["type"] == "element_closed") + assert element_event["element_id"] == "elem-1" + + +@when("I call on_session_end with a session end event") +def step_call_session_end(context: Any) -> None: + """Call on_session_end with a mock session end event.""" + event = SessionEnd( + event_type="session_end", + handle_id="session", + element_kind="session", + exit_code=0, + ) + context.materializer.on_session_end(event) + + +@then("the materializer should record a session_end event") +def step_verify_session_end_event(context: Any) -> None: + """Verify a session_end event was recorded.""" + events = context.materializer.get_events() + assert any(e["type"] == "session_end" for e in events) + + +@then("the session should be marked as inactive") +def step_verify_session_marked_inactive(context: Any) -> None: + """Verify the session is marked as inactive after session end.""" + assert context.materializer._session_active is False, "Session should be inactive" + + +@when("I call get_output") +def step_call_get_output(context: Any) -> None: + """Call get_output on the materializer.""" + context.output = context.materializer.get_output() + + +@then("the materializer output should be valid JSON") +def step_verify_valid_json(context: Any) -> None: + """Verify the output is valid JSON.""" + import json + + try: + json.loads(context.output) + except json.JSONDecodeError as err: + raise AssertionError("Output is not valid JSON") from err + + +@then("the JSON should contain all recorded events") +def step_verify_json_contains_events(context: Any) -> None: + """Verify the JSON contains all recorded events.""" + import json + + data = json.loads(context.output) + assert isinstance(data, list) + assert len(data) == len(context.materializer._events) + + +@when("I call get_error_output with an exception") +def step_call_get_error_output(context: Any) -> None: + """Call get_error_output with an exception.""" + error = ValueError("Test error") + context.error_output = context.materializer.get_error_output(error, "test context") + + +@then("the materializer should record an error event") +def step_verify_error_event(context: Any) -> None: + """Verify an error event was recorded.""" + events = context.materializer.get_events() + assert any(e["type"] == "error" for e in events) + + +@then("the error output should contain the error type and message") +def step_verify_error_output_content(context: Any) -> None: + """Verify the error output contains error details.""" + import json + + error_data = json.loads(context.error_output) + assert error_data["error_type"] == "ValueError" + assert "Test error" in error_data["error_message"] + + +@when("I call clear_events") +def step_call_clear_events(context: Any) -> None: + """Call clear_events on the materializer.""" + context.materializer.clear_events() + + +@then("the events buffer should be empty") +def step_verify_events_cleared(context: Any) -> None: + """Verify the events buffer is empty.""" + assert len(context.materializer._events) == 0 + + +@when("I call get_events") +def step_call_get_events(context: Any) -> None: + """Call get_events on the materializer.""" + context.events_list = context.materializer.get_events() + + +@then("the returned list should contain all recorded events") +def step_verify_events_list(context: Any) -> None: + """Verify the returned list contains all events.""" + assert len(context.events_list) == len(context.materializer._events) + + +@then("the original buffer should remain unchanged") +def step_verify_buffer_unchanged(context: Any) -> None: + """Verify the original buffer is unchanged.""" + assert len(context.materializer._events) == len(context.events_list) + + +@given("I have a TUI A2A integration instance") +def step_create_a2a_integration(context: Any) -> None: + """Create a TUI A2A integration instance.""" + context.integration = TuiA2aIntegration() + + +@given("I have a TUI A2A integration instance with pending events") +def step_create_integration_with_events(context: Any) -> None: + """Create a TUI A2A integration with pending events.""" + context.integration = TuiA2aIntegration() + context.integration.emit_event({"type": "test_event", "data": "test"}) + + +@when("I check the integration state") +def step_check_integration_state(context: Any) -> None: + """Check the integration state.""" + context.has_materializer = context.integration.materializer is not None + context.queue_empty = len(context.integration._event_queue) == 0 + + +@then("the integration should have a materializer") +def step_verify_has_materializer(context: Any) -> None: + """Verify the integration has a materializer.""" + assert context.has_materializer is True + + +@then("the event queue should be empty") +def step_verify_queue_empty(context: Any) -> None: + """Verify the event queue is empty.""" + assert context.queue_empty is True + + +@when("I convert a TUI event to A2A event") +def step_convert_tui_to_a2a(context: Any) -> None: + """Convert a TUI event to A2A event.""" + tui_event = {"type": "test_event", "data": "test_data"} + context.a2a_event = context.integration.convert_to_a2a_event(tui_event) + + +@then("the A2A event should have the correct type") +def step_verify_a2a_event_type(context: Any) -> None: + """Verify the A2A event has correct type.""" + assert context.a2a_event.event_type == "test_event" + + +@then("the A2A event should contain the TUI event data") +def step_verify_a2a_event_data(context: Any) -> None: + """Verify the A2A event contains TUI data.""" + assert context.a2a_event.data["data"] == "test_data" + + +@when("I emit an A2A event") +def step_emit_a2a_event(context: Any) -> None: + """Emit an A2A event.""" + context.integration.emit_event({"type": "emit_test", "value": 42}) + + +@then("the event should be added to the queue") +def step_verify_event_in_queue(context: Any) -> None: + """Verify the event was added to the queue.""" + assert len(context.integration._event_queue) > 0 + + +@then("the event handler should be called if provided") +def step_verify_event_handler_called(context: Any) -> None: + """Verify the event handler is called if one was provided. + + Since no event handler was provided in this scenario, this step + simply verifies the emit completed without error. + """ + # No handler was provided, so nothing to assert — emit succeeded + assert len(context.integration._event_queue) > 0 + + +@when("I call get_pending_events") +def step_call_get_pending_events(context: Any) -> None: + """Call get_pending_events.""" + context.pending_events = context.integration.get_pending_events() + + +@then("the pending events should be returned") +def step_verify_pending_events_returned(context: Any) -> None: + """Verify pending events were returned.""" + assert len(context.pending_events) > 0 + + +@then("the event queue should be cleared") +def step_verify_event_queue_cleared(context: Any) -> None: + """Verify the event queue is cleared after get_pending_events.""" + assert len(context.integration._event_queue) == 0 + + +@given("I have a TUI A2A integration instance with recorded events") +def step_create_integration_with_recorded_events(context: Any) -> None: + """Create integration with recorded events.""" + context.integration = TuiA2aIntegration() + context.integration.materializer._events = [{"type": "test", "data": "value"}] + + +@when("I create an A2A response") +def step_create_a2a_response(context: Any) -> None: + """Create an A2A response.""" + context.response = context.integration.create_response("success") + + +@then("the response should contain TUI events") +def step_verify_response_has_events(context: Any) -> None: + """Verify the response contains TUI events.""" + assert "tui_events" in context.response.result + + +@then("the response should contain TUI output") +def step_verify_response_has_output(context: Any) -> None: + """Verify the response contains TUI output. + + Handles both the integration response (context.response) and the + adapter response (context.adapter_response) scenarios. + """ + response = getattr(context, "adapter_response", None) or getattr( + context, "response", None + ) + assert response is not None, "No response found in context" + assert "tui_output" in response.result + + +@given("I have a TUI A2A adapter instance") +def step_create_a2a_adapter(context: Any) -> None: + """Create a TUI A2A adapter instance.""" + context.adapter = TuiA2aAdapter() + + +@when("I check the adapter state") +def step_check_adapter_state(context: Any) -> None: + """Check the current state of the adapter.""" + context.has_integration = context.adapter.integration is not None + + +@then("the adapter should have an integration instance") +def step_verify_adapter_has_integration(context: Any) -> None: + """Verify the adapter has an integration instance.""" + assert context.has_integration is True + + +@when("I handle TUI output") +def step_handle_tui_output(context: Any) -> None: + """Handle TUI output.""" + context.output_event = context.adapter.handle_tui_output("Test output") + + +@then("an A2A event should be created") +def step_verify_a2a_event_created(context: Any) -> None: + """Verify an A2A output event was created.""" + assert context.output_event is not None + assert context.output_event.event_type == "tui_output" + + +@then("a TUI error A2A event should be created") +def step_verify_error_a2a_event_created(context: Any) -> None: + """Verify an A2A error event was created.""" + assert context.error_event is not None + assert context.error_event.event_type == "tui_error" + + +@then("the event should contain the output data") +def step_verify_output_event_data(context: Any) -> None: + """Verify the event contains output data.""" + assert context.output_event.data["output"] == "Test output" + + +@when("I handle a TUI error") +def step_handle_tui_error(context: Any) -> None: + """Handle a TUI error.""" + error = RuntimeError("Test error") + context.error_event = context.adapter.handle_tui_error(error) + + +@then("the event should contain the error information") +def step_verify_error_event_info(context: Any) -> None: + """Verify the event contains error information.""" + assert context.error_event.event_type == "tui_error" + assert "RuntimeError" in context.error_event.data["error_type"] + + +@given("I have a TUI A2A adapter instance with recorded events") +def step_create_adapter_with_events(context: Any) -> None: + """Create adapter with recorded events.""" + context.adapter = TuiA2aAdapter() + context.adapter.integration.materializer._events = [ + {"type": "test", "data": "value"} + ] + + +@when("I get a response from the adapter") +def step_get_adapter_response(context: Any) -> None: + """Get a response from the adapter.""" + context.adapter_response = context.adapter.get_response("success") + + +@then("the response status should be set correctly") +def step_verify_response_status(context: Any) -> None: + """Verify the response status is set correctly.""" + result = context.adapter_response.result + assert result is not None, "Response result must not be None" + assert "tui_events" in result, "result must contain tui_events" + assert "tui_output" in result, "result must contain tui_output" + assert isinstance(result["tui_events"], list), "tui_events must be a list" + assert isinstance(result["tui_output"], str), "tui_output must be a string" diff --git a/features/tui_materializer_a2a_integration.feature b/features/tui_materializer_a2a_integration.feature new file mode 100644 index 000000000..31045b954 --- /dev/null +++ b/features/tui_materializer_a2a_integration.feature @@ -0,0 +1,116 @@ +Feature: TUI Materializer A2A Integration Layer + As a developer + I want to integrate TUI output with the A2A communication system + So that TUI events can be streamed and communicated across agent boundaries + + Scenario: Initialize TUI materializer + Given I have a TUI materializer instance + When I check the materializer state + Then the materializer should be initialized with empty events + And the session should be inactive + + Scenario: Handle session begin event + Given I have a TUI materializer instance + When I call on_session_begin with a session + Then the materializer should record a session_begin event + And the session should be marked as active + + Scenario: Handle element creation event + Given I have a TUI materializer instance + When I call on_element_created with an element + Then the materializer should record an element_created event + And the event should contain the element ID and type + + Scenario: Handle element update event + Given I have a TUI materializer instance + When I call on_element_updated with an element + Then the materializer should record an element_updated event + And the event should contain the updated element data + + Scenario: Handle element close event + Given I have a TUI materializer instance + When I call on_element_closed with an element ID + Then the materializer should record an element_closed event + And the event should contain the element ID + + Scenario: Handle session end event + Given I have a TUI materializer instance + When I call on_session_end with a session end event + Then the materializer should record a session_end event + And the session should be marked as inactive + + Scenario: Get output as JSON + Given I have a TUI materializer instance with recorded events + When I call get_output + Then the materializer output should be valid JSON + And the JSON should contain all recorded events + + Scenario: Handle error output + Given I have a TUI materializer instance + When I call get_error_output with an exception + Then the materializer should record an error event + And the error output should contain the error type and message + + Scenario: Clear events + Given I have a TUI materializer instance with recorded events + When I call clear_events + Then the events buffer should be empty + + Scenario: Get events list + Given I have a TUI materializer instance with recorded events + When I call get_events + Then the returned list should contain all recorded events + And the original buffer should remain unchanged + + Scenario: Initialize A2A integration + Given I have a TUI A2A integration instance + When I check the integration state + Then the integration should have a materializer + And the event queue should be empty + + Scenario: Convert TUI event to A2A event + Given I have a TUI A2A integration instance + When I convert a TUI event to A2A event + Then the A2A event should have the correct type + And the A2A event should contain the TUI event data + + Scenario: Emit A2A event + Given I have a TUI A2A integration instance + When I emit an A2A event + Then the event should be added to the queue + And the event handler should be called if provided + + Scenario: Get pending events + Given I have a TUI A2A integration instance with pending events + When I call get_pending_events + Then the pending events should be returned + And the event queue should be cleared + + Scenario: Create A2A response with TUI output + Given I have a TUI A2A integration instance with recorded events + When I create an A2A response + Then the response should contain TUI events + And the response should contain TUI output + + Scenario: Initialize A2A adapter + Given I have a TUI A2A adapter instance + When I check the adapter state + Then the adapter should have an integration instance + + Scenario: Handle TUI output in adapter + Given I have a TUI A2A adapter instance + When I handle TUI output + Then an A2A event should be created + And the event should contain the output data + + Scenario: Handle TUI error in adapter + Given I have a TUI A2A adapter instance + When I handle a TUI error + Then a TUI error A2A event should be created + And the event should contain the error information + + Scenario: Get response from adapter + Given I have a TUI A2A adapter instance with recorded events + When I get a response from the adapter + Then the response should contain TUI output + And the response status should be set correctly diff --git a/src/cleveragents/tui/__init__.py b/src/cleveragents/tui/__init__.py index 650585e82..91ee334b3 100644 --- a/src/cleveragents/tui/__init__.py +++ b/src/cleveragents/tui/__init__.py @@ -12,7 +12,27 @@ def run_tui(*, headless: bool = False) -> int: return _run_tui(headless=headless) +def __getattr__(name: str) -> object: + """Lazy import for TUI materializer and A2A integration.""" + if name == "TuiMaterializer": + from cleveragents.tui.materializer import TuiMaterializer + + return TuiMaterializer + elif name == "TuiA2aIntegration": + from cleveragents.tui.a2a_integration import TuiA2aIntegration + + return TuiA2aIntegration + elif name == "TuiA2aAdapter": + from cleveragents.tui.a2a_integration import TuiA2aAdapter + + return TuiA2aAdapter + raise AttributeError(f"module {__name__!r} has no attribute {name!r}") + + __all__ = [ "CleverAgentsTuiApp", + "TuiA2aAdapter", + "TuiA2aIntegration", + "TuiMaterializer", "run_tui", ] diff --git a/src/cleveragents/tui/_tui_events.py b/src/cleveragents/tui/_tui_events.py index 0f205bcd6..4740e5059 100644 --- a/src/cleveragents/tui/_tui_events.py +++ b/src/cleveragents/tui/_tui_events.py @@ -23,6 +23,7 @@ if TYPE_CHECKING: class TuiWidgetEventType: """String constants for TUI widget event types.""" + SESSION_BEGIN: str = "session_begin" ELEMENT_CREATED: str = "element_created" ELEMENT_UPDATED: str = "element_updated" ELEMENT_CLOSED: str = "element_closed" diff --git a/src/cleveragents/tui/a2a_integration.py b/src/cleveragents/tui/a2a_integration.py new file mode 100644 index 000000000..56385fa86 --- /dev/null +++ b/src/cleveragents/tui/a2a_integration.py @@ -0,0 +1,197 @@ +"""A2A integration layer for TUI materializer. + +This module provides the integration between the TUI materializer and the +A2A (Agent-to-Agent) communication system, enabling TUI output to be +streamed and communicated across agent boundaries. +""" + +from __future__ import annotations + +from collections.abc import Callable +from typing import TYPE_CHECKING, Any +from uuid import uuid4 + +from cleveragents.a2a.models import A2aErrorDetail, A2aEvent, A2aResponse +from cleveragents.tui.materializer import TuiMaterializer + +if TYPE_CHECKING: + pass + + +class TuiA2aIntegration: + """Integration layer between TUI materializer and A2A communication. + + This class manages the conversion of TUI output events into A2A events + and handles the transmission of these events through A2A channels. + """ + + def __init__( + self, + materializer: TuiMaterializer | None = None, + event_handler: Callable[[A2aEvent], None] | None = None, + ) -> None: + """Initialize the TUI A2A integration. + + Args: + materializer: The TUI materializer instance. If None, a new one + is created. + event_handler: Optional callback for handling A2A events. + """ + self.materializer = materializer or TuiMaterializer() + self.event_handler = event_handler + self._event_queue: list[A2aEvent] = [] + + def convert_to_a2a_event( + self, + event_data: dict[str, Any], + ) -> A2aEvent: + """Convert a TUI event to an A2A event. + + Args: + event_data: The TUI event data to convert. + + Returns: + An A2aEvent instance. + """ + return A2aEvent( + event_id=str(uuid4()), + event_type=event_data.get("type", "tui_event"), + data=event_data, + ) + + def emit_event(self, event_data: dict[str, Any]) -> None: + """Emit a TUI event as an A2A event. + + Args: + event_data: The event data to emit. + """ + a2a_event = self.convert_to_a2a_event(event_data) + self._event_queue.append(a2a_event) + + if self.event_handler: + self.event_handler(a2a_event) + + def get_pending_events(self) -> list[A2aEvent]: + """Get all pending A2A events. + + Returns: + A list of pending A2A events. + """ + events = self._event_queue.copy() + self._event_queue.clear() + return events + + def create_response( + self, + status: str = "success", + data: dict[str, Any] | None = None, + ) -> A2aResponse: + """Create an A2A response with TUI output. + + Args: + status: The response status. Use "success" for a result response, + any other value for an error response. + data: Optional response data. + + Returns: + An A2aResponse instance. + """ + response_data = data or {} + response_data["tui_events"] = self.materializer.get_events() + response_data["tui_output"] = self.materializer.get_output() + + if status == "success": + return A2aResponse( + id=str(uuid4()), + result=response_data, + ) + return A2aResponse( + id=str(uuid4()), + error=A2aErrorDetail( + code=-1, + message=status, + data=response_data, + ), + ) + + def clear_events(self) -> None: + """Clear all accumulated events.""" + self.materializer.clear_events() + self._event_queue.clear() + + +class TuiA2aAdapter: + """Adapter for integrating TUI materializer with A2A system. + + This adapter provides a bridge between the TUI output system and the + A2A communication infrastructure, handling event routing and + serialization. + """ + + def __init__(self, integration: TuiA2aIntegration | None = None) -> None: + """Initialize the TUI A2A adapter. + + Args: + integration: The TUI A2A integration instance. If None, a new + one is created. + """ + self.integration = integration or TuiA2aIntegration() + + def handle_tui_output( + self, + output: str, + metadata: dict[str, Any] | None = None, + ) -> A2aEvent: + """Handle TUI output and convert to A2A event. + + Args: + output: The TUI output string. + metadata: Optional metadata about the output. + + Returns: + An A2aEvent instance. + """ + event_data = { + "type": "tui_output", + "output": output, + "metadata": metadata or {}, + } + return self.integration.convert_to_a2a_event(event_data) + + def handle_tui_error( + self, + error: Exception, + context: str | None = None, + ) -> A2aEvent: + """Handle TUI errors and convert to A2A event. + + Args: + error: The exception that occurred. + context: Optional context information. + + Returns: + An A2aEvent instance. + """ + event_data = { + "type": "tui_error", + "error_type": type(error).__name__, + "error_message": str(error), + "context": context, + } + return self.integration.convert_to_a2a_event(event_data) + + def get_response( + self, + status: str = "success", + data: dict[str, Any] | None = None, + ) -> A2aResponse: + """Get an A2A response with TUI output. + + Args: + status: The response status. + data: Optional response data. + + Returns: + An A2aResponse instance. + """ + return self.integration.create_response(status, data) diff --git a/src/cleveragents/tui/materializer.py b/src/cleveragents/tui/materializer.py index 6a51c157a..dc2803290 100644 --- a/src/cleveragents/tui/materializer.py +++ b/src/cleveragents/tui/materializer.py @@ -32,9 +32,10 @@ compatibility. from __future__ import annotations +import json import threading from collections.abc import Callable -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any # Re-export event types and rendering helpers from companion modules # so consumers can still do ``from cleveragents.tui.materializer import ...`` @@ -118,6 +119,7 @@ class TuiMaterializer: self._on_event = on_event self._session: OutputSession | None = None self._events: list[TuiWidgetEvent] = [] + self._session_active = False self._index_map: dict[str, int] = {} self._rendered: dict[int, str] = {} self._lock: threading.Lock = threading.Lock() @@ -149,6 +151,17 @@ class TuiMaterializer: def on_session_begin(self, session: OutputSession) -> None: """Called when a new output session begins.""" self._session = session + self._session_active = True + tui_event = TuiWidgetEvent( + event_type=TuiWidgetEventType.SESSION_BEGIN, + handle_id=str(getattr(session, "id", "")), + element_kind="session", + extra={ + "session_id": getattr(session, "id", None), + "timestamp": getattr(session, "timestamp", None), + }, + ) + self._emit(tui_event) def on_element_created(self, event: ElementCreated) -> None: """Called when a new element handle is created. @@ -226,6 +239,7 @@ class TuiMaterializer: Emits a ``TuiWidgetEvent`` with ``event_type="session_end"``. """ + self._session_active = False tui_event = TuiWidgetEvent( event_type=TuiWidgetEventType.SESSION_END, handle_id=event.handle_id, @@ -290,10 +304,89 @@ class TuiMaterializer: self._emit(tui_event) return tui_event + # ------------------------------------------------------------------ + # A2A serialisation helpers + # ------------------------------------------------------------------ + + def get_output(self) -> str: + """Return accumulated TUI events as JSON for A2A transmission.""" + return json.dumps(self.get_events(), default=str) + + def get_error_output( + self, + error: Exception, + context: str | None = None, + ) -> str: + """Record and return an error event as JSON for A2A transmission.""" + error_data = { + "type": "error", + "error_type": type(error).__name__, + "error_message": str(error), + "context": context, + } + self._emit( + TuiWidgetEvent( + event_type="error", + handle_id="", + element_kind="error", + rendered_text=str(error), + extra=error_data, + ) + ) + return json.dumps(error_data, default=str) + + def clear_events(self) -> None: + """Clear accumulated event and rendered-output buffers.""" + with self._lock: + self._events.clear() + self._rendered.clear() + self._index_map.clear() + + def get_events(self) -> list[dict[str, Any]]: + """Return accumulated events as A2A-serialisable dictionaries.""" + with self._lock: + events = list(self._events) + return [self._event_to_dict(event) for event in events] + # ------------------------------------------------------------------ # Internal helpers # ------------------------------------------------------------------ + def _event_to_dict(self, event: TuiWidgetEvent | dict[str, Any]) -> dict[str, Any]: + """Convert a TUI widget event to a plain A2A-safe dictionary.""" + if isinstance(event, dict): + return dict(event) + + event_data: dict[str, Any] = { + "type": event.event_type, + "handle_id": event.handle_id, + "element_id": event.handle_id, + "element_type": event.element_kind, + "element_kind": event.element_kind, + "rendered_text": event.rendered_text, + } + if event.element_snapshot is not None: + event_data["content"] = self._serialize_element(event.element_snapshot) + if isinstance(event.extra, dict): + event_data.update(event.extra) + elif event.extra is not None: + event_data["extra"] = event.extra + return event_data + + def _serialize_element(self, element: Any) -> dict[str, Any]: + """Serialize an output element snapshot for A2A event payloads.""" + if hasattr(element, "model_dump"): + data = element.model_dump(mode="json") + data.setdefault("type", data.get("element_type")) + data.setdefault("id", getattr(element, "id", None)) + return data + return { + "id": getattr(element, "id", None), + "type": getattr(element, "type", None), + "content": getattr(element, "content", None), + "metadata": getattr(element, "metadata", {}), + } + def _emit(self, event: TuiWidgetEvent) -> None: """Emit a TuiWidgetEvent to the callback and accumulate it.""" with self._lock: