Python: Add explicit input, output, and workflow_output parameters to @handler, @executor and request_info (#3472)

* Support specifying types via handler and executor decorators

* Add handling for string types

* Fix typing

* Address PR feedback

* All or nothing for handler typing approach

* Fix mypy issues

* type support for request info

* Fix naming issue

* Fix mypy
This commit is contained in:
Evan Mattson
2026-02-04 07:47:40 +09:00
committed by GitHub
Unverified
parent 8d939f8ffa
commit f56218fa1e
17 changed files with 1718 additions and 170 deletions
@@ -1,5 +1,7 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
import pytest
from typing_extensions import Never
@@ -17,6 +19,27 @@ from agent_framework import (
)
# Module-level types for string forward reference tests
@dataclass
class ForwardRefMessage:
content: str
@dataclass
class ForwardRefTypeA:
value: str
@dataclass
class ForwardRefTypeB:
value: int
@dataclass
class ForwardRefResponse:
result: str
def test_executor_without_id():
"""Test that an executor without an ID raises an error when trying to run."""
@@ -537,3 +560,362 @@ async def test_executor_invoked_event_data_not_mutated_by_handler():
f"{[m.text for m in mutator_invoked.data]}"
)
assert mutator_invoked.data[0].text == "hello"
# region: Tests for @handler decorator with explicit input_type and output_type
class TestHandlerExplicitTypes:
"""Test suite for @handler decorator with explicit input_type and output_type parameters."""
def test_handler_with_explicit_input_type(self):
"""Test that explicit input_type takes precedence over introspection."""
from typing import Any
class ExplicitInputExecutor(Executor):
@handler(input=str)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
pass
exec_instance = ExplicitInputExecutor(id="explicit_input")
# Handler should be registered for str (explicit), not Any (introspected)
assert str in exec_instance._handlers
assert len(exec_instance._handlers) == 1
# Can handle str messages
assert exec_instance.can_handle(Message(data="hello", source_id="mock"))
# Cannot handle int messages (since explicit type is str)
assert not exec_instance.can_handle(Message(data=42, source_id="mock"))
def test_handler_with_explicit_output_type(self):
"""Test that explicit output works when input is also specified."""
class ExplicitOutputExecutor(Executor):
@handler(input=str, output=int)
async def handle(self, message: str, ctx: WorkflowContext[str]) -> None:
pass
exec_instance = ExplicitOutputExecutor(id="explicit_output")
# Handler spec should have int as output type (explicit)
handler_func = exec_instance._handlers[str]
assert handler_func._handler_spec["output_types"] == [int]
# Executor output_types property should reflect explicit type
assert int in exec_instance.output_types
assert str not in exec_instance.output_types
def test_handler_with_explicit_input_and_output_types(self):
"""Test that both explicit input_type and output_type work together."""
from typing import Any
class ExplicitBothExecutor(Executor):
@handler(input=dict, output=list)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
pass
exec_instance = ExplicitBothExecutor(id="explicit_both")
# Handler should be registered for dict (explicit input type)
assert dict in exec_instance._handlers
assert len(exec_instance._handlers) == 1
# Output type should be list (explicit)
handler_func = exec_instance._handlers[dict]
assert handler_func._handler_spec["output_types"] == [list]
# Verify can_handle
assert exec_instance.can_handle(Message(data={"key": "value"}, source_id="mock"))
assert not exec_instance.can_handle(Message(data="string", source_id="mock"))
def test_handler_with_explicit_union_input_type(self):
"""Test that explicit union input_type is handled correctly."""
from typing import Any
class UnionInputExecutor(Executor):
@handler(input=str | int)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
pass
exec_instance = UnionInputExecutor(id="union_input")
# Handler should be registered for the union type
# The union type itself is stored as the key
assert len(exec_instance._handlers) == 1
# Can handle both str and int messages
assert exec_instance.can_handle(Message(data="hello", source_id="mock"))
assert exec_instance.can_handle(Message(data=42, source_id="mock"))
# Cannot handle float
assert not exec_instance.can_handle(Message(data=3.14, source_id="mock"))
def test_handler_with_explicit_union_output_type(self):
"""Test that explicit union output is normalized to a list."""
from typing import Any
class UnionOutputExecutor(Executor):
@handler(input=bytes, output=str | int | bool)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
pass
exec_instance = UnionOutputExecutor(id="union_output")
# Output types should be a list with all union members
assert set(exec_instance.output_types) == {str, int, bool}
def test_handler_explicit_types_precedence_over_introspection(self):
"""Test that explicit types always take precedence over introspected types."""
class PrecedenceExecutor(Executor):
# Introspection would give: input=str, output=[int]
# Explicit gives: input=bytes, output=[float]
@handler(input=bytes, output=float)
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
pass
exec_instance = PrecedenceExecutor(id="precedence")
# Should use explicit input type (bytes), not introspected (str)
assert bytes in exec_instance._handlers
assert str not in exec_instance._handlers
# Should use explicit output type (float), not introspected (int)
assert float in exec_instance.output_types
assert int not in exec_instance.output_types
def test_handler_fallback_to_introspection_when_no_explicit_types(self):
"""Test that introspection is used when no explicit types are provided."""
class IntrospectedExecutor(Executor):
@handler
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
pass
exec_instance = IntrospectedExecutor(id="introspected")
# Should use introspected types
assert str in exec_instance._handlers
assert int in exec_instance.output_types
def test_handler_explicit_mode_requires_input(self):
"""Test that using any explicit type param requires input to be specified."""
# Only explicit input - output defaults to empty (no introspection)
class OnlyInputExecutor(Executor):
@handler(input=bytes)
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
pass
exec_input = OnlyInputExecutor(id="only_input")
assert bytes in exec_input._handlers # Explicit
assert exec_input.output_types == [] # No output types (not introspected)
# Only explicit output without input should raise error
with pytest.raises(ValueError, match="must specify 'input' type"):
class OnlyOutputExecutor(Executor):
@handler(output=float)
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
pass
# Only explicit workflow_output without input should raise error
with pytest.raises(ValueError, match="must specify 'input' type"):
class OnlyWorkflowOutputExecutor(Executor):
@handler(workflow_output=bool)
async def handle(self, message: str, ctx: WorkflowContext[int, str]) -> None:
pass
def test_handler_explicit_input_type_allows_no_message_annotation(self):
"""Test that explicit input_type allows handler without message type annotation."""
class NoAnnotationExecutor(Executor):
@handler(input=str)
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
exec_instance = NoAnnotationExecutor(id="no_annotation")
# Should work with explicit input_type
assert str in exec_instance._handlers
assert exec_instance.can_handle(Message(data="hello", source_id="mock"))
def test_handler_multiple_handlers_mixed_explicit_and_introspected(self):
"""Test executor with multiple handlers, some with explicit types and some introspected."""
class MixedExecutor(Executor):
@handler(input=str, output=int)
async def handle_explicit(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
@handler
async def handle_introspected(self, message: float, ctx: WorkflowContext[bool]) -> None:
pass
exec_instance = MixedExecutor(id="mixed")
# Should have both handlers
assert len(exec_instance._handlers) == 2
assert str in exec_instance._handlers # Explicit
assert float in exec_instance._handlers # Introspected
# Should have both output types
assert int in exec_instance.output_types # Explicit
assert bool in exec_instance.output_types # Introspected
def test_handler_with_string_forward_reference_input_type(self):
"""Test that string forward references work for input_type."""
class StringRefExecutor(Executor):
@handler(input="ForwardRefMessage")
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
exec_instance = StringRefExecutor(id="string_ref")
# Should resolve the string to the actual type
assert ForwardRefMessage in exec_instance._handlers
assert exec_instance.can_handle(Message(data=ForwardRefMessage("hello"), source_id="mock"))
def test_handler_with_string_forward_reference_union(self):
"""Test that string forward references work with union types."""
class StringUnionExecutor(Executor):
@handler(input="ForwardRefTypeA | ForwardRefTypeB")
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
exec_instance = StringUnionExecutor(id="string_union")
# Should handle both types
assert exec_instance.can_handle(Message(data=ForwardRefTypeA("hello"), source_id="mock"))
assert exec_instance.can_handle(Message(data=ForwardRefTypeB(42), source_id="mock"))
def test_handler_with_string_forward_reference_output_type(self):
"""Test that string forward references work for output_type."""
class StringOutputExecutor(Executor):
@handler(input=str, output="ForwardRefResponse")
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
exec_instance = StringOutputExecutor(id="string_output")
# Should resolve the string output type
assert ForwardRefResponse in exec_instance.output_types
def test_handler_with_explicit_workflow_output_type(self):
"""Test that explicit workflow_output works when input is also specified."""
class ExplicitWorkflowOutputExecutor(Executor):
@handler(input=str, workflow_output=bool)
async def handle(self, message: str, ctx: WorkflowContext[int]) -> None:
pass
exec_instance = ExplicitWorkflowOutputExecutor(id="explicit_workflow_output")
# Handler spec should have bool as workflow_output_type (explicit)
handler_func = exec_instance._handlers[str]
assert handler_func._handler_spec["workflow_output_types"] == [bool]
# Executor workflow_output_types property should reflect explicit type
assert bool in exec_instance.workflow_output_types
# output_types should be empty (explicit mode, output not specified)
assert exec_instance.output_types == []
def test_handler_with_explicit_workflow_output_and_output(self):
"""Test that explicit workflow_output works alongside explicit output."""
class PrecedenceExecutor(Executor):
@handler(input=int, output=float, workflow_output=str)
async def handle(self, message: int, ctx: WorkflowContext[int, bool]) -> None:
pass
exec_instance = PrecedenceExecutor(id="precedence")
# All types should come from explicit params
assert int in exec_instance._handlers
assert float in exec_instance.output_types
assert str in exec_instance.workflow_output_types
# Introspected types should NOT be present
assert bool not in exec_instance.workflow_output_types
def test_handler_with_all_explicit_types(self):
"""Test that all three explicit type parameters work together."""
from typing import Any
class AllExplicitExecutor(Executor):
@handler(input=str, output=int, workflow_output=bool)
async def handle(self, message: Any, ctx: WorkflowContext) -> None:
pass
exec_instance = AllExplicitExecutor(id="all_explicit")
# Check input type
assert str in exec_instance._handlers
assert exec_instance.can_handle(Message(data="hello", source_id="mock"))
# Check output_type
assert int in exec_instance.output_types
# Check workflow_output_type
assert bool in exec_instance.workflow_output_types
def test_handler_with_union_workflow_output_type(self):
"""Test that union types work for workflow_output."""
class UnionWorkflowOutputExecutor(Executor):
@handler(input=str, workflow_output=str | int)
async def handle(self, message: str, ctx: WorkflowContext) -> None:
pass
exec_instance = UnionWorkflowOutputExecutor(id="union_workflow_output")
# Should include both types from union
assert str in exec_instance.workflow_output_types
assert int in exec_instance.workflow_output_types
def test_handler_with_string_forward_reference_workflow_output_type(self):
"""Test that string forward references work for workflow_output_type."""
class StringWorkflowOutputExecutor(Executor):
@handler(input=str, workflow_output="ForwardRefResponse")
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
exec_instance = StringWorkflowOutputExecutor(id="string_workflow_output")
# Should resolve the string workflow_output_type
assert ForwardRefResponse in exec_instance.workflow_output_types
def test_handler_with_string_forward_reference_union_workflow_output_type(self):
"""Test that string forward reference union types work for workflow_output_type."""
class StringUnionWorkflowOutputExecutor(Executor):
@handler(input=str, workflow_output="ForwardRefTypeA | ForwardRefTypeB")
async def handle(self, message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
exec_instance = StringUnionWorkflowOutputExecutor(id="string_union_workflow_output")
# Should resolve both types from string union
assert ForwardRefTypeA in exec_instance.workflow_output_types
assert ForwardRefTypeB in exec_instance.workflow_output_types
def test_handler_fallback_to_introspection_for_workflow_output_type(self):
"""Test that workflow_output_type falls back to introspection when not explicitly provided."""
class IntrospectedWorkflowOutputExecutor(Executor):
@handler
async def handle(self, message: str, ctx: WorkflowContext[int, bool]) -> None:
pass
exec_instance = IntrospectedWorkflowOutputExecutor(id="introspected_workflow_output")
# Should use introspected types from WorkflowContext[int, bool]
assert int in exec_instance.output_types
assert bool in exec_instance.workflow_output_types
# endregion: Tests for @handler decorator with explicit input_type and output_type
@@ -1,5 +1,6 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from typing import Any
import pytest
@@ -14,6 +15,27 @@ from agent_framework import (
)
# Module-level types for string forward reference tests
@dataclass
class FuncExecForwardRefMessage:
content: str
@dataclass
class FuncExecForwardRefTypeA:
value: str
@dataclass
class FuncExecForwardRefTypeB:
value: int
@dataclass
class FuncExecForwardRefResponse:
result: str
class TestFunctionExecutor:
"""Test suite for FunctionExecutor and @executor decorator."""
@@ -535,3 +557,341 @@ class TestFunctionExecutor:
async_static = static_wrapped
assert asyncio.iscoroutinefunction(C.async_static) # Works via descriptor protocol
class TestExecutorExplicitTypes:
"""Test suite for @executor decorator with explicit input_type and output_type parameters."""
def test_executor_with_explicit_input_type(self):
"""Test that explicit input_type takes precedence over introspection."""
@executor(input=str)
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Handler should be registered for str (explicit)
assert str in process._handlers
assert len(process._handlers) == 1
# Can handle str messages
assert process.can_handle(Message(data="hello", source_id="mock"))
# Cannot handle int messages
assert not process.can_handle(Message(data=42, source_id="mock"))
def test_executor_with_explicit_output_type(self):
"""Test that explicit output_type takes precedence over introspection."""
@executor(output=int)
async def process(message: str, ctx: WorkflowContext[str]) -> None:
pass
# Handler spec should have int as output type (explicit), not str (introspected)
spec = process._handler_specs[0]
assert spec["output_types"] == [int]
# Executor output_types property should reflect explicit type
assert int in process.output_types
assert str not in process.output_types
def test_executor_with_explicit_input_and_output_types(self):
"""Test that both explicit input_type and output_type work together."""
@executor(id="explicit_both", input=dict, output=list)
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Handler should be registered for dict (explicit input type)
assert dict in process._handlers
assert len(process._handlers) == 1
# Output type should be list (explicit)
spec = process._handler_specs[0]
assert spec["output_types"] == [list]
# Verify can_handle
assert process.can_handle(Message(data={"key": "value"}, source_id="mock"))
assert not process.can_handle(Message(data="string", source_id="mock"))
def test_executor_with_explicit_union_input_type(self):
"""Test that explicit union input_type is handled correctly."""
@executor(input=str | int)
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Handler should be registered for the union type
assert len(process._handlers) == 1
# Can handle both str and int messages
assert process.can_handle(Message(data="hello", source_id="mock"))
assert process.can_handle(Message(data=42, source_id="mock"))
# Cannot handle float
assert not process.can_handle(Message(data=3.14, source_id="mock"))
def test_executor_with_explicit_union_output_type(self):
"""Test that explicit union output_type is normalized to a list."""
@executor(output=str | int | bool)
async def process(message: Any, ctx: WorkflowContext) -> None:
pass
# Output types should be a list with all union members
assert set(process.output_types) == {str, int, bool}
def test_executor_explicit_types_precedence_over_introspection(self):
"""Test that explicit types always take precedence over introspected types."""
# Introspection would give: input=str, output=[int]
# Explicit gives: input=bytes, output=[float]
@executor(input=bytes, output=float)
async def process(message: str, ctx: WorkflowContext[int]) -> None:
pass
# Should use explicit input type (bytes), not introspected (str)
assert bytes in process._handlers
assert str not in process._handlers
# Should use explicit output type (float), not introspected (int)
assert float in process.output_types
assert int not in process.output_types
def test_executor_fallback_to_introspection_when_no_explicit_types(self):
"""Test that introspection is used when no explicit types are provided."""
@executor
async def process(message: str, ctx: WorkflowContext[int]) -> None:
pass
# Should use introspected types
assert str in process._handlers
assert int in process.output_types
def test_executor_partial_explicit_types(self):
"""Test that partial explicit types work (only input_type or only output_type)."""
# Only explicit input_type, introspect output_type
@executor(input=bytes)
async def process_input(message: str, ctx: WorkflowContext[int]) -> None:
pass
assert bytes in process_input._handlers # Explicit
assert int in process_input.output_types # Introspected
# Only explicit output_type, introspect input_type
@executor(output=float)
async def process_output(message: str, ctx: WorkflowContext[int]) -> None:
pass
assert str in process_output._handlers # Introspected
assert float in process_output.output_types # Explicit
assert int not in process_output.output_types # Not introspected when explicit provided
def test_executor_explicit_input_type_allows_no_message_annotation(self):
"""Test that explicit input_type allows function without message type annotation."""
@executor(input=str)
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Should work with explicit input_type
assert str in process._handlers
assert process.can_handle(Message(data="hello", source_id="mock"))
def test_executor_explicit_types_with_id(self):
"""Test that explicit types work together with id parameter."""
@executor(id="custom_id", input=bytes, output=int)
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
assert process.id == "custom_id"
assert bytes in process._handlers
assert int in process.output_types
def test_executor_explicit_types_with_single_param_function(self):
"""Test that explicit input_type works with single-parameter functions."""
@executor(input=str)
async def process(message): # type: ignore[no-untyped-def]
return message.upper()
# Should work with explicit input_type
assert str in process._handlers
assert process.can_handle(Message(data="hello", source_id="mock"))
assert not process.can_handle(Message(data=42, source_id="mock"))
def test_executor_explicit_types_with_sync_function(self):
"""Test that explicit types work with synchronous functions."""
@executor(input=int, output=str)
def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
assert int in process._handlers
assert str in process.output_types
def test_function_executor_constructor_with_explicit_types(self):
"""Test FunctionExecutor constructor with explicit input_type and output_type."""
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
func_exec = FunctionExecutor(process, id="test", input=dict, output=list)
assert dict in func_exec._handlers
spec = func_exec._handler_specs[0]
assert spec["message_type"] is dict
assert spec["output_types"] == [list]
def test_executor_explicit_union_types_via_typing_union(self):
"""Test that Union[] syntax also works for explicit types."""
from typing import Union
@executor(input=Union[str, int], output=Union[bool, float])
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Can handle both str and int
assert process.can_handle(Message(data="hello", source_id="mock"))
assert process.can_handle(Message(data=42, source_id="mock"))
# Output types should include both
assert set(process.output_types) == {bool, float}
def test_executor_with_string_forward_reference_input_type(self):
"""Test that string forward references work for input_type."""
@executor(input="FuncExecForwardRefMessage")
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Should resolve the string to the actual type
assert FuncExecForwardRefMessage in process._handlers
assert process.can_handle(Message(data=FuncExecForwardRefMessage("hello"), source_id="mock"))
def test_executor_with_string_forward_reference_union(self):
"""Test that string forward references work with union types."""
@executor(input="FuncExecForwardRefTypeA | FuncExecForwardRefTypeB")
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Should handle both types
assert process.can_handle(Message(data=FuncExecForwardRefTypeA("hello"), source_id="mock"))
assert process.can_handle(Message(data=FuncExecForwardRefTypeB(42), source_id="mock"))
def test_executor_with_string_forward_reference_output_type(self):
"""Test that string forward references work for output_type."""
@executor(input=str, output="FuncExecForwardRefResponse")
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Should resolve the string output type
assert FuncExecForwardRefResponse in process.output_types
def test_executor_with_explicit_workflow_output_type(self):
"""Test that explicit workflow_output_type takes precedence over introspection."""
@executor(workflow_output=bool)
async def process(message: str, ctx: WorkflowContext[int]) -> None:
pass
# Handler spec should have bool as workflow_output_type (explicit)
spec = process._handler_specs[0]
assert spec["workflow_output_types"] == [bool]
# Executor workflow_output_types property should reflect explicit type
assert bool in process.workflow_output_types
# output_types should still come from introspection (int from WorkflowContext[int])
assert int in process.output_types
def test_executor_with_explicit_workflow_output_type_precedence(self):
"""Test that explicit workflow_output_type overrides introspected WorkflowContext second param."""
@executor(workflow_output=str)
async def process(message: int, ctx: WorkflowContext[int, bool]) -> None:
pass
# workflow_output_types should be str (explicit), not bool (introspected from ctx)
assert str in process.workflow_output_types
assert bool not in process.workflow_output_types
def test_executor_with_all_explicit_types(self):
"""Test that all three explicit type parameters work together."""
from typing import Any
@executor(input=str, output=int, workflow_output=bool)
async def process(message: Any, ctx: WorkflowContext) -> None:
pass
# Check input type
assert str in process._handlers
assert process.can_handle(Message(data="hello", source_id="mock"))
# Check output_type
assert int in process.output_types
# Check workflow_output_type
assert bool in process.workflow_output_types
def test_executor_with_union_workflow_output_type(self):
"""Test that union types work for workflow_output_type."""
@executor(workflow_output=str | int)
async def process(message: str, ctx: WorkflowContext) -> None:
pass
# Should include both types from union
assert str in process.workflow_output_types
assert int in process.workflow_output_types
def test_executor_with_string_forward_reference_workflow_output_type(self):
"""Test that string forward references work for workflow_output_type."""
@executor(input=str, workflow_output="FuncExecForwardRefResponse")
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Should resolve the string workflow_output_type
assert FuncExecForwardRefResponse in process.workflow_output_types
def test_executor_with_string_forward_reference_union_workflow_output_type(self):
"""Test that string forward reference union types work for workflow_output_type."""
@executor(input=str, workflow_output="FuncExecForwardRefTypeA | FuncExecForwardRefTypeB")
async def process(message, ctx: WorkflowContext) -> None: # type: ignore[no-untyped-def]
pass
# Should resolve both types from string union
assert FuncExecForwardRefTypeA in process.workflow_output_types
assert FuncExecForwardRefTypeB in process.workflow_output_types
def test_executor_fallback_to_introspection_for_workflow_output_type(self):
"""Test that workflow_output_type falls back to introspection when not explicitly provided."""
@executor
async def process(message: str, ctx: WorkflowContext[int, bool]) -> None:
pass
# Should use introspected types from WorkflowContext[int, bool]
assert int in process.output_types
assert bool in process.workflow_output_types
def test_function_executor_constructor_with_workflow_output_type(self):
"""Test FunctionExecutor constructor accepts workflow_output_type parameter."""
async def my_func(message: str, ctx: WorkflowContext) -> None:
pass
exec_instance = FunctionExecutor(
my_func,
id="test_constructor",
input=str,
output=int,
workflow_output=bool,
)
assert str in exec_instance._handlers
assert int in exec_instance.output_types
assert bool in exec_instance.workflow_output_types
@@ -247,7 +247,6 @@ class TestRequestInfoMixin:
assert "output_types" in spec
assert "workflow_output_types" in spec
assert "ctx_annotation" in spec
assert spec["source"] == "class_method"
def test_multiple_discovery_calls_raise_error(self):
"""Test that multiple calls to _discover_response_handlers raise an error for duplicates."""
@@ -786,3 +785,170 @@ class TestRequestInfoMixin:
# Should not support unregistered combinations
assert child.is_request_supported(str, str) is False
assert child.is_request_supported(int, str) is False
class TestResponseHandlerExplicitTypes:
"""Test cases for response_handler with explicit type parameters."""
def test_response_handler_with_explicit_types(self):
"""Test response_handler with explicit request and response types."""
@response_handler(request=str, response=int)
async def test_handler(self, original_request, response, ctx) -> None:
pass
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
assert spec["name"] == "test_handler"
assert spec["request_type"] is str
assert spec["response_type"] is int
def test_response_handler_with_explicit_output_types(self):
"""Test response_handler with explicit output and workflow_output types."""
@response_handler(request=str, response=int, output=bool, workflow_output=float)
async def test_handler(self, original_request, response, ctx) -> None:
pass
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
assert spec["request_type"] is str
assert spec["response_type"] is int
assert bool in spec["output_types"]
assert float in spec["workflow_output_types"]
def test_response_handler_with_union_types(self):
"""Test response_handler with union types."""
@response_handler(request=str | int, response=bool | float)
async def test_handler(self, original_request, response, ctx) -> None:
pass
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
assert spec["request_type"] == str | int
assert spec["response_type"] == bool | float
def test_response_handler_with_string_forward_references(self):
"""Test response_handler with string forward references."""
@response_handler(request="str", response="int")
async def test_handler(self, original_request, response, ctx) -> None:
pass
spec = test_handler._response_handler_spec # type: ignore[reportAttributeAccessIssue]
assert spec["request_type"] is str
assert spec["response_type"] is int
def test_response_handler_explicit_missing_request_raises_error(self):
"""Test that using explicit types without request raises an error."""
with pytest.raises(ValueError, match="must specify 'request' type"):
@response_handler(response=int)
async def test_handler(self, original_request, response, ctx) -> None:
pass
def test_response_handler_explicit_missing_response_raises_error(self):
"""Test that using explicit types without response raises an error."""
with pytest.raises(ValueError, match="must specify 'response' type"):
@response_handler(request=str)
async def test_handler(self, original_request, response, ctx) -> None:
pass
def test_response_handler_explicit_only_output_raises_error(self):
"""Test that using only output without request/response raises an error."""
with pytest.raises(ValueError, match="must specify 'request' type"):
@response_handler(output=bool)
async def test_handler(self, original_request, response, ctx) -> None:
pass
def test_executor_with_explicit_response_handlers(self):
"""Test an executor with explicit type response handlers."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor")
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler(request=str, response=int, output=bool)
async def handle_explicit(self, original_request, response, ctx) -> None:
pass
executor = TestExecutor()
# Should be request-response capable
assert executor.is_request_response_capable is True
# Should have registered handler
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 1
assert (str, int) in response_handlers
# Check specs
specs = executor._response_handler_specs # type: ignore[reportAttributeAccessIssue]
assert len(specs) == 1
assert specs[0]["request_type"] is str
assert specs[0]["response_type"] is int
assert bool in specs[0]["output_types"]
def test_response_handler_explicit_callable(self):
"""Test that explicit type response handlers can be called."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor")
self.handled_request = None
self.handled_response = None
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
@response_handler(request=str, response=int)
async def handle_response(self, original_request, response, ctx) -> None:
self.handled_request = original_request
self.handled_response = response
executor = TestExecutor()
# Get the handler
response_handler_func = executor._response_handlers[(str, int)] # type: ignore[reportAttributeAccessIssue]
# Call the handler
asyncio.run(response_handler_func("test_request", 42, None)) # type: ignore[reportArgumentType]
assert executor.handled_request == "test_request"
assert executor.handled_response == 42
def test_mixed_introspection_and_explicit_handlers(self):
"""Test executor with both introspection and explicit type handlers."""
class TestExecutor(Executor):
def __init__(self):
super().__init__(id="test_executor")
@handler
async def dummy_handler(self, message: str, ctx: WorkflowContext) -> None:
pass
# Introspection-based handler
@response_handler
async def handle_introspection(
self, original_request: str, response: int, ctx: WorkflowContext[str]
) -> None:
pass
# Explicit type handler
@response_handler(request=dict, response=bool)
async def handle_explicit(self, original_request, response, ctx) -> None:
pass
executor = TestExecutor()
# Should have both handlers
response_handlers = executor._response_handlers # type: ignore[reportAttributeAccessIssue]
assert len(response_handlers) == 2
assert (str, int) in response_handlers
assert (dict, bool) in response_handlers
@@ -1,16 +1,153 @@
# Copyright (c) Microsoft. All rights reserved.
from dataclasses import dataclass
from typing import Any, Generic, TypeVar, Union
from typing import Any, Generic, Optional, TypeVar, Union
import pytest
from agent_framework import RequestInfoEvent
from agent_framework._workflows._typing_utils import (
deserialize_type,
is_instance_of,
is_type_compatible,
normalize_type_to_list,
resolve_type_annotation,
serialize_type,
)
# region: normalize_type_to_list tests
def test_normalize_type_to_list_single_type() -> None:
"""Test normalize_type_to_list with single types."""
assert normalize_type_to_list(str) == [str]
assert normalize_type_to_list(int) == [int]
assert normalize_type_to_list(float) == [float]
assert normalize_type_to_list(bool) == [bool]
assert normalize_type_to_list(list) == [list]
assert normalize_type_to_list(dict) == [dict]
def test_normalize_type_to_list_none() -> None:
"""Test normalize_type_to_list with None returns empty list."""
assert normalize_type_to_list(None) == []
def test_normalize_type_to_list_union_pipe_syntax() -> None:
"""Test normalize_type_to_list with union types using | syntax."""
result = normalize_type_to_list(str | int)
assert set(result) == {str, int}
result = normalize_type_to_list(str | int | bool)
assert set(result) == {str, int, bool}
def test_normalize_type_to_list_union_typing_syntax() -> None:
"""Test normalize_type_to_list with Union[] from typing module."""
result = normalize_type_to_list(Union[str, int])
assert set(result) == {str, int}
result = normalize_type_to_list(Union[str, int, bool])
assert set(result) == {str, int, bool}
def test_normalize_type_to_list_optional() -> None:
"""Test normalize_type_to_list with Optional types (Union[T, None])."""
# Optional[str] is Union[str, None]
result = normalize_type_to_list(Optional[str])
assert str in result
assert type(None) in result
assert len(result) == 2
# str | None is equivalent
result = normalize_type_to_list(str | None)
assert str in result
assert type(None) in result
assert len(result) == 2
def test_normalize_type_to_list_custom_types() -> None:
"""Test normalize_type_to_list with custom class types."""
@dataclass
class CustomMessage:
content: str
result = normalize_type_to_list(CustomMessage)
assert result == [CustomMessage]
result = normalize_type_to_list(CustomMessage | str)
assert set(result) == {CustomMessage, str}
# endregion: normalize_type_to_list tests
# region: resolve_type_annotation tests
def test_resolve_type_annotation_none() -> None:
"""Test resolve_type_annotation with None returns None."""
assert resolve_type_annotation(None) is None
def test_resolve_type_annotation_actual_types() -> None:
"""Test resolve_type_annotation passes through actual types unchanged."""
assert resolve_type_annotation(str) is str
assert resolve_type_annotation(int) is int
assert resolve_type_annotation(str | int) == str | int
def test_resolve_type_annotation_string_builtin() -> None:
"""Test resolve_type_annotation resolves string references to builtin types."""
result = resolve_type_annotation("str", {"str": str})
assert result is str
result = resolve_type_annotation("int", {"int": int})
assert result is int
def test_resolve_type_annotation_string_union() -> None:
"""Test resolve_type_annotation resolves string union types."""
result = resolve_type_annotation("str | int", {"str": str, "int": int})
assert result == str | int
def test_resolve_type_annotation_string_custom_type() -> None:
"""Test resolve_type_annotation resolves string references to custom types."""
@dataclass
class MyCustomType:
value: int
result = resolve_type_annotation("MyCustomType", {"MyCustomType": MyCustomType})
assert result is MyCustomType
result = resolve_type_annotation("MyCustomType | str", {"MyCustomType": MyCustomType, "str": str})
assert set(result.__args__) == {MyCustomType, str} # type: ignore[union-attr]
def test_resolve_type_annotation_string_typing_union() -> None:
"""Test resolve_type_annotation resolves Union[] syntax in strings."""
result = resolve_type_annotation("Union[str, int]", {"str": str, "int": int})
assert set(result.__args__) == {str, int} # type: ignore[union-attr]
def test_resolve_type_annotation_string_optional() -> None:
"""Test resolve_type_annotation resolves Optional[] syntax in strings."""
result = resolve_type_annotation("Optional[str]", {"str": str})
assert str in result.__args__ # type: ignore[union-attr]
assert type(None) in result.__args__ # type: ignore[union-attr]
def test_resolve_type_annotation_unresolvable_raises() -> None:
"""Test resolve_type_annotation raises NameError for unresolvable types."""
with pytest.raises(NameError, match="Could not resolve type annotation"):
resolve_type_annotation("NonExistentType", {})
# endregion: resolve_type_annotation tests
def test_basic_types() -> None:
"""Test basic built-in types."""