feat(tui): TuiMaterializer A2A integration layer #10793
@@ -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()
|
||||
|
HAL9001
commented
BLOCKING: Duplicate step name — the string "the session should be marked as inactive" is defined twice:
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()
|
||||
|
||||
|
||||
|
HAL9001
commented
SUGGESTION: No scenario covers 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
|
||||
|
||||
|
HAL9001
commented
BLOCKING: The "Get response from adapter" scenario asserts 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
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
@@ -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:
|
||||
|
||||
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).