[BREAKING] Python: Refactor workflow events to unified discriminated union pattern (#3690)

* Refactor events

* Merge main

* Fixes

* Cleanup

* Update samples and tests

* Remove unused imports

* PR feedback

* Merge main. Add properties for events to help typing

* Formatting

* Cleanup

* use builtins.type to avoid shadowing by WorkflowEvent.type attribute

* Final improvements
This commit is contained in:
Evan Mattson
2026-02-06 16:47:20 +09:00
committed by GitHub
Unverified
parent 09f59b21ad
commit 0f3f4dbcaf
127 changed files with 1646 additions and 1703 deletions
+10 -10
View File
@@ -249,9 +249,9 @@ Given that DevUI offers an OpenAI Responses API, it internally maps messages and
| `response.created` + `response.in_progress` | `AgentStartedEvent` | OpenAI |
| `response.completed` | `AgentCompletedEvent` | OpenAI |
| `response.failed` | `AgentFailedEvent` | OpenAI |
| `response.created` + `response.in_progress` | `WorkflowStartedEvent` | OpenAI |
| `response.completed` | `WorkflowCompletedEvent` | OpenAI |
| `response.failed` | `WorkflowFailedEvent` | OpenAI |
| `response.created` + `response.in_progress` | `WorkflowEvent (type='started')` | OpenAI |
| `response.completed` | `WorkflowEvent (type='status')` | OpenAI |
| `response.failed` | `WorkflowEvent (type='failed')` | OpenAI |
| | **Content Types** | |
| `response.content_part.added` + `response.output_text.delta` | `TextContent` | OpenAI |
| `response.reasoning_text.delta` | `TextReasoningContent` | OpenAI |
@@ -267,13 +267,13 @@ Given that DevUI offers an OpenAI Responses API, it internally maps messages and
| `error` | `ErrorContent` | OpenAI |
| Final `Response.usage` field (not streamed) | `UsageContent` | OpenAI |
| | **Workflow Events** | |
| `response.output_item.added` (ExecutorActionItem)* | `ExecutorInvokedEvent` | OpenAI |
| `response.output_item.done` (ExecutorActionItem)* | `ExecutorCompletedEvent` | OpenAI |
| `response.output_item.done` (ExecutorActionItem with error)* | `ExecutorFailedEvent` | OpenAI |
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowOutputEvent` | OpenAI |
| `response.workflow_event.complete` | `WorkflowEvent` (other) | DevUI |
| `response.trace.complete` | `WorkflowStatusEvent` | DevUI |
| `response.trace.complete` | `WorkflowWarningEvent` | DevUI |
| `response.output_item.added` (ExecutorActionItem)* | `WorkflowEvent (type='executor_invoked')` | OpenAI |
| `response.output_item.done` (ExecutorActionItem)* | `WorkflowEvent (type='executor_completed')` | OpenAI |
| `response.output_item.done` (ExecutorActionItem with error)* | `WorkflowEvent (type='executor_failed')` | OpenAI |
| `response.output_item.added` (ResponseOutputMessage) | `WorkflowEvent (type='output')` | OpenAI |
| `response.workflow_event.complete` | `WorkflowEvent` (other types) | DevUI |
| `response.trace.complete` | `WorkflowEvent (type='status')` | DevUI |
| `response.trace.complete` | `WorkflowEvent (type='warning')` | DevUI |
| | **Trace Content** | |
| `response.trace.complete` | `DataContent` (no data/errors) | DevUI |
| `response.trace.complete` | `UriContent` (unsupported MIME) | DevUI |
@@ -7,8 +7,7 @@ import logging
from collections.abc import AsyncGenerator
from typing import Any
from agent_framework import AgentProtocol, Content
from agent_framework._workflows._events import RequestInfoEvent
from agent_framework import AgentProtocol, Content, Workflow
from ._conversations import ConversationStore, InMemoryConversationStore
from ._discovery import EntityDiscovery
@@ -262,10 +261,11 @@ class AgentFrameworkExecutor:
yield event
elif entity_info.type == "workflow":
async for event in self._execute_workflow(entity_obj, request, trace_collector):
# Log RequestInfoEvent for debugging HIL flow
event_class = event.__class__.__name__ if hasattr(event, "__class__") else type(event).__name__
if event_class == "RequestInfoEvent":
logger.info("🔔 [EXECUTOR] RequestInfoEvent detected from workflow!")
# Log request_info event (type='request_info') for debugging HIL flow
if event.type == "request_info":
logger.info(
"🔔 [EXECUTOR] request_info event (type='request_info') detected from workflow!"
)
logger.info(f" request_id: {getattr(event, 'request_id', 'N/A')}")
logger.info(f" source_executor_id: {getattr(event, 'source_executor_id', 'N/A')}")
logger.info(f" request_type: {getattr(event, 'request_type', 'N/A')}")
@@ -360,7 +360,7 @@ class AgentFrameworkExecutor:
yield {"type": "error", "message": f"Agent execution error: {e!s}"}
async def _execute_workflow(
self, workflow: Any, request: AgentFrameworkRequest, trace_collector: Any
self, workflow: Workflow, request: AgentFrameworkRequest, trace_collector: Any
) -> AsyncGenerator[Any, None]:
"""Execute Agent Framework workflow with checkpoint support via conversation items.
@@ -515,8 +515,9 @@ class AgentFrameworkExecutor:
logger.warning(f"Could not convert HIL responses to proper types: {e}")
async for event in workflow.send_responses_streaming(hil_responses):
# Enrich new RequestInfoEvents that may come from subsequent HIL requests
if isinstance(event, RequestInfoEvent):
# Enrich new request_info events (type='request_info')
# that may come from subsequent HIL requests
if event.type == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -538,7 +539,7 @@ class AgentFrameworkExecutor:
checkpoint_id=checkpoint_id,
checkpoint_storage=checkpoint_storage,
):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -546,7 +547,7 @@ class AgentFrameworkExecutor:
yield event
# Note: Removed break on RequestInfoEvent - continue yielding all events
# Note: Removed break on request_info event (type='request_info') - continue yielding all events
# The workflow is already paused by ctx.request_info() in the framework
# DevUI should continue yielding events even during HIL pause
@@ -562,7 +563,7 @@ class AgentFrameworkExecutor:
parsed_input = await self._parse_workflow_input(workflow, request.input)
async for event in workflow.run(parsed_input, stream=True, checkpoint_storage=checkpoint_storage):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
self._enrich_request_info_event_with_response_schema(event, workflow)
for trace_event in trace_collector.get_pending_events():
@@ -570,7 +571,7 @@ class AgentFrameworkExecutor:
yield event
# Note: Removed break on RequestInfoEvent - continue yielding all events
# Note: Removed break on request_info event (type='request_info') - continue yielding all events
# The workflow is already paused by ctx.request_info() in the framework
# DevUI should continue yielding events even during HIL pause
@@ -1015,10 +1016,12 @@ class AgentFrameworkExecutor:
return raw_input
def _enrich_request_info_event_with_response_schema(self, event: Any, workflow: Any) -> None:
"""Extract response type from workflow executor and attach response schema to RequestInfoEvent.
"""Extract response type from workflow executor.
Attach response schema to request_info event (type='request_info').
Args:
event: RequestInfoEvent to enrich
event: request_info event (type='request_info') to enrich
workflow: Workflow object containing executors
"""
try:
@@ -1029,7 +1032,7 @@ class AgentFrameworkExecutor:
request_type = getattr(event, "request_type", None)
if not source_executor_id or not request_type:
logger.debug("RequestInfoEvent missing source_executor_id or request_type")
logger.debug("request_info event (type='request_info') missing source_executor_id or request_type")
return
# Find the source executor in the workflow
@@ -1062,4 +1065,4 @@ class AgentFrameworkExecutor:
event._response_schema = response_schema
except Exception as e:
logger.warning(f"Failed to enrich RequestInfoEvent with response schema: {e}")
logger.warning(f"Failed to enrich request_info event (type='request_info') with response schema: {e}")
@@ -12,7 +12,7 @@ from datetime import datetime
from typing import Any, Union
from uuid import uuid4
from agent_framework import ChatMessage, Content, WorkflowOutputEvent
from agent_framework import ChatMessage, Content
from openai.types.responses import (
Response,
ResponseContentPartAddedEvent,
@@ -180,16 +180,18 @@ class MessageMapper:
try:
from agent_framework import AgentResponse, AgentResponseUpdate, WorkflowEvent
# Handle AgentRunUpdateEvent - workflow event wrapping AgentResponseUpdate
# Handle WorkflowEvent with type='output' or 'data' wrapping AgentResponseUpdate
# This must be checked BEFORE generic WorkflowEvent check
if isinstance(raw_event, WorkflowOutputEvent):
# Extract the AgentResponseUpdate from the event's data attribute
if raw_event.data and isinstance(raw_event.data, AgentResponseUpdate):
# Preserve executor_id in context for proper output routing
context["current_executor_id"] = raw_event.executor_id
return await self._convert_agent_update(raw_event.data, context)
# If no data, treat as generic workflow event
return await self._convert_workflow_event(raw_event, context)
# Note: AgentExecutor uses type='output' for streaming updates
if (
isinstance(raw_event, WorkflowEvent)
and raw_event.type in ("output", "data")
and raw_event.data
and isinstance(raw_event.data, AgentResponseUpdate)
):
# Preserve executor_id in context for proper output routing
context["current_executor_id"] = raw_event.executor_id
return await self._convert_agent_update(raw_event.data, context)
# Handle complete agent response (AgentResponse) - for non-streaming agent execution
if isinstance(raw_event, AgentResponse):
@@ -824,10 +826,12 @@ class MessageMapper:
List of OpenAI response stream events
"""
try:
event_class = event.__class__.__name__
# Use event.type for discriminated union pattern (similar to Content class)
event_type = getattr(event, "type", None)
event_class = event.__class__.__name__ # Fallback for non-workflow events
# Response-level events - construct proper OpenAI objects
if event_class == "WorkflowStartedEvent":
if event_type == "started":
workflow_id = getattr(event, "workflow_id", str(uuid4()))
context["workflow_id"] = workflow_id
@@ -871,8 +875,8 @@ class MessageMapper:
return events
# Handle WorkflowOutputEvent separately to preserve output data
if event_class == "WorkflowOutputEvent":
# Handle output events separately to preserve output data
if event_type == "output":
output_data = getattr(event, "data", None)
executor_id = getattr(event, "executor_id", "unknown")
@@ -934,7 +938,7 @@ class MessageMapper:
# Emit output_item.added for each yield_output
logger.debug(
f"WorkflowOutputEvent converted to output_item.added "
f"output event (type='output') converted to output_item.added "
f"(executor: {executor_id}, length: {len(text)})"
)
return [
@@ -946,15 +950,15 @@ class MessageMapper:
)
]
# Handle WorkflowCompletedEvent - Don't emit response.completed here
# Handle completed event - Don't emit response.completed here
# The server will emit a proper one with usage data after aggregating all events
if event_class == "WorkflowCompletedEvent":
if event_type == "completed":
return []
if event_class == "WorkflowFailedEvent":
if event_type == "failed":
workflow_id = context.get("workflow_id", str(uuid4()))
# WorkflowFailedEvent uses 'details' field (WorkflowErrorDetails), not 'error'
# This matches ExecutorFailedEvent which also uses 'details'
# failed event (type='failed') uses 'details' field (WorkflowErrorDetails), not 'error'
# This matches executor_failed event which also uses 'details'
details = getattr(event, "details", None)
# Import Response and ResponseError types
@@ -1000,7 +1004,8 @@ class MessageMapper:
]
# Executor-level events (output items)
if event_class == "ExecutorInvokedEvent":
# Check for executor lifecycle events via event.type
if event_type == "executor_invoked":
executor_id = getattr(event, "executor_id", "unknown")
item_id = f"exec_{executor_id}_{uuid4().hex[:8]}"
context[f"exec_item_{executor_id}"] = item_id
@@ -1029,7 +1034,7 @@ class MessageMapper:
)
]
if event_class == "ExecutorCompletedEvent":
if event_type == "executor_completed":
executor_id = getattr(event, "executor_id", "unknown")
item_id = context.get(f"exec_item_{executor_id}", f"exec_{executor_id}_unknown")
@@ -1038,7 +1043,7 @@ class MessageMapper:
context.pop("current_executor_id", None)
# Create ExecutorActionItem with completed status
# ExecutorCompletedEvent uses 'data' field, not 'result'
# executor_completed event (type='executor_completed') uses 'data' field, not 'result'
# Serialize the result data to ensure it's JSON-serializable
# (AgentExecutorResponse contains AgentResponse/ChatMessage which are SerializationMixin)
raw_result = getattr(event, "data", None)
@@ -1061,10 +1066,11 @@ class MessageMapper:
)
]
if event_class == "ExecutorFailedEvent":
if event_type == "executor_failed":
executor_id = getattr(event, "executor_id", "unknown")
item_id = context.get(f"exec_item_{executor_id}", f"exec_{executor_id}_unknown")
# ExecutorFailedEvent uses 'details' field (WorkflowErrorDetails), not 'error'
# executor_failed event (type='executor_failed') uses 'details' property (WorkflowErrorDetails)
# not 'error'. This matches WorkflowEvent.details which returns self.data for executor_failed type
details = getattr(event, "details", None)
if details:
err_msg = getattr(details, "message", None) or str(details)
@@ -1093,8 +1099,8 @@ class MessageMapper:
)
]
# Handle RequestInfoEvent specially - emit as HIL event with schema
if event_class == "RequestInfoEvent":
# Handle request_info events specially - emit as HIL event with schema
if event_type == "request_info":
from .models._openai_custom import ResponseRequestInfoEvent
request_id = getattr(event, "request_id", "")
@@ -1102,7 +1108,7 @@ class MessageMapper:
request_type_class = getattr(event, "request_type", None)
request_data = getattr(event, "data", None)
logger.info("📨 [MAPPER] Processing RequestInfoEvent")
logger.info("📨 [MAPPER] Processing request_info event (type='request_info')")
logger.info(f" request_id: {request_id}")
logger.info(f" source_executor_id: {source_executor_id}")
logger.info(f" request_type_class: {request_type_class}")
@@ -1163,26 +1169,23 @@ class MessageMapper:
return [hil_event]
# Handle other informational workflow events (status, warnings, errors)
if event_class in ["WorkflowStatusEvent", "WorkflowWarningEvent", "WorkflowErrorEvent"]:
if event_type in ["status", "warning", "error"]:
# These are informational events that don't map to OpenAI lifecycle events
# Convert them to trace events for debugging visibility
event_data: dict[str, Any] = {}
# Extract relevant data based on event type
if event_class == "WorkflowStatusEvent":
if event_type == "status":
event_data["state"] = str(getattr(event, "state", "unknown"))
elif event_class == "WorkflowWarningEvent":
event_data["message"] = str(getattr(event, "message", ""))
elif event_class == "WorkflowErrorEvent":
event_data["message"] = str(getattr(event, "message", ""))
event_data["error"] = str(getattr(event, "error", ""))
elif event_type == "warning" or event_type == "error":
event_data["message"] = str(getattr(event, "data", ""))
# Create a trace event for debugging
trace_event = ResponseTraceEventComplete(
type="response.trace.completed",
data={
"trace_type": "workflow_info",
"event_type": event_class,
"event_type": event_type,
"data": event_data,
"timestamp": datetime.now().isoformat(),
},
+23 -24
View File
@@ -32,10 +32,8 @@ from agent_framework import (
from agent_framework._clients import TOptions_co
from agent_framework._workflows._agent_executor import AgentExecutorResponse
from agent_framework._workflows._events import (
ExecutorCompletedEvent,
ExecutorFailedEvent,
ExecutorInvokedEvent,
WorkflowErrorDetails,
WorkflowEvent,
)
from agent_framework.orchestrations import ConcurrentBuilder, SequentialBuilder
@@ -284,7 +282,8 @@ def _create_agent_executor_response(
executor_id: str = "test_executor",
response_text: str = "Executor response",
) -> AgentExecutorResponse:
"""Create an AgentExecutorResponse - the type that's nested in ExecutorCompletedEvent.data."""
"""Create an AgentExecutorResponse - the type that's nested in
executor_completed event (type='executor_completed').data."""
agent_response = _create_agent_run_response(response_text)
return AgentExecutorResponse(
executor_id=executor_id,
@@ -306,32 +305,32 @@ def create_agent_run_response(text: str = "Test response") -> AgentResponse:
return _create_agent_run_response(text)
def create_executor_invoked_event(executor_id: str = "test_executor") -> ExecutorInvokedEvent:
"""Create an ExecutorInvokedEvent."""
return ExecutorInvokedEvent(executor_id=executor_id)
def create_executor_invoked_event(executor_id: str = "test_executor") -> WorkflowEvent[Any]:
"""Create a WorkflowEvent(type='executor_invoked')."""
return WorkflowEvent.executor_invoked(executor_id=executor_id)
def create_executor_completed_event(
executor_id: str = "test_executor",
with_agent_response: bool = True,
) -> ExecutorCompletedEvent:
"""Create an ExecutorCompletedEvent with realistic nested data.
) -> WorkflowEvent[Any]:
"""Create a WorkflowEvent(type='executor_completed') with realistic nested data.
This creates the exact data structure that caused the serialization bug:
ExecutorCompletedEvent.data contains AgentExecutorResponse which contains
WorkflowEvent.data contains AgentExecutorResponse which contains
AgentResponse and ChatMessage objects (SerializationMixin, not Pydantic).
"""
data = _create_agent_executor_response(executor_id) if with_agent_response else {"simple": "dict"}
return ExecutorCompletedEvent(executor_id=executor_id, data=data)
return WorkflowEvent.executor_completed(executor_id=executor_id, data=data)
def create_executor_failed_event(
executor_id: str = "test_executor",
error_message: str = "Test error",
) -> ExecutorFailedEvent:
"""Create an ExecutorFailedEvent."""
) -> WorkflowEvent[WorkflowErrorDetails]:
"""Create a WorkflowEvent(type='executor_failed')."""
details = WorkflowErrorDetails(error_type="TestError", message=error_message)
return ExecutorFailedEvent(executor_id=executor_id, details=details)
return WorkflowEvent.executor_failed(executor_id=executor_id, details=details)
# =============================================================================
@@ -386,28 +385,28 @@ def agent_run_response() -> AgentResponse:
@pytest.fixture
def executor_completed_event() -> ExecutorCompletedEvent:
"""Create an ExecutorCompletedEvent with realistic nested data.
def executor_completed_event() -> WorkflowEvent[Any]:
"""Create a WorkflowEvent(type='executor_completed') with realistic nested data.
This creates the exact data structure that caused the serialization bug:
ExecutorCompletedEvent.data contains AgentExecutorResponse which contains
executor_completed event (type='executor_completed').data contains AgentExecutorResponse which contains
AgentResponse and ChatMessage objects (SerializationMixin, not Pydantic).
"""
data = _create_agent_executor_response("test_executor")
return ExecutorCompletedEvent(executor_id="test_executor", data=data)
return WorkflowEvent.executor_completed(executor_id="test_executor", data=data)
@pytest.fixture
def executor_invoked_event() -> ExecutorInvokedEvent:
"""Create an ExecutorInvokedEvent."""
return ExecutorInvokedEvent(executor_id="test_executor")
def executor_invoked_event() -> WorkflowEvent[Any]:
"""Create a WorkflowEvent(type='executor_invoked')."""
return WorkflowEvent.executor_invoked(executor_id="test_executor")
@pytest.fixture
def executor_failed_event() -> ExecutorFailedEvent:
"""Create an ExecutorFailedEvent."""
def executor_failed_event() -> WorkflowEvent[WorkflowErrorDetails]:
"""Create a WorkflowEvent(type='executor_failed')."""
details = WorkflowErrorDetails(error_type="TestError", message="Test error")
return ExecutorFailedEvent(executor_id="test_executor", details=details)
return WorkflowEvent.executor_failed(executor_id="test_executor", details=details)
@pytest.fixture
@@ -8,10 +8,8 @@ import pytest
from agent_framework import (
Executor,
InMemoryCheckpointStorage,
RequestInfoEvent,
WorkflowBuilder,
WorkflowContext,
WorkflowStatusEvent,
handler,
response_handler,
)
@@ -428,13 +426,13 @@ class TestIntegration:
# Run workflow until it reaches IDLE_WITH_PENDING_REQUESTS (after checkpoint is created)
saw_request_event = False
async for event in test_workflow.run(WorkflowTestData(value="test"), stream=True):
if isinstance(event, RequestInfoEvent):
if event.type == "request_info":
saw_request_event = True
# Wait for IDLE_WITH_PENDING_REQUESTS status (comes after checkpoint creation)
if isinstance(event, WorkflowStatusEvent) and "IDLE_WITH_PENDING_REQUESTS" in str(event.state):
if event.type == "status" and "IDLE_WITH_PENDING_REQUESTS" in str(event.state):
break
assert saw_request_event, "Test workflow should have emitted RequestInfoEvent"
assert saw_request_event, "Test workflow should have emitted request_info event (type='request_info')"
# Verify checkpoint was AUTOMATICALLY saved to our storage by the framework
checkpoints_after = await checkpoint_storage.list_checkpoints()
@@ -292,7 +292,7 @@ async def test_full_pipeline_workflow_events_are_json_serializable():
"""CRITICAL TEST: Verify ALL events from workflow execution can be JSON serialized.
This is particularly important for workflows with AgentExecutor because:
- AgentExecutor produces ExecutorCompletedEvent with AgentExecutorResponse
- AgentExecutor produces executor_completed event (type='executor_completed') with AgentExecutorResponse
- AgentExecutorResponse contains AgentResponse and ChatMessage objects
- These are SerializationMixin objects, not Pydantic, which caused the original bug
@@ -672,10 +672,10 @@ async def test_full_pipeline_concurrent_workflow(concurrent_workflow):
@pytest.mark.asyncio
async def test_full_pipeline_workflow_output_event_serialization():
"""Test that WorkflowOutputEvent from ctx.yield_output() serializes correctly.
"""Test that output event (type='output') from ctx.yield_output() serializes correctly.
This tests the pattern where executors yield output via ctx.yield_output(),
which emits WorkflowOutputEvent that DevUI must serialize for SSE.
which emits output event (type='output') that DevUI must serialize for SSE.
"""
from agent_framework import Executor, WorkflowBuilder, WorkflowContext, handler
@@ -19,9 +19,8 @@ from agent_framework._types import (
# Import real workflow event classes - NOT mocks!
from agent_framework._workflows._events import (
ExecutorCompletedEvent,
WorkflowStartedEvent,
WorkflowStatusEvent,
WorkflowEvent,
WorkflowRunState,
)
# Import factory functions from conftest for parameterized test data creation
@@ -261,7 +260,7 @@ async def test_agent_run_response_mapping(mapper: MessageMapper, test_request: A
async def test_executor_invoked_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test ExecutorInvokedEvent using the REAL class from agent_framework."""
"""Test WorkflowEvent(type='executor_invoked') using the REAL class from agent_framework."""
# Use real class, not mock!
event = create_executor_invoked_event(executor_id="exec_123")
@@ -277,9 +276,9 @@ async def test_executor_invoked_event(mapper: MessageMapper, test_request: Agent
async def test_executor_completed_event_simple_data(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test ExecutorCompletedEvent with simple dict data."""
"""Test WorkflowEvent(type='executor_completed') with simple dict data."""
# Create event with simple data
event = ExecutorCompletedEvent(executor_id="exec_123", data={"simple": "result"})
event = WorkflowEvent.executor_completed(executor_id="exec_123", data={"simple": "result"})
# First need to invoke the executor to set up context
invoke_event = create_executor_invoked_event(executor_id="exec_123")
@@ -301,10 +300,10 @@ async def test_executor_completed_event_simple_data(mapper: MessageMapper, test_
async def test_executor_completed_event_with_agent_response(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Test ExecutorCompletedEvent with nested AgentExecutorResponse.
"""Test WorkflowEvent(type='executor_completed') with nested AgentExecutorResponse.
This is a REGRESSION TEST for the serialization bug where
ExecutorCompletedEvent.data contained AgentExecutorResponse with nested
WorkflowEvent.data contained AgentExecutorResponse with nested
AgentResponse and ChatMessage objects (SerializationMixin) that
Pydantic couldn't serialize.
"""
@@ -374,7 +373,7 @@ async def test_executor_completed_event_serialization_to_json(
async def test_executor_failed_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test ExecutorFailedEvent using the REAL class."""
"""Test WorkflowEvent(type='executor_failed') using the REAL class."""
# First invoke the executor
invoke_event = create_executor_invoked_event(executor_id="exec_fail")
await mapper.convert_event(invoke_event, test_request)
@@ -398,22 +397,21 @@ async def test_executor_failed_event(mapper: MessageMapper, test_request: AgentF
async def test_workflow_started_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowStartedEvent using the REAL class."""
"""Test WorkflowEvent(type='started') using the REAL class."""
event = WorkflowStartedEvent(data=None)
event = WorkflowEvent.started()
events = await mapper.convert_event(event, test_request)
# WorkflowStartedEvent should emit response.created and response.in_progress
# WorkflowEvent(type='started') should emit response.created and response.in_progress
assert len(events) == 2
assert events[0].type == "response.created"
assert events[1].type == "response.in_progress"
async def test_workflow_status_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowStatusEvent using the REAL class."""
from agent_framework._workflows._events import WorkflowRunState
"""Test WorkflowEvent(type='status') using the REAL class."""
event = WorkflowStatusEvent(state=WorkflowRunState.IN_PROGRESS)
event = WorkflowEvent.status(state=WorkflowRunState.IN_PROGRESS)
events = await mapper.convert_event(event, test_request)
# Should emit some status-related event
@@ -421,20 +419,20 @@ async def test_workflow_status_event(mapper: MessageMapper, test_request: AgentF
# =============================================================================
# Magentic Event Tests - Testing WorkflowOutputEvent with additional_properties
# Magentic Event Tests - Testing WorkflowEvent[AgentResponseUpdate] with additional_properties
# =============================================================================
async def test_magentic_agent_run_update_event_with_agent_delta_metadata(
async def test_magentic_executor_event_with_agent_delta_metadata(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Test that WorkflowOutputEvent with magentic_event_type='agent_delta' is handled correctly.
"""Test that WorkflowEvent[AgentResponseUpdate] with magentic_event_type='agent_delta' is handled correctly.
This tests the ACTUAL event format Magentic emits - not a fake MagenticAgentDeltaEvent class.
Magentic uses WorkflowOutputEvent wrapping AgentResponseUpdate with additional_properties.
Magentic uses WorkflowEvent.emit() with additional_properties containing magentic_event_type.
"""
from agent_framework._types import AgentResponseUpdate
from agent_framework._workflows._events import WorkflowOutputEvent
from agent_framework._workflows._events import WorkflowEvent
# Create the REAL event format that Magentic emits
update = AgentResponseUpdate(
@@ -446,11 +444,11 @@ async def test_magentic_agent_run_update_event_with_agent_delta_metadata(
"agent_id": "writer_agent",
},
)
event = WorkflowOutputEvent(executor_id="magentic_executor", data=update)
event = WorkflowEvent.emit(executor_id="magentic_executor", data=update)
events = await mapper.convert_event(event, test_request)
# Should be treated as a regular WorkflowOutputEvent with text content
# Should be treated as a regular WorkflowEvent[AgentResponseUpdate] with text content
# The mapper should emit text delta events
assert len(events) >= 1
text_events = [e for e in events if getattr(e, "type", "") == "response.output_text.delta"]
@@ -459,13 +457,13 @@ async def test_magentic_agent_run_update_event_with_agent_delta_metadata(
async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test that WorkflowOutputEvent with magentic_event_type='orchestrator_message' is handled.
"""Test that WorkflowEvent[AgentResponseUpdate] with magentic_event_type='orchestrator_message' is handled.
Magentic emits orchestrator planning/instruction messages using WorkflowOutputEvent
wrapping AgentResponseUpdate with additional_properties.
Magentic emits orchestrator planning/instruction messages using WorkflowEvent.emit()
with additional_properties containing magentic_event_type='orchestrator_message'.
"""
from agent_framework._types import AgentResponseUpdate
from agent_framework._workflows._events import WorkflowOutputEvent
from agent_framework._workflows._events import WorkflowEvent
# Create orchestrator message event (REAL format from Magentic)
update = AgentResponseUpdate(
@@ -478,11 +476,11 @@ async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_r
"orchestrator_id": "magentic_orchestrator",
},
)
event = WorkflowOutputEvent(executor_id="magentic_orchestrator", data=update)
event = WorkflowEvent.emit(executor_id="magentic_orchestrator", data=update)
events = await mapper.convert_event(event, test_request)
# Currently, mapper treats this as regular WorkflowOutputEvent (no special handling)
# Currently, mapper treats this as regular WorkflowEvent[AgentResponseUpdate] (no special handling)
# This test documents the current behavior
assert len(events) >= 1
text_events = [e for e in events if getattr(e, "type", "") == "response.output_text.delta"]
@@ -493,15 +491,15 @@ async def test_magentic_orchestrator_message_event(mapper: MessageMapper, test_r
async def test_magentic_events_use_same_event_class_as_other_workflows(
mapper: MessageMapper, test_request: AgentFrameworkRequest
) -> None:
"""Verify Magentic uses the same WorkflowOutputEvent class as other workflows.
"""Verify Magentic uses the same WorkflowEvent class as other workflows.
This test documents that Magentic does NOT define separate event classes like
MagenticAgentDeltaEvent - it reuses WorkflowOutputEvent with metadata in
MagenticAgentDeltaEvent - it reuses WorkflowEvent with metadata in
additional_properties. Any mapper code checking for 'MagenticAgentDeltaEvent'
class names is dead code.
"""
from agent_framework._types import AgentResponseUpdate
from agent_framework._workflows._events import WorkflowOutputEvent
from agent_framework._workflows._events import WorkflowEvent
# Create events the way different workflows do it
# 1. Regular workflow (no additional_properties)
@@ -509,7 +507,7 @@ async def test_magentic_events_use_same_event_class_as_other_workflows(
contents=[Content.from_text(text="Regular workflow response")],
role="assistant",
)
regular_event = WorkflowOutputEvent(executor_id="regular_executor", data=regular_update)
regular_event = WorkflowEvent.emit(executor_id="regular_executor", data=regular_update)
# 2. Magentic workflow (with additional_properties)
magentic_update = AgentResponseUpdate(
@@ -517,12 +515,12 @@ async def test_magentic_events_use_same_event_class_as_other_workflows(
role="assistant",
additional_properties={"magentic_event_type": "agent_delta"},
)
magentic_event = WorkflowOutputEvent(executor_id="magentic_executor", data=magentic_update)
magentic_event = WorkflowEvent.emit(executor_id="magentic_executor", data=magentic_update)
# Both should be the SAME class
assert type(regular_event) is type(magentic_event)
assert isinstance(regular_event, WorkflowOutputEvent)
assert isinstance(magentic_event, WorkflowOutputEvent)
assert isinstance(regular_event, WorkflowEvent)
assert isinstance(magentic_event, WorkflowEvent)
# Both should be handled by the same isinstance check in mapper
regular_events = await mapper.convert_event(regular_event, test_request)
@@ -559,18 +557,18 @@ async def test_unknown_content_fallback(mapper: MessageMapper, test_request: Age
# =============================================================================
# WorkflowOutputEvent Tests
# output event (type='output') Tests
# =============================================================================
async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowOutputEvent is converted to output_item.added."""
from agent_framework._workflows._events import WorkflowOutputEvent
"""Test output event (type='output') is converted to output_item.added."""
from agent_framework._workflows._events import WorkflowEvent
event = WorkflowOutputEvent(data="Final workflow output", executor_id="final_executor")
event = WorkflowEvent.output(executor_id="final_executor", data="Final workflow output")
events = await mapper.convert_event(event, test_request)
# WorkflowOutputEvent should emit output_item.added
# output event (type='output') should emit output_item.added
assert len(events) == 1
assert events[0].type == "response.output_item.added"
# Check item contains the output text
@@ -580,16 +578,16 @@ async def test_workflow_output_event(mapper: MessageMapper, test_request: AgentF
async def test_workflow_output_event_with_list_data(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowOutputEvent with list data (common for sequential/concurrent workflows)."""
"""Test output event (type='output') with list data (common for sequential/concurrent workflows)."""
from agent_framework import ChatMessage
from agent_framework._workflows._events import WorkflowOutputEvent
from agent_framework._workflows._events import WorkflowEvent
# Sequential/Concurrent workflows often output list[ChatMessage]
messages = [
ChatMessage(role="user", contents=[Content.from_text(text="Hello")]),
ChatMessage(role="assistant", contents=[Content.from_text(text="World")]),
]
event = WorkflowOutputEvent(data=messages, executor_id="complete")
event = WorkflowEvent.output(executor_id="complete", data=messages)
events = await mapper.convert_event(event, test_request)
assert len(events) == 1
@@ -597,23 +595,23 @@ async def test_workflow_output_event_with_list_data(mapper: MessageMapper, test_
# =============================================================================
# WorkflowFailedEvent Tests
# failed event (type='failed') Tests
# =============================================================================
async def test_workflow_failed_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowFailedEvent is converted to response.failed."""
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowFailedEvent
"""Test failed event (type='failed') is converted to response.failed."""
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowEvent
details = WorkflowErrorDetails(
error_type="TestError",
message="Workflow failed due to test error",
executor_id="failing_executor",
)
event = WorkflowFailedEvent(details=details)
event = WorkflowEvent.failed(details=details)
events = await mapper.convert_event(event, test_request)
# WorkflowFailedEvent should emit response.failed
# failed event (type='failed') should emit response.failed
assert len(events) >= 1
# Find the failed event
failed_events = [e for e in events if getattr(e, "type", "") == "response.failed"]
@@ -628,8 +626,8 @@ async def test_workflow_failed_event(mapper: MessageMapper, test_request: AgentF
async def test_workflow_failed_event_with_extra(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowFailedEvent includes extra context when available."""
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowFailedEvent
"""Test failed event (type='failed') includes extra context when available."""
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowEvent
details = WorkflowErrorDetails(
error_type="ValidationError",
@@ -637,7 +635,7 @@ async def test_workflow_failed_event_with_extra(mapper: MessageMapper, test_requ
executor_id="validation_executor",
extra={"field": "email", "reason": "invalid format"},
)
event = WorkflowFailedEvent(details=details)
event = WorkflowEvent.failed(details=details)
events = await mapper.convert_event(event, test_request)
assert len(events) == 1
@@ -650,8 +648,8 @@ async def test_workflow_failed_event_with_extra(mapper: MessageMapper, test_requ
async def test_workflow_failed_event_with_traceback(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowFailedEvent includes traceback when available."""
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowFailedEvent
"""Test failed event (type='failed') includes traceback when available."""
from agent_framework._workflows._events import WorkflowErrorDetails, WorkflowEvent
details = WorkflowErrorDetails(
error_type="ValueError",
@@ -659,7 +657,7 @@ async def test_workflow_failed_event_with_traceback(mapper: MessageMapper, test_
traceback="Traceback (most recent call last):\n File ...\nValueError: Invalid input",
executor_id="validation_executor",
)
event = WorkflowFailedEvent(details=details)
event = WorkflowEvent.failed(details=details)
events = await mapper.convert_event(event, test_request)
assert len(events) == 1
@@ -672,41 +670,41 @@ async def test_workflow_failed_event_with_traceback(mapper: MessageMapper, test_
async def test_workflow_warning_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowWarningEvent is converted to trace event."""
from agent_framework._workflows._events import WorkflowWarningEvent
"""Test WorkflowEvent(type='warning') is converted to trace event."""
from agent_framework._workflows._events import WorkflowEvent
event = WorkflowWarningEvent(data="This is a warning message")
event = WorkflowEvent.warning("This is a warning message")
events = await mapper.convert_event(event, test_request)
# WorkflowWarningEvent should emit a trace event
# WorkflowEvent(type='warning') should emit a trace event
assert len(events) == 1
assert events[0].type == "response.trace.completed"
assert events[0].data["event_type"] == "WorkflowWarningEvent"
assert events[0].data["event_type"] == "warning"
async def test_workflow_error_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test WorkflowErrorEvent is converted to trace event."""
from agent_framework._workflows._events import WorkflowErrorEvent
"""Test WorkflowEvent(type='error') is converted to trace event."""
from agent_framework._workflows._events import WorkflowEvent
event = WorkflowErrorEvent(data=ValueError("Something went wrong"))
event = WorkflowEvent.error(ValueError("Something went wrong"))
events = await mapper.convert_event(event, test_request)
# WorkflowErrorEvent should emit a trace event
# WorkflowEvent(type='error') should emit a trace event
assert len(events) == 1
assert events[0].type == "response.trace.completed"
assert events[0].data["event_type"] == "WorkflowErrorEvent"
assert events[0].data["event_type"] == "error"
# =============================================================================
# RequestInfoEvent Tests (Human-in-the-Loop)
# request_info event (type='request_info') Tests (Human-in-the-Loop)
# =============================================================================
async def test_request_info_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test RequestInfoEvent is converted to HIL request event."""
from agent_framework._workflows._events import RequestInfoEvent
"""Test request_info event (type='request_info') is converted to HIL request event."""
from agent_framework._workflows._events import WorkflowEvent
event = RequestInfoEvent(
event = WorkflowEvent.request_info(
request_id="req_123",
source_executor_id="approval_executor",
request_data={"action": "approve", "details": "Please approve this action"},
@@ -714,7 +712,7 @@ async def test_request_info_event(mapper: MessageMapper, test_request: AgentFram
)
events = await mapper.convert_event(event, test_request)
# RequestInfoEvent should emit response.request_info.requested
# request_info event (type='request_info') should emit response.request_info.requested
assert len(events) >= 1
# Check that request info is captured
has_hil_event = any(getattr(e, "type", "") == "response.request_info.requested" for e in events)
@@ -732,24 +730,24 @@ async def test_request_info_event(mapper: MessageMapper, test_request: AgentFram
async def test_superstep_started_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test SuperStepStartedEvent is handled gracefully."""
from agent_framework._workflows._events import SuperStepStartedEvent
"""Test superstep_started event (type='superstep_started') is handled gracefully."""
from agent_framework._workflows._events import WorkflowEvent
event = SuperStepStartedEvent(iteration=1)
event = WorkflowEvent.superstep_started(iteration=1)
events = await mapper.convert_event(event, test_request)
# SuperStepStartedEvent may not emit events (internal workflow signal)
# superstep_started event (type='superstep_started') may not emit events (internal workflow signal)
# Just ensure it doesn't crash
assert isinstance(events, list)
async def test_superstep_completed_event(mapper: MessageMapper, test_request: AgentFrameworkRequest) -> None:
"""Test SuperStepCompletedEvent is handled gracefully."""
from agent_framework._workflows._events import SuperStepCompletedEvent
"""Test superstep_completed event (type='superstep_completed') is handled gracefully."""
from agent_framework._workflows._events import WorkflowEvent
event = SuperStepCompletedEvent(iteration=1)
event = WorkflowEvent.superstep_completed(iteration=1)
events = await mapper.convert_event(event, test_request)
# SuperStepCompletedEvent may not emit events (internal workflow signal)
# superstep_completed event (type='superstep_completed') may not emit events (internal workflow signal)
# Just ensure it doesn't crash
assert isinstance(events, list)