feat(tui): TuiMaterializer A2A integration layer #10793

Closed
HAL9000 wants to merge 8 commits from feature/m694-tui-materializer-a2a-integration-layer into master
6 changed files with 902 additions and 1 deletions
@@ -0,0 +1,474 @@
"""Step definitions for TUI Materializer A2A Integration tests."""
Outdated
Review

BLOCKING: File is 450 lines, approaching the 500-line hard limit. The project code style rules require files under 500 lines and SOLID principles (single responsibility). Please split into multiple step files — one per component (e.g., materializer_steps.py, a2a_integration_steps.py). Additionally, ensure each file stays well within limits (ideally ≤300 lines for maintainability).

BLOCKING: File is 450 lines, approaching the 500-line hard limit. The project code style rules require files under 500 lines and SOLID principles (single responsibility). Please split into multiple step files — one per component (e.g., materializer_steps.py, a2a_integration_steps.py). Additionally, ensure each file stays well within limits (ideally ≤300 lines for maintainability).
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()
Outdated
Review

BLOCKING: Duplicate step name — the string "the session should be marked as inactive" is defined twice:

  1. Line ~69: @then("the session should be marked as inactive") in step_verify_session_inactive
  2. Line ~143: @then("the session should be marked as inactive") in step_verify_session_marked_inactive

Behave will fail due to ambiguous step matching. Rename one of these to differentiate, e.g., "the TUI materializer session should be active" vs "the session should be marked as inactive after end".

BLOCKING: Duplicate step name — the string "the session should be marked as inactive" is defined twice: 1. Line ~69: `@then("the session should be marked as inactive")` in step_verify_session_inactive 2. Line ~143: `@then("the session should be marked as inactive")` in step_verify_session_marked_inactive Behave will fail due to ambiguous step matching. Rename one of these to differentiate, e.g., "the TUI materializer session should be active" vs "the session should be marked as inactive after end".
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()
Outdated
Review

SUGGESTION: No scenario covers TuiMaterializer._serialize_element() with edge cases (None content, deeply nested structure). Consider adding a feature step that passes an element with minimal or unusual attributes.

SUGGESTION: No scenario covers `TuiMaterializer._serialize_element()` with edge cases (None content, deeply nested structure). Consider adding a feature step that passes an element with minimal or unusual attributes.
@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"
@@ -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
Outdated
Review

BLOCKING: The "Get response from adapter" scenario asserts response status should be set correctly but the corresponding step only checks that result is not None. This does not actually verify the response data — it should assert on presence of key fields like tui_events and tui_output within result.

BLOCKING: The "Get response from adapter" scenario asserts `response status should be set correctly` but the corresponding step only checks that `result is not None`. This does not actually verify the response data — it should assert on presence of key fields like tui_events and tui_output within result.
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
+20
View File
@@ -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",
]
+1
View File
@@ -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"
+197
View File
@@ -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)
+94 -1
View File
@@ -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: