Python: Add CreateConversationExecutor, fix input routing, remove unused handler layer (#4159)

* Fixed declarative deep research sample

* Small fix

* Resolved comment

* Add CreateConversationExecutor, fix input routing, remove unused handler layer

* Address Copilot feedback

* Fix System.ConversationId

---------

Co-authored-by: Dmytro Struk <13853051+dmytrostruk@users.noreply.github.com>
This commit is contained in:
Evan Mattson
2026-02-24 10:59:39 +09:00
committed by GitHub
Unverified
parent bb4fe48c9a
commit de612c47f5
24 changed files with 456 additions and 3597 deletions
@@ -1,348 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for additional action handlers (conversation, variables, etc.)."""
import pytest
import agent_framework_declarative._workflows._actions_basic # noqa: F401
import agent_framework_declarative._workflows._actions_control_flow # noqa: F401
from agent_framework_declarative._workflows._handlers import get_action_handler
from agent_framework_declarative._workflows._state import WorkflowState
def create_action_context(action: dict, state: WorkflowState | None = None):
"""Create a minimal action context for testing."""
from agent_framework_declarative._workflows._handlers import ActionContext
if state is None:
state = WorkflowState()
async def execute_actions(actions, state):
for act in actions:
handler = get_action_handler(act.get("kind"))
if handler:
async for event in handler(
ActionContext(
state=state,
action=act,
execute_actions=execute_actions,
agents={},
bindings={},
)
):
yield event
return ActionContext(
state=state,
action=action,
execute_actions=execute_actions,
agents={},
bindings={},
)
class TestSetTextVariableHandler:
"""Tests for SetTextVariable action handler."""
@pytest.mark.asyncio
async def test_set_text_variable_simple(self):
"""Test setting a simple text variable."""
ctx = create_action_context({
"kind": "SetTextVariable",
"variable": "Local.greeting",
"value": "Hello, World!",
})
handler = get_action_handler("SetTextVariable")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.greeting") == "Hello, World!"
@pytest.mark.asyncio
async def test_set_text_variable_with_interpolation(self):
"""Test setting text with variable interpolation."""
state = WorkflowState()
state.set("Local.name", "Alice")
ctx = create_action_context(
{
"kind": "SetTextVariable",
"variable": "Local.message",
"value": "Hello, {Local.name}!",
},
state=state,
)
handler = get_action_handler("SetTextVariable")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.message") == "Hello, Alice!"
class TestResetVariableHandler:
"""Tests for ResetVariable action handler."""
@pytest.mark.asyncio
async def test_reset_variable(self):
"""Test resetting a variable to None."""
state = WorkflowState()
state.set("Local.counter", 5)
ctx = create_action_context(
{
"kind": "ResetVariable",
"variable": "Local.counter",
},
state=state,
)
handler = get_action_handler("ResetVariable")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.counter") is None
class TestSetMultipleVariablesHandler:
"""Tests for SetMultipleVariables action handler."""
@pytest.mark.asyncio
async def test_set_multiple_variables(self):
"""Test setting multiple variables at once."""
ctx = create_action_context({
"kind": "SetMultipleVariables",
"variables": [
{"variable": "Local.a", "value": 1},
{"variable": "Local.b", "value": 2},
{"variable": "Local.c", "value": "three"},
],
})
handler = get_action_handler("SetMultipleVariables")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.a") == 1
assert ctx.state.get("Local.b") == 2
assert ctx.state.get("Local.c") == "three"
class TestClearAllVariablesHandler:
"""Tests for ClearAllVariables action handler."""
@pytest.mark.asyncio
async def test_clear_all_variables(self):
"""Test clearing all turn-scoped variables."""
state = WorkflowState()
state.set("Local.a", 1)
state.set("Local.b", 2)
state.set("Workflow.Outputs.result", "kept")
ctx = create_action_context(
{
"kind": "ClearAllVariables",
},
state=state,
)
handler = get_action_handler("ClearAllVariables")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.a") is None
assert ctx.state.get("Local.b") is None
# Workflow outputs should be preserved
assert ctx.state.get("Workflow.Outputs.result") == "kept"
class TestCreateConversationHandler:
"""Tests for CreateConversation action handler."""
@pytest.mark.asyncio
async def test_create_conversation_with_output_binding(self):
"""Test creating a new conversation with output variable binding.
The conversationId field specifies the OUTPUT variable where the
auto-generated conversation ID is stored.
"""
ctx = create_action_context({
"kind": "CreateConversation",
"conversationId": "Local.myConvId", # Output variable
})
handler = get_action_handler("CreateConversation")
_events = [e async for e in handler(ctx)] # noqa: F841
# Check conversation was created with auto-generated ID
conversations = ctx.state.get("System.conversations")
assert conversations is not None
assert len(conversations) == 1
# Get the generated ID
generated_id = list(conversations.keys())[0]
assert conversations[generated_id]["messages"] == []
# Check output binding - the ID should be stored in the specified variable
assert ctx.state.get("Local.myConvId") == generated_id
@pytest.mark.asyncio
async def test_create_conversation_legacy_output(self):
"""Test creating a conversation with legacy output binding."""
ctx = create_action_context({
"kind": "CreateConversation",
"output": {
"conversationId": "Local.myConvId",
},
})
handler = get_action_handler("CreateConversation")
_events = [e async for e in handler(ctx)] # noqa: F841
# Check conversation was created
conversations = ctx.state.get("System.conversations")
assert conversations is not None
assert len(conversations) == 1
# Get the generated ID
generated_id = list(conversations.keys())[0]
# Check legacy output binding
assert ctx.state.get("Local.myConvId") == generated_id
@pytest.mark.asyncio
async def test_create_conversation_auto_id(self):
"""Test creating a conversation with auto-generated ID."""
ctx = create_action_context({
"kind": "CreateConversation",
})
handler = get_action_handler("CreateConversation")
_events = [e async for e in handler(ctx)] # noqa: F841
# Check conversation was created with some ID
conversations = ctx.state.get("System.conversations")
assert conversations is not None
assert len(conversations) == 1
class TestAddConversationMessageHandler:
"""Tests for AddConversationMessage action handler."""
@pytest.mark.asyncio
async def test_add_conversation_message(self):
"""Test adding a message to a conversation."""
state = WorkflowState()
state.set(
"System.conversations",
{
"conv-123": {"id": "conv-123", "messages": []},
},
)
ctx = create_action_context(
{
"kind": "AddConversationMessage",
"conversationId": "conv-123",
"message": {
"role": "user",
"content": "Hello!",
},
},
state=state,
)
handler = get_action_handler("AddConversationMessage")
_events = [e async for e in handler(ctx)] # noqa: F841
conversations = ctx.state.get("System.conversations")
assert len(conversations["conv-123"]["messages"]) == 1
assert conversations["conv-123"]["messages"][0]["content"] == "Hello!"
class TestEndWorkflowHandler:
"""Tests for EndWorkflow action handler."""
@pytest.mark.asyncio
async def test_end_workflow_signal(self):
"""Test that EndWorkflow emits correct signal."""
from agent_framework_declarative._workflows._actions_control_flow import EndWorkflowSignal
ctx = create_action_context({
"kind": "EndWorkflow",
"reason": "Completed successfully",
})
handler = get_action_handler("EndWorkflow")
events = [e async for e in handler(ctx)]
assert len(events) == 1
assert isinstance(events[0], EndWorkflowSignal)
assert events[0].reason == "Completed successfully"
class TestEndConversationHandler:
"""Tests for EndConversation action handler."""
@pytest.mark.asyncio
async def test_end_conversation_signal(self):
"""Test that EndConversation emits correct signal."""
from agent_framework_declarative._workflows._actions_control_flow import EndConversationSignal
ctx = create_action_context({
"kind": "EndConversation",
"conversationId": "conv-123",
})
handler = get_action_handler("EndConversation")
events = [e async for e in handler(ctx)]
assert len(events) == 1
assert isinstance(events[0], EndConversationSignal)
assert events[0].conversation_id == "conv-123"
class TestConditionGroupWithElseActions:
"""Tests for ConditionGroup with elseActions."""
@pytest.mark.asyncio
async def test_condition_group_else_actions(self):
"""Test that elseActions execute when no condition matches."""
ctx = create_action_context({
"kind": "ConditionGroup",
"conditions": [
{
"condition": False,
"actions": [
{"kind": "SetValue", "path": "Local.result", "value": "matched"},
],
},
],
"elseActions": [
{"kind": "SetValue", "path": "Local.result", "value": "else"},
],
})
handler = get_action_handler("ConditionGroup")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.result") == "else"
@pytest.mark.asyncio
async def test_condition_group_match_skips_else(self):
"""Test that elseActions don't execute when a condition matches."""
ctx = create_action_context({
"kind": "ConditionGroup",
"conditions": [
{
"condition": True,
"actions": [
{"kind": "SetValue", "path": "Local.result", "value": "matched"},
],
},
],
"elseActions": [
{"kind": "SetValue", "path": "Local.result", "value": "else"},
],
})
handler = get_action_handler("ConditionGroup")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.result") == "matched"
@@ -1,286 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Tests for human-in-the-loop action handlers."""
import pytest
from agent_framework_declarative._workflows._handlers import ActionContext, get_action_handler
from agent_framework_declarative._workflows._human_input import (
QuestionRequest,
process_external_loop,
validate_input_response,
)
from agent_framework_declarative._workflows._state import WorkflowState
def create_action_context(action: dict, state: WorkflowState | None = None):
"""Create a minimal action context for testing."""
if state is None:
state = WorkflowState()
async def execute_actions(actions, state):
for act in actions:
handler = get_action_handler(act.get("kind"))
if handler:
async for event in handler(
ActionContext(
state=state,
action=act,
execute_actions=execute_actions,
agents={},
bindings={},
)
):
yield event
return ActionContext(
state=state,
action=action,
execute_actions=execute_actions,
agents={},
bindings={},
)
class TestQuestionHandler:
"""Tests for Question action handler."""
@pytest.mark.asyncio
async def test_question_emits_request_info_event(self):
"""Test that Question handler emits QuestionRequest."""
ctx = create_action_context({
"kind": "Question",
"id": "ask_name",
"variable": "Local.userName",
"prompt": "What is your name?",
})
handler = get_action_handler("Question")
events = [e async for e in handler(ctx)]
assert len(events) == 1
assert isinstance(events[0], QuestionRequest)
assert events[0].request_id == "ask_name"
assert events[0].prompt == "What is your name?"
assert events[0].variable == "Local.userName"
@pytest.mark.asyncio
async def test_question_with_choices(self):
"""Test Question with multiple choice options."""
ctx = create_action_context({
"kind": "Question",
"id": "ask_choice",
"variable": "Local.selection",
"prompt": "Select an option:",
"choices": ["Option A", "Option B", "Option C"],
"default": "Option A",
})
handler = get_action_handler("Question")
events = [e async for e in handler(ctx)]
assert len(events) == 1
event = events[0]
assert isinstance(event, QuestionRequest)
assert event.choices == ["Option A", "Option B", "Option C"]
assert event.default_value == "Option A"
@pytest.mark.asyncio
async def test_question_with_validation(self):
"""Test Question with validation rules."""
ctx = create_action_context({
"kind": "Question",
"id": "ask_email",
"variable": "Local.email",
"prompt": "Enter your email:",
"validation": {
"required": True,
"pattern": r"^[\w\.-]+@[\w\.-]+\.\w+$",
},
})
handler = get_action_handler("Question")
events = [e async for e in handler(ctx)]
assert len(events) == 1
event = events[0]
assert event.validation == {
"required": True,
"pattern": r"^[\w\.-]+@[\w\.-]+\.\w+$",
}
class TestRequestExternalInputHandler:
"""Tests for RequestExternalInput action handler."""
@pytest.mark.asyncio
async def test_request_external_input(self):
"""Test RequestExternalInput handler emits event."""
ctx = create_action_context({
"kind": "RequestExternalInput",
"id": "get_approval",
"variable": "Local.approval",
"prompt": "Please approve or reject",
"timeout": 300,
})
handler = get_action_handler("RequestExternalInput")
events = [e async for e in handler(ctx)]
assert len(events) == 1
event = events[0]
assert isinstance(event, QuestionRequest)
assert event.request_id == "get_approval"
assert event.variable == "Local.approval"
assert event.validation == {"timeout": 300}
class TestWaitForInputHandler:
"""Tests for WaitForInput action handler."""
@pytest.mark.asyncio
async def test_wait_for_input(self):
"""Test WaitForInput handler."""
ctx = create_action_context({
"kind": "WaitForInput",
"id": "wait",
"variable": "Local.response",
"message": "Waiting...",
})
handler = get_action_handler("WaitForInput")
events = [e async for e in handler(ctx)]
assert len(events) == 1
event = events[0]
assert isinstance(event, QuestionRequest)
assert event.request_id == "wait"
assert event.prompt == "Waiting..."
class TestProcessExternalLoop:
"""Tests for process_external_loop helper function."""
def test_no_external_loop(self):
"""Test when no external loop is configured."""
state = WorkflowState()
result, expr = process_external_loop({}, state)
assert result is False
assert expr is None
def test_external_loop_true_condition(self):
"""Test when external loop condition evaluates to true."""
state = WorkflowState()
state.set("Local.isComplete", False)
input_config = {
"externalLoop": {
"when": "=!Local.isComplete",
},
}
result, expr = process_external_loop(input_config, state)
# !False = True, so loop should continue
assert result is True
assert expr == "=!Local.isComplete"
def test_external_loop_false_condition(self):
"""Test when external loop condition evaluates to false."""
state = WorkflowState()
state.set("Local.isComplete", True)
input_config = {
"externalLoop": {
"when": "=!Local.isComplete",
},
}
result, expr = process_external_loop(input_config, state)
# !True = False, so loop should stop
assert result is False
class TestValidateInputResponse:
"""Tests for validate_input_response helper function."""
def test_no_validation(self):
"""Test with no validation rules."""
is_valid, error = validate_input_response("any value", None)
assert is_valid is True
assert error is None
def test_required_valid(self):
"""Test required validation with valid value."""
is_valid, error = validate_input_response("value", {"required": True})
assert is_valid is True
assert error is None
def test_required_empty_string(self):
"""Test required validation with empty string."""
is_valid, error = validate_input_response("", {"required": True})
assert is_valid is False
assert "required" in error.lower()
def test_required_none(self):
"""Test required validation with None."""
is_valid, error = validate_input_response(None, {"required": True})
assert is_valid is False
assert "required" in error.lower()
def test_min_length_valid(self):
"""Test minLength validation with valid value."""
is_valid, error = validate_input_response("hello", {"minLength": 3})
assert is_valid is True
def test_min_length_invalid(self):
"""Test minLength validation with too short value."""
is_valid, error = validate_input_response("hi", {"minLength": 3})
assert is_valid is False
assert "minimum length" in error.lower()
def test_max_length_valid(self):
"""Test maxLength validation with valid value."""
is_valid, error = validate_input_response("hello", {"maxLength": 10})
assert is_valid is True
def test_max_length_invalid(self):
"""Test maxLength validation with too long value."""
is_valid, error = validate_input_response("hello world", {"maxLength": 5})
assert is_valid is False
assert "maximum length" in error.lower()
def test_min_value_valid(self):
"""Test min validation for numbers."""
is_valid, error = validate_input_response(10, {"min": 5})
assert is_valid is True
def test_min_value_invalid(self):
"""Test min validation with too small number."""
is_valid, error = validate_input_response(3, {"min": 5})
assert is_valid is False
assert "minimum value" in error.lower()
def test_max_value_valid(self):
"""Test max validation for numbers."""
is_valid, error = validate_input_response(5, {"max": 10})
assert is_valid is True
def test_max_value_invalid(self):
"""Test max validation with too large number."""
is_valid, error = validate_input_response(15, {"max": 10})
assert is_valid is False
assert "maximum value" in error.lower()
def test_pattern_valid(self):
"""Test pattern validation with matching value."""
is_valid, error = validate_input_response("test@example.com", {"pattern": r"^[\w\.-]+@[\w\.-]+\.\w+$"})
assert is_valid is True
def test_pattern_invalid(self):
"""Test pattern validation with non-matching value."""
is_valid, error = validate_input_response("not-an-email", {"pattern": r"^[\w\.-]+@[\w\.-]+\.\w+$"})
assert is_valid is False
assert "pattern" in error.lower()
@@ -740,6 +740,90 @@ class TestAgentExecutorsCoverage:
name = executor._get_agent_name(state)
assert name == "LegacyAgent"
async def test_agent_executor_get_agent_name_string_expression(self, mock_context, mock_state):
"""Test agent name extraction from simple string expression."""
from unittest.mock import patch
from agent_framework_declarative._workflows._executors_agents import (
InvokeAzureAgentExecutor,
)
action_def = {
"kind": "InvokeAzureAgent",
"agent": "=Local.SelectedAgent",
}
executor = InvokeAzureAgentExecutor(action_def)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
with patch.object(state, "eval_if_expression", return_value="DynamicAgent"):
name = executor._get_agent_name(state)
assert name == "DynamicAgent"
async def test_agent_executor_get_agent_name_dict_expression(self, mock_context, mock_state):
"""Test agent name extraction from nested dict with expression."""
from unittest.mock import patch
from agent_framework_declarative._workflows._executors_agents import (
InvokeAzureAgentExecutor,
)
action_def = {
"kind": "InvokeAzureAgent",
"agent": {"name": "=Local.ManagerResult.next_speaker.answer"},
}
executor = InvokeAzureAgentExecutor(action_def)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
with patch.object(state, "eval_if_expression", return_value="WeatherAgent"):
name = executor._get_agent_name(state)
assert name == "WeatherAgent"
async def test_agent_executor_get_agent_name_legacy_expression(self, mock_context, mock_state):
"""Test agent name extraction from legacy agentName with expression."""
from unittest.mock import patch
from agent_framework_declarative._workflows._executors_agents import (
InvokeAzureAgentExecutor,
)
action_def = {
"kind": "InvokeAzureAgent",
"agentName": "=Local.NextAgent",
}
executor = InvokeAzureAgentExecutor(action_def)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
with patch.object(state, "eval_if_expression", return_value="ResolvedAgent"):
name = executor._get_agent_name(state)
assert name == "ResolvedAgent"
async def test_agent_executor_get_agent_name_expression_returns_none(self, mock_context, mock_state):
"""Test agent name returns None when expression evaluates to None."""
from unittest.mock import patch
from agent_framework_declarative._workflows._executors_agents import (
InvokeAzureAgentExecutor,
)
action_def = {
"kind": "InvokeAzureAgent",
"agent": {"name": "=Local.UndefinedVar"},
}
executor = InvokeAzureAgentExecutor(action_def)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
with patch.object(state, "eval_if_expression", return_value=None):
name = executor._get_agent_name(state)
assert name is None
async def test_agent_executor_get_input_config_simple(self, mock_context, mock_state):
"""Test input config parsing with simple non-dict input."""
from agent_framework_declarative._workflows._executors_agents import (
@@ -2337,6 +2421,89 @@ class TestBuilderEdgeWiring:
exit_exec = graph_builder._get_branch_exit(None)
assert exit_exec is None
def test_get_branch_exit_returns_none_for_goto_terminator(self):
"""Test that _get_branch_exit returns None when branch ends with GotoAction.
GotoAction is a terminator that handles its own control flow (jumping to
the target action). It should NOT be returned as a branch exit, because
that would cause the parent ConditionGroup to wire it to the next
sequential action, creating a dual-edge where both the goto target and
the next action receive messages.
"""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor
yaml_def = {"name": "test_workflow", "actions": []}
graph_builder = DeclarativeWorkflowBuilder(yaml_def)
# GotoAction executor is a JoinExecutor with a GotoAction action_def
goto_executor = JoinExecutor(
{"kind": "GotoAction", "id": "goto_summary", "actionId": "invoke_summary"},
id="goto_summary",
)
# Simulate a single-action branch chain
goto_executor._chain_executors = [goto_executor] # type: ignore[attr-defined]
exit_exec = graph_builder._get_branch_exit(goto_executor)
assert exit_exec is None
def test_get_branch_exit_returns_none_for_end_workflow_terminator(self):
"""Test that _get_branch_exit returns None when branch ends with EndWorkflow."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor
yaml_def = {"name": "test_workflow", "actions": []}
graph_builder = DeclarativeWorkflowBuilder(yaml_def)
end_executor = JoinExecutor(
{"kind": "EndWorkflow", "id": "end"},
id="end",
)
end_executor._chain_executors = [end_executor] # type: ignore[attr-defined]
exit_exec = graph_builder._get_branch_exit(end_executor)
assert exit_exec is None
def test_get_branch_exit_returns_none_for_goto_in_chain(self):
"""Test that _get_branch_exit returns None when chain ends with GotoAction.
Even when a branch has multiple actions before the GotoAction,
the branch exit should be None because the last action is a terminator.
"""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
from agent_framework_declarative._workflows._executors_basic import SendActivityExecutor
from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor
yaml_def = {"name": "test_workflow", "actions": []}
graph_builder = DeclarativeWorkflowBuilder(yaml_def)
# A branch with: SendActivity -> GotoAction
activity = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "msg"}}, id="msg")
goto = JoinExecutor(
{"kind": "GotoAction", "id": "goto_target", "actionId": "some_target"},
id="goto_target",
)
activity._chain_executors = [activity, goto] # type: ignore[attr-defined]
exit_exec = graph_builder._get_branch_exit(activity)
assert exit_exec is None
def test_get_branch_exit_returns_executor_for_non_terminator(self):
"""Test that _get_branch_exit still returns the exit for non-terminator branches."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
from agent_framework_declarative._workflows._executors_basic import SendActivityExecutor
yaml_def = {"name": "test_workflow", "actions": []}
graph_builder = DeclarativeWorkflowBuilder(yaml_def)
exec1 = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "1"}}, id="e1")
exec2 = SendActivityExecutor({"kind": "SendActivity", "activity": {"text": "2"}}, id="e2")
exec1._chain_executors = [exec1, exec2] # type: ignore[attr-defined]
exit_exec = graph_builder._get_branch_exit(exec1)
assert exit_exec == exec2
# ---------------------------------------------------------------------------
# Agent executor external loop response handler tests
@@ -2702,3 +2869,133 @@ class TestLongMessageTextHandling:
result = state.eval('=!IsBlank(Find("CONGRATULATIONS", Upper(MessageText(Local.Messages))))')
assert result is False
class TestCreateConversationExecutor:
"""Tests for CreateConversationExecutor."""
async def test_basic_creation(self, mock_context, mock_state):
"""Test that a UUID is generated, stored at conversationId path, and conversation entry created."""
from agent_framework_declarative._workflows._executors_basic import (
CreateConversationExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "CreateConversation",
"conversationId": "Local.myConvId",
}
executor = CreateConversationExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
# A UUID should be stored at the requested path
conv_id = state.get("Local.myConvId")
assert conv_id is not None
assert isinstance(conv_id, str)
assert len(conv_id) == 36 # UUID format
# Conversation entry should exist in System.conversations
conversations = state.get("System.conversations")
assert conversations is not None
assert conv_id in conversations
assert conversations[conv_id]["id"] == conv_id
assert conversations[conv_id]["messages"] == []
async def test_no_conversation_id_param(self, mock_context, mock_state):
"""Test that conversation is still created even without a conversationId param."""
from agent_framework_declarative._workflows._executors_basic import (
CreateConversationExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "CreateConversation",
}
executor = CreateConversationExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
# Conversation entry should still exist in System.conversations
# (initialize() seeds one default conversation, plus the one just created)
conversations = state.get("System.conversations")
assert conversations is not None
assert len(conversations) == 2
async def test_multiple_conversations(self, mock_context, mock_state):
"""Test creating multiple conversations produces distinct IDs."""
from agent_framework_declarative._workflows._executors_basic import (
CreateConversationExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def1 = {
"kind": "CreateConversation",
"conversationId": "Local.conv1",
}
action_def2 = {
"kind": "CreateConversation",
"conversationId": "Local.conv2",
}
executor1 = CreateConversationExecutor(action_def1)
await executor1.handle_action(ActionTrigger(), mock_context)
executor2 = CreateConversationExecutor(action_def2)
await executor2.handle_action(ActionTrigger(), mock_context)
conv1 = state.get("Local.conv1")
conv2 = state.get("Local.conv2")
assert conv1 != conv2
# initialize() seeds one default conversation, plus the two just created
conversations = state.get("System.conversations")
assert len(conversations) == 3
assert conv1 in conversations
assert conv2 in conversations
class TestDeclarativeWorkflowStateConversationIdInit:
"""Tests that DeclarativeWorkflowState.initialize() generates a real UUID for ConversationId."""
async def test_conversation_id_is_not_default(self, mock_state):
"""System.ConversationId should be a UUID, not 'default'."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
conv_id = state.get("System.ConversationId")
assert conv_id is not None
assert conv_id != "default"
# Validate it looks like a UUID
import uuid
uuid.UUID(conv_id) # Raises ValueError if not a valid UUID
async def test_conversations_dict_initialized(self, mock_state):
"""System.conversations should contain an entry matching ConversationId."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
conv_id = state.get("System.ConversationId")
conversations = state.get("System.conversations")
assert conversations is not None
assert conv_id in conversations
assert conversations[conv_id]["id"] == conv_id
assert conversations[conv_id]["messages"] == []
async def test_each_initialize_generates_unique_id(self, mock_state):
"""Each call to initialize() should produce a different ConversationId."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
id1 = state.get("System.ConversationId")
state.initialize()
id2 = state.get("System.ConversationId")
assert id1 != id2
@@ -231,49 +231,3 @@ actions:
# Should execute successfully with displayName metadata
assert len(outputs) >= 1
def test_action_context_display_name_property(self):
"""Test that ActionContext provides displayName property."""
from agent_framework_declarative._workflows._handlers import ActionContext
from agent_framework_declarative._workflows._state import WorkflowState
state = WorkflowState()
ctx = ActionContext(
state=state,
action={
"kind": "SetValue",
"id": "test_action",
"displayName": "Test Action Display Name",
"path": "Local.value",
"value": "test",
},
execute_actions=lambda a, s: None,
agents={},
bindings={},
)
assert ctx.action_id == "test_action"
assert ctx.display_name == "Test Action Display Name"
assert ctx.action_kind == "SetValue"
def test_action_context_without_display_name(self):
"""Test ActionContext when displayName is not provided."""
from agent_framework_declarative._workflows._handlers import ActionContext
from agent_framework_declarative._workflows._state import WorkflowState
state = WorkflowState()
ctx = ActionContext(
state=state,
action={
"kind": "SetValue",
"path": "Local.value",
"value": "test",
},
execute_actions=lambda a, s: None,
agents={},
bindings={},
)
assert ctx.action_id is None
assert ctx.display_name is None
assert ctx.action_kind == "SetValue"
@@ -1,553 +0,0 @@
# Copyright (c) Microsoft. All rights reserved.
"""Unit tests for action handlers."""
from collections.abc import AsyncGenerator
from typing import Any
from unittest.mock import AsyncMock, MagicMock
import pytest
# Import handlers to register them
from agent_framework_declarative._workflows import (
_actions_basic, # noqa: F401
_actions_control_flow, # noqa: F401
_actions_error, # noqa: F401
)
from agent_framework_declarative._workflows._handlers import (
ActionContext,
CustomEvent,
TextOutputEvent,
WorkflowEvent,
get_action_handler,
list_action_handlers,
)
from agent_framework_declarative._workflows._state import WorkflowState
def create_action_context(
action: dict[str, Any],
inputs: dict[str, Any] | None = None,
agents: dict[str, Any] | None = None,
bindings: dict[str, Any] | None = None,
run_kwargs: dict[str, Any] | None = None,
) -> ActionContext:
"""Helper to create an ActionContext for testing."""
state = WorkflowState(inputs=inputs or {})
async def execute_actions(
actions: list[dict[str, Any]], state: WorkflowState
) -> AsyncGenerator[WorkflowEvent, None]:
"""Mock execute_actions that runs handlers for nested actions."""
for nested_action in actions:
action_kind = nested_action.get("kind")
handler = get_action_handler(action_kind)
if handler:
ctx = ActionContext(
state=state,
action=nested_action,
execute_actions=execute_actions,
agents=agents or {},
bindings=bindings or {},
run_kwargs=run_kwargs or {},
)
async for event in handler(ctx):
yield event
return ActionContext(
state=state,
action=action,
execute_actions=execute_actions,
agents=agents or {},
bindings=bindings or {},
run_kwargs=run_kwargs or {},
)
class TestActionHandlerRegistry:
"""Tests for action handler registration."""
def test_basic_handlers_registered(self):
"""Test that basic handlers are registered."""
handlers = list_action_handlers()
assert "SetValue" in handlers
assert "AppendValue" in handlers
assert "SendActivity" in handlers
assert "EmitEvent" in handlers
def test_control_flow_handlers_registered(self):
"""Test that control flow handlers are registered."""
handlers = list_action_handlers()
assert "Foreach" in handlers
assert "If" in handlers
assert "Switch" in handlers
assert "RepeatUntil" in handlers
assert "BreakLoop" in handlers
assert "ContinueLoop" in handlers
def test_error_handlers_registered(self):
"""Test that error handlers are registered."""
handlers = list_action_handlers()
assert "ThrowException" in handlers
assert "TryCatch" in handlers
def test_get_unknown_handler_returns_none(self):
"""Test that getting an unknown handler returns None."""
assert get_action_handler("UnknownAction") is None
class TestSetValueHandler:
"""Tests for SetValue action handler."""
@pytest.mark.asyncio
async def test_set_simple_value(self):
"""Test setting a simple value."""
ctx = create_action_context({
"kind": "SetValue",
"path": "Local.result",
"value": "test value",
})
handler = get_action_handler("SetValue")
events = [e async for e in handler(ctx)]
assert len(events) == 0 # SetValue doesn't emit events
assert ctx.state.get("Local.result") == "test value"
@pytest.mark.asyncio
async def test_set_value_from_input(self):
"""Test setting a value from workflow inputs."""
ctx = create_action_context(
{
"kind": "SetValue",
"path": "Local.copy",
"value": "literal",
},
inputs={"original": "from input"},
)
handler = get_action_handler("SetValue")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.copy") == "literal"
class TestAppendValueHandler:
"""Tests for AppendValue action handler."""
@pytest.mark.asyncio
async def test_append_to_new_list(self):
"""Test appending to a non-existent list creates it."""
ctx = create_action_context({
"kind": "AppendValue",
"path": "Local.results",
"value": "item1",
})
handler = get_action_handler("AppendValue")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.results") == ["item1"]
@pytest.mark.asyncio
async def test_append_to_existing_list(self):
"""Test appending to an existing list."""
ctx = create_action_context({
"kind": "AppendValue",
"path": "Local.results",
"value": "item2",
})
ctx.state.set("Local.results", ["item1"])
handler = get_action_handler("AppendValue")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.results") == ["item1", "item2"]
class TestSendActivityHandler:
"""Tests for SendActivity action handler."""
@pytest.mark.asyncio
async def test_send_text_activity(self):
"""Test sending a text activity."""
ctx = create_action_context({
"kind": "SendActivity",
"activity": {
"text": "Hello, world!",
},
})
handler = get_action_handler("SendActivity")
events = [e async for e in handler(ctx)]
assert len(events) == 1
assert isinstance(events[0], TextOutputEvent)
assert events[0].text == "Hello, world!"
class TestEmitEventHandler:
"""Tests for EmitEvent action handler."""
@pytest.mark.asyncio
async def test_emit_custom_event(self):
"""Test emitting a custom event."""
ctx = create_action_context({
"kind": "EmitEvent",
"event": {
"name": "myEvent",
"data": {"key": "value"},
},
})
handler = get_action_handler("EmitEvent")
events = [e async for e in handler(ctx)]
assert len(events) == 1
assert isinstance(events[0], CustomEvent)
assert events[0].name == "myEvent"
assert events[0].data == {"key": "value"}
class TestForeachHandler:
"""Tests for Foreach action handler."""
@pytest.mark.asyncio
async def test_foreach_basic_iteration(self):
"""Test basic foreach iteration."""
ctx = create_action_context({
"kind": "Foreach",
"source": ["a", "b", "c"],
"itemName": "letter",
"actions": [
{
"kind": "AppendValue",
"path": "Local.results",
"value": "processed",
}
],
})
handler = get_action_handler("Foreach")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.results") == ["processed", "processed", "processed"]
@pytest.mark.asyncio
async def test_foreach_sets_item_and_index(self):
"""Test that foreach sets item and index variables."""
ctx = create_action_context({
"kind": "Foreach",
"source": ["x", "y"],
"itemName": "item",
"indexName": "idx",
"actions": [],
})
# We'll check the last values after iteration
handler = get_action_handler("Foreach")
_events = [e async for e in handler(ctx)] # noqa: F841
# After iteration, the last item/index should be set
assert ctx.state.get("Local.item") == "y"
assert ctx.state.get("Local.idx") == 1
class TestIfHandler:
"""Tests for If action handler."""
@pytest.mark.asyncio
async def test_if_true_branch(self):
"""Test that the 'then' branch executes when condition is true."""
ctx = create_action_context({
"kind": "If",
"condition": True,
"then": [
{"kind": "SetValue", "path": "Local.branch", "value": "then"},
],
"else": [
{"kind": "SetValue", "path": "Local.branch", "value": "else"},
],
})
handler = get_action_handler("If")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.branch") == "then"
@pytest.mark.asyncio
async def test_if_false_branch(self):
"""Test that the 'else' branch executes when condition is false."""
ctx = create_action_context({
"kind": "If",
"condition": False,
"then": [
{"kind": "SetValue", "path": "Local.branch", "value": "then"},
],
"else": [
{"kind": "SetValue", "path": "Local.branch", "value": "else"},
],
})
handler = get_action_handler("If")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.branch") == "else"
class TestSwitchHandler:
"""Tests for Switch action handler."""
@pytest.mark.asyncio
async def test_switch_matching_case(self):
"""Test switch with a matching case."""
ctx = create_action_context({
"kind": "Switch",
"value": "option2",
"cases": [
{
"match": "option1",
"actions": [{"kind": "SetValue", "path": "Local.result", "value": "one"}],
},
{
"match": "option2",
"actions": [{"kind": "SetValue", "path": "Local.result", "value": "two"}],
},
],
"default": [{"kind": "SetValue", "path": "Local.result", "value": "default"}],
})
handler = get_action_handler("Switch")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.result") == "two"
@pytest.mark.asyncio
async def test_switch_default_case(self):
"""Test switch falls through to default."""
ctx = create_action_context({
"kind": "Switch",
"value": "unknown",
"cases": [
{
"match": "option1",
"actions": [{"kind": "SetValue", "path": "Local.result", "value": "one"}],
},
],
"default": [{"kind": "SetValue", "path": "Local.result", "value": "default"}],
})
handler = get_action_handler("Switch")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.result") == "default"
class TestRepeatUntilHandler:
"""Tests for RepeatUntil action handler."""
@pytest.mark.asyncio
async def test_repeat_until_condition_met(self):
"""Test repeat until condition becomes true."""
ctx = create_action_context({
"kind": "RepeatUntil",
"condition": False, # Will be evaluated each iteration
"maxIterations": 3,
"actions": [
{"kind": "SetValue", "path": "Local.count", "value": 1},
],
})
# Set up a counter that will cause the loop to exit
ctx.state.set("Local.count", 0)
handler = get_action_handler("RepeatUntil")
_events = [e async for e in handler(ctx)] # noqa: F841
# With condition=False (literal), it will run maxIterations times
assert ctx.state.get("Local.iteration") == 3
class TestTryCatchHandler:
"""Tests for TryCatch action handler."""
@pytest.mark.asyncio
async def test_try_without_error(self):
"""Test try block without errors."""
ctx = create_action_context({
"kind": "TryCatch",
"try": [
{"kind": "SetValue", "path": "Local.result", "value": "success"},
],
"catch": [
{"kind": "SetValue", "path": "Local.result", "value": "caught"},
],
})
handler = get_action_handler("TryCatch")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.result") == "success"
@pytest.mark.asyncio
async def test_try_with_throw_exception(self):
"""Test catching a thrown exception."""
ctx = create_action_context({
"kind": "TryCatch",
"try": [
{"kind": "ThrowException", "message": "Test error", "code": "ERR001"},
],
"catch": [
{"kind": "SetValue", "path": "Local.result", "value": "caught"},
],
})
handler = get_action_handler("TryCatch")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.result") == "caught"
assert ctx.state.get("Local.error.message") == "Test error"
assert ctx.state.get("Local.error.code") == "ERR001"
@pytest.mark.asyncio
async def test_finally_always_executes(self):
"""Test that finally block always executes."""
ctx = create_action_context({
"kind": "TryCatch",
"try": [
{"kind": "SetValue", "path": "Local.try", "value": "ran"},
],
"finally": [
{"kind": "SetValue", "path": "Local.finally", "value": "ran"},
],
})
handler = get_action_handler("TryCatch")
_events = [e async for e in handler(ctx)] # noqa: F841
assert ctx.state.get("Local.try") == "ran"
assert ctx.state.get("Local.finally") == "ran"
class TestActionContextKwargs:
"""ActionContext should carry and forward run_kwargs to agent invocations."""
@pytest.mark.asyncio
async def test_action_context_carries_run_kwargs(self):
"""ActionContext should store and expose run_kwargs."""
ctx = create_action_context(
{"kind": "SetValue", "path": "Local.x", "value": "1"},
run_kwargs={"user_token": "test123"},
)
assert ctx.run_kwargs == {"user_token": "test123"}
@pytest.mark.asyncio
async def test_action_context_defaults_to_empty_kwargs(self):
"""ActionContext.run_kwargs should default to empty dict."""
ctx = create_action_context(
{"kind": "SetValue", "path": "Local.x", "value": "1"},
)
assert ctx.run_kwargs == {}
@pytest.mark.asyncio
async def test_invoke_agent_handler_forwards_kwargs(self):
"""handle_invoke_azure_agent should forward ctx.run_kwargs to agent.run()."""
import agent_framework_declarative._workflows._actions_agents # noqa: F401
mock_response = MagicMock()
mock_response.text = "response"
mock_response.messages = []
mock_response.tool_calls = []
async def non_streaming_run(*args, **kwargs):
if kwargs.get("stream"):
raise TypeError("no streaming")
return mock_response
mock_agent = AsyncMock()
mock_agent.run = AsyncMock(side_effect=non_streaming_run)
test_kwargs = {"user_token": "secret", "api_key": "key123"}
state = WorkflowState()
state.add_conversation_message(MagicMock(role="user", text="hello"))
ctx = create_action_context(
action={
"kind": "InvokeAzureAgent",
"agent": "my_agent",
},
agents={"my_agent": mock_agent},
run_kwargs=test_kwargs,
)
handler = get_action_handler("InvokeAzureAgent")
_ = [e async for e in handler(ctx)]
assert mock_agent.run.call_count >= 1
# Find the non-streaming fallback call
for call in mock_agent.run.call_args_list:
call_kw = call.kwargs
if not call_kw.get("stream"):
assert call_kw.get("user_token") == "secret"
assert call_kw.get("api_key") == "key123"
assert call_kw.get("options") == {"additional_function_arguments": test_kwargs}
break
else:
# All calls were streaming — check the streaming call
call_kw = mock_agent.run.call_args_list[0].kwargs
assert call_kw.get("user_token") == "secret"
assert call_kw.get("api_key") == "key123"
@pytest.mark.asyncio
async def test_invoke_agent_handler_merges_caller_options(self):
"""Caller-provided options in run_kwargs should be merged, not cause TypeError."""
import agent_framework_declarative._workflows._actions_agents # noqa: F401
mock_response = MagicMock()
mock_response.text = "response"
mock_response.messages = []
mock_response.tool_calls = []
async def non_streaming_run(*args, **kwargs):
if kwargs.get("stream"):
raise TypeError("no streaming")
return mock_response
mock_agent = AsyncMock()
mock_agent.run = AsyncMock(side_effect=non_streaming_run)
# Include 'options' in run_kwargs to test merge behavior
test_kwargs = {"user_token": "secret", "options": {"temperature": 0.7}}
state = WorkflowState()
state.add_conversation_message(MagicMock(role="user", text="hello"))
ctx = create_action_context(
action={
"kind": "InvokeAzureAgent",
"agent": "my_agent",
},
agents={"my_agent": mock_agent},
run_kwargs=test_kwargs,
)
handler = get_action_handler("InvokeAzureAgent")
_ = [e async for e in handler(ctx)]
assert mock_agent.run.call_count >= 1
# Find the non-streaming fallback call
for call in mock_agent.run.call_args_list:
call_kw = call.kwargs
if not call_kw.get("stream"):
# Caller options should be merged with additional_function_arguments
assert call_kw["options"]["temperature"] == 0.7
assert "additional_function_arguments" in call_kw["options"]
# Direct kwargs should not include 'options' (no duplicate keyword)
assert call_kw.get("user_token") == "secret"
break
else:
call_kw = mock_agent.run.call_args_list[0].kwargs
assert call_kw["options"]["temperature"] == 0.7
assert "additional_function_arguments" in call_kw["options"]
@@ -216,53 +216,22 @@ class TestHandlerCoverage:
return action_kinds
def test_handlers_exist_for_sample_actions(self, all_action_kinds):
"""Test that handlers exist for all action kinds in samples."""
from agent_framework_declarative._workflows._handlers import list_action_handlers
def test_executors_exist_for_sample_actions(self, all_action_kinds):
"""Test that executors exist for all action kinds used in samples."""
from agent_framework_declarative._workflows._declarative_builder import ALL_ACTION_EXECUTORS
registered_handlers = set(list_action_handlers())
registered_executors = set(ALL_ACTION_EXECUTORS.keys())
# Handlers we expect but may not be in samples
expected_handlers = {
"SetValue",
"SetVariable",
"SetTextVariable",
"SetMultipleVariables",
"ResetVariable",
"ClearAllVariables",
"AppendValue",
"SendActivity",
"EmitEvent",
"Foreach",
"If",
"Switch",
"ConditionGroup",
"GotoAction",
"BreakLoop",
"ContinueLoop",
"RepeatUntil",
"TryCatch",
"ThrowException",
"EndWorkflow",
"EndConversation",
"InvokeAzureAgent",
"InvokePromptAgent",
"CreateConversation",
"AddConversationMessage",
"CopyConversationMessages",
"RetrieveConversationMessages",
"Question",
"RequestExternalInput",
"WaitForInput",
# Kinds handled structurally by the builder (not registered as executors)
structural_kinds = {
"OnConversationStart", # Trigger kind, not an action
"ConditionGroup", # Decomposed into evaluator/join nodes
"GotoAction", # Resolved as graph edges, not executor nodes
"Goto", # Alias for GotoAction
}
# Check that sample action kinds have handlers
missing_handlers = all_action_kinds - registered_handlers - {"OnConversationStart"} # Trigger kind, not action
missing_executors = all_action_kinds - registered_executors - structural_kinds
if missing_handlers:
# Informational, not a failure, as some actions may be future work
pass
# Check that we have handlers for the expected core set
core_handlers = registered_handlers & expected_handlers
assert len(core_handlers) > 10, "Expected more core handlers to be registered"
assert not missing_executors, (
f"Missing executors for action kinds used in workflow samples: {sorted(missing_executors)}"
)
@@ -223,3 +223,33 @@ class TestWorkflowStateResetTurn:
assert state.get("Workflow.Inputs.query") == "test"
assert state.get("Workflow.Outputs.result") == "done"
class TestWorkflowStateConversationIdInit:
"""Tests that WorkflowState generates a real UUID for System.ConversationId."""
def test_conversation_id_is_not_default(self):
"""System.ConversationId should be a UUID, not 'default'."""
import uuid
state = WorkflowState()
conv_id = state.get("System.ConversationId")
assert conv_id is not None
assert conv_id != "default"
uuid.UUID(conv_id) # Raises ValueError if not a valid UUID
def test_conversations_dict_initialized(self):
"""System.conversations should contain an entry matching ConversationId."""
state = WorkflowState()
conv_id = state.get("System.ConversationId")
conversations = state.get("System.conversations")
assert conversations is not None
assert conv_id in conversations
assert conversations[conv_id]["id"] == conv_id
assert conversations[conv_id]["messages"] == []
def test_each_instance_generates_unique_id(self):
"""Each WorkflowState instance should have a different ConversationId."""
state1 = WorkflowState()
state2 = WorkflowState()
assert state1.get("System.ConversationId") != state2.get("System.ConversationId")