mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
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:
committed by
GitHub
Unverified
parent
bb4fe48c9a
commit
de612c47f5
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user