Python: [Breaking] Remove Python-only declarative actions and rename alias kinds to C# canonical names (#6126)

* Remove Python-only declarative actions and rename alias kinds to C# canonical names

* Address PR comments.

* Address PR comments.

* Reduce verbose and duplicate output from sample workflow.
This commit is contained in:
Peter Ibekwe
2026-05-28 03:16:22 -07:00
committed by GitHub
Unverified
parent 55dc3ce734
commit ded17b178c
15 changed files with 577 additions and 771 deletions
@@ -515,27 +515,6 @@ class TestBasicExecutorsCoverage:
assert state.get("Local.b") == 2
assert state.get("Local.c") == 3
async def test_append_value_executor(self, mock_context, mock_state):
"""Test AppendValueExecutor."""
from agent_framework_declarative._workflows._executors_basic import (
AppendValueExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.items", ["a"])
action_def = {
"kind": "AppendValue",
"path": "Local.items",
"value": "b",
}
executor = AppendValueExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
result = state.get("Local.items")
assert result == ["a", "b"]
async def test_reset_variable_executor(self, mock_context, mock_state):
"""Test ResetVariableExecutor."""
from agent_framework_declarative._workflows._executors_basic import (
@@ -632,52 +611,6 @@ class TestBasicExecutorsCoverage:
mock_context.yield_output.assert_called_once_with("Dynamic message")
async def test_emit_event_executor_graph_mode(self, mock_context, mock_state):
"""Test EmitEventExecutor with graph-mode schema (eventName/eventValue)."""
from agent_framework_declarative._workflows._executors_basic import (
EmitEventExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "EmitEvent",
"eventName": "myEvent",
"eventValue": {"key": "value"},
}
executor = EmitEventExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.yield_output.assert_called_once()
event_data = mock_context.yield_output.call_args[0][0]
assert event_data["eventName"] == "myEvent"
assert event_data["eventValue"] == {"key": "value"}
async def test_emit_event_executor_interpreter_mode(self, mock_context, mock_state):
"""Test EmitEventExecutor with interpreter-mode schema (event.name/event.data)."""
from agent_framework_declarative._workflows._executors_basic import (
EmitEventExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "EmitEvent",
"event": {
"name": "interpreterEvent",
"data": {"payload": "test"},
},
}
executor = EmitEventExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.yield_output.assert_called_once()
event_data = mock_context.yield_output.call_args[0][0]
assert event_data["eventName"] == "interpreterEvent"
assert event_data["eventValue"] == {"payload": "test"}
# ---------------------------------------------------------------------------
# Agent Executors Tests - Covering _executors_agents.py gaps
@@ -1155,8 +1088,8 @@ class TestControlFlowCoverage:
"""Tests for control flow executors covering uncovered code paths."""
@_requires_powerfx
async def test_foreach_with_source_alias(self, mock_context, mock_state):
"""Test ForeachInitExecutor with 'source' alias (interpreter mode)."""
async def test_foreach_with_source(self, mock_context, mock_state):
"""Test ForeachInitExecutor with the 'source' field."""
from agent_framework_declarative._workflows._executors_control_flow import (
ForeachInitExecutor,
)
@@ -1205,8 +1138,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="foreach_init")
@@ -1217,81 +1150,6 @@ class TestControlFlowCoverage:
assert msg.current_index == 1
assert msg.current_item == "b"
@_requires_powerfx
async def test_switch_evaluator_with_value_cases(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor with value/cases schema."""
from agent_framework_declarative._workflows._executors_control_flow import (
SwitchEvaluatorExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.status", "pending")
action_def = {
"kind": "Switch",
"value": "=Local.status",
}
cases = [
{"match": "active"},
{"match": "pending"},
]
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
await executor.handle_action(ActionTrigger(), mock_context)
msg = mock_context.send_message.call_args[0][0]
assert isinstance(msg, ConditionResult)
assert msg.matched is True
assert msg.branch_index == 1 # Second case matched
@_requires_powerfx
async def test_switch_evaluator_default_case(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor falls through to default."""
from agent_framework_declarative._workflows._executors_control_flow import (
SwitchEvaluatorExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.status", "unknown")
action_def = {
"kind": "Switch",
"value": "=Local.status",
}
cases = [
{"match": "active"},
{"match": "pending"},
]
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
await executor.handle_action(ActionTrigger(), mock_context)
msg = mock_context.send_message.call_args[0][0]
assert isinstance(msg, ConditionResult)
assert msg.matched is False
assert msg.branch_index == -1 # Default case
async def test_switch_evaluator_no_value(self, mock_context, mock_state):
"""Test SwitchEvaluatorExecutor with no value defaults to else."""
from agent_framework_declarative._workflows._executors_control_flow import (
SwitchEvaluatorExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {"kind": "Switch"} # No value
cases = [{"match": "x"}]
executor = SwitchEvaluatorExecutor(action_def, cases=cases)
await executor.handle_action(ActionTrigger(), mock_context)
msg = mock_context.send_message.call_args[0][0]
assert isinstance(msg, ConditionResult)
assert msg.branch_index == -1
async def test_join_executor_accepts_condition_result(self, mock_context, mock_state):
"""Test JoinExecutor accepts ConditionResult as trigger."""
from agent_framework_declarative._workflows._executors_control_flow import (
@@ -1357,8 +1215,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="missing_loop")
@@ -1391,8 +1249,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
@@ -1425,8 +1283,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
@@ -1459,8 +1317,8 @@ class TestControlFlowCoverage:
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.data",
"iteratorVariable": "Local.item",
"source": "=Local.data",
"itemName": "item",
}
executor = ForeachNextExecutor(action_def, init_executor_id="loop_id")
@@ -1719,60 +1577,6 @@ class TestDeclarativeActionExecutorBase:
class TestHumanInputExecutorsCoverage:
"""Tests for human input executors covering uncovered code paths."""
async def test_wait_for_input_executor_with_prompt(self, mock_context, mock_state):
"""Test WaitForInputExecutor with prompt."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
WaitForInputExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "WaitForInput",
"prompt": "Please enter your name:",
"property": "Local.userName",
"timeout": 30,
}
executor = WaitForInputExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
# Should yield prompt first, then call request_info
assert mock_context.yield_output.call_count == 1
assert mock_context.yield_output.call_args_list[0][0][0] == "Please enter your name:"
# request_info call for ExternalInputRequest
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.request_type == "user_input"
async def test_wait_for_input_executor_no_prompt(self, mock_context, mock_state):
"""Test WaitForInputExecutor without prompt."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
WaitForInputExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "WaitForInput",
"property": "Local.input",
}
executor = WaitForInputExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
# Should not yield output (no prompt), just call request_info
assert mock_context.yield_output.call_count == 0
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.request_type == "user_input"
async def test_request_external_input_executor(self, mock_context, mock_state):
"""Test RequestExternalInputExecutor."""
from agent_framework_declarative._workflows._executors_external_input import (
@@ -1786,8 +1590,8 @@ class TestHumanInputExecutorsCoverage:
action_def = {
"kind": "RequestExternalInput",
"requestType": "approval",
"message": "Please approve this request",
"property": "Local.approvalResult",
"prompt": {"text": "Please approve this request"},
"variable": "Local.approvalResult",
"timeout": 3600,
"requiredFields": ["approver", "notes"],
"metadata": {"priority": "high"},
@@ -1817,8 +1621,8 @@ class TestHumanInputExecutorsCoverage:
action_def = {
"kind": "Question",
"question": "Select an option:",
"property": "Local.selection",
"question": {"text": "Select an option:"},
"variable": "Local.selection",
"choices": [
{"value": "a", "label": "Option A"},
{"value": "b"}, # No label, should use value
@@ -1841,6 +1645,111 @@ class TestHumanInputExecutorsCoverage:
assert choices[2] == {"value": "c", "label": "c"}
assert request.metadata["allow_free_text"] is False
async def test_question_executor_reads_nested_question_text(self, mock_context, mock_state):
"""QuestionExecutor reads ``question.text``/``variable``/``default`` into the request."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
QuestionExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "Question",
"question": {"text": "What is your name?"},
"variable": "Local.userName",
"default": "Guest",
}
executor = QuestionExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
# Canonical text comes through as a plain string, not the stringified dict.
assert request.message == "What is your name?"
# Canonical `variable` overrides the legacy default of Local.answer.
assert request.metadata["output_property"] == "Local.userName"
assert request.metadata["default_value"] == "Guest"
async def test_question_executor_reads_top_level_alternates(self, mock_context, mock_state):
"""Top-level ``text``/``property``/``defaultValue`` are accepted as alternates."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
QuestionExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "Question",
"text": "Legacy question",
"property": "Local.legacyAnswer",
"defaultValue": "legacy-default",
}
executor = QuestionExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.message == "Legacy question"
assert request.metadata["output_property"] == "Local.legacyAnswer"
assert request.metadata["default_value"] == "legacy-default"
async def test_request_external_input_reads_nested_prompt_text(self, mock_context, mock_state):
"""RequestExternalInputExecutor reads ``prompt.text``/``variable``/``default``."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
RequestExternalInputExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "RequestExternalInput",
"prompt": {"text": "Please approve"},
"variable": "Local.approved",
"default": "pending",
}
executor = RequestExternalInputExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.message == "Please approve"
assert request.metadata["output_property"] == "Local.approved"
assert request.metadata["default_value"] == "pending"
async def test_request_external_input_reads_top_level_alternates(self, mock_context, mock_state):
"""Top-level ``message``/``property`` are accepted as alternates."""
from agent_framework_declarative._workflows._executors_external_input import (
ExternalInputRequest,
RequestExternalInputExecutor,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "RequestExternalInput",
"message": "Legacy message",
"property": "Local.legacyApproval",
}
executor = RequestExternalInputExecutor(action_def)
await executor.handle_action(ActionTrigger(), mock_context)
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.message == "Legacy message"
assert request.metadata["output_property"] == "Local.legacyApproval"
# ---------------------------------------------------------------------------
# Additional Agent Executor Tests - External Loop Coverage
@@ -2122,7 +2031,7 @@ class TestBuilderControlFlowCreation:
# Create a mock loop_next executor
loop_next = ForeachNextExecutor(
{"kind": "Foreach", "itemsProperty": "items"},
{"kind": "Foreach", "source": "=Local.items"},
init_executor_id="foreach_init",
id="foreach_next",
)
@@ -2181,7 +2090,7 @@ class TestBuilderControlFlowCreation:
# Create a mock loop_next executor
loop_next = ForeachNextExecutor(
{"kind": "Foreach", "itemsProperty": "items"},
{"kind": "Foreach", "source": "=Local.items"},
init_executor_id="foreach_init",
id="foreach_next",
)
@@ -2235,8 +2144,8 @@ class TestBuilderEdgeWiring:
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{"kind": "SendActivity", "id": "step_2", "activity": {"text": "two"}},
@@ -2266,8 +2175,8 @@ class TestBuilderEdgeWiring:
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{"kind": "BreakLoop", "id": "stop"},
@@ -2292,8 +2201,8 @@ class TestBuilderEdgeWiring:
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": "one"}},
{
@@ -2704,7 +2613,7 @@ class TestBuilderValidation:
assert workflow is not None
def test_missing_required_field_foreach(self):
"""Test Foreach without items raises error."""
"""Test Foreach without source raises error."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
@@ -2717,7 +2626,7 @@ class TestBuilderValidation:
builder.build()
assert "Foreach" in str(exc_info.value)
assert "items" in str(exc_info.value)
assert "source" in str(exc_info.value)
def test_self_referencing_goto_raises_error(self):
"""Test that a goto referencing itself is detected."""
@@ -2725,7 +2634,7 @@ class TestBuilderValidation:
yaml_def = {
"name": "test_workflow",
"actions": [{"id": "loop", "kind": "Goto", "target": "loop"}],
"actions": [{"id": "loop", "kind": "GotoAction", "actionId": "loop"}],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
@@ -2757,23 +2666,22 @@ class TestBuilderValidation:
workflow = builder.build()
assert workflow is not None
def test_validation_in_switch_branches(self):
"""Test validation catches issues in Switch branches."""
def test_validation_in_condition_group_branches(self):
"""Test validation catches issues in ConditionGroup branches."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "test_workflow",
"actions": [
{
"kind": "Switch",
"value": "=Local.choice",
"cases": [
"kind": "ConditionGroup",
"conditions": [
{
"match": "a",
"condition": '=Local.choice = "a"',
"actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "A"}}],
},
{
"match": "b",
"condition": '=Local.choice = "b"',
"actions": [{"id": "dup", "kind": "SendActivity", "activity": {"text": "B"}}],
},
],
@@ -2796,7 +2704,7 @@ class TestBuilderValidation:
"actions": [
{
"kind": "Foreach",
"items": "=Local.items",
"source": "=Local.items",
"actions": [{"kind": "SendActivity"}], # Missing 'activity'
}
],
@@ -207,16 +207,16 @@ class TestDeclarativeActionExecutor:
# Note: ConditionEvaluatorExecutor tests removed - conditions are now evaluated on edges
@_requires_powerfx
async def test_foreach_init_with_items(self, mock_context, mock_state):
"""Test ForeachInitExecutor with items."""
async def test_foreach_init_with_source(self, mock_context, mock_state):
"""Test ForeachInitExecutor with the 'source' field."""
state = DeclarativeWorkflowState(mock_state)
state.initialize()
state.set("Local.items", ["a", "b", "c"])
action_def = {
"kind": "Foreach",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
}
executor = ForeachInitExecutor(action_def)
@@ -240,8 +240,8 @@ class TestDeclarativeActionExecutor:
# Use a literal empty list - no expression evaluation needed
action_def = {
"kind": "Foreach",
"itemsSource": [], # Direct empty list, not an expression
"iteratorVariable": "Local.item",
"source": [], # Direct empty list, not an expression
"itemName": "item",
}
executor = ForeachInitExecutor(action_def)
@@ -264,7 +264,6 @@ class TestDeclarativeWorkflowBuilder:
"SetValue",
"SetVariable",
"SendActivity",
"EmitEvent",
"EndWorkflow",
"InvokeAzureAgent",
"Question",
@@ -335,8 +334,8 @@ class TestDeclarativeWorkflowBuilder:
{
"kind": "Foreach",
"id": "process_items",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
],
@@ -353,13 +352,13 @@ class TestDeclarativeWorkflowBuilder:
assert "process_items_exit" in builder._executors
assert "show_item" in builder._executors
def test_build_workflow_with_switch(self):
"""Test building a workflow with Switch control flow."""
def test_build_workflow_with_condition_group(self):
"""Test building a workflow with ConditionGroup control flow."""
yaml_def = {
"name": "switch_workflow",
"name": "condition_group_workflow",
"actions": [
{
"kind": "Switch",
"kind": "ConditionGroup",
"id": "check_status",
"conditions": [
{
@@ -375,7 +374,7 @@ class TestDeclarativeWorkflowBuilder:
],
},
],
"else": [
"elseActions": [
{"kind": "SendActivity", "id": "say_unknown", "activity": {"text": "Unknown"}},
],
},
@@ -385,12 +384,12 @@ class TestDeclarativeWorkflowBuilder:
workflow = builder.build()
assert workflow is not None
# Verify switch executors were created
# Verify ConditionGroup branch executors were created
# Note: No join executors - branches wire directly to successor
assert "say_active" in builder._executors
assert "say_pending" in builder._executors
assert "say_unknown" in builder._executors
# Entry node is created when Switch is first action
# Entry node is created when ConditionGroup is first action
assert "_workflow_entry" in builder._executors
@@ -493,9 +492,9 @@ class TestHumanInputExecutors:
action_def = {
"kind": "Question",
"text": "What is your name?",
"property": "Local.name",
"defaultValue": "Anonymous",
"question": {"text": "What is your name?"},
"variable": "Local.name",
"default": "Anonymous",
}
executor = QuestionExecutor(action_def)
@@ -509,36 +508,6 @@ class TestHumanInputExecutors:
assert request.request_type == "question"
assert "What is your name?" in request.message
@pytest.mark.asyncio
async def test_confirmation_executor(self, mock_context, mock_state):
"""Test ConfirmationExecutor."""
from agent_framework_declarative._workflows import (
ConfirmationExecutor,
ExternalInputRequest,
)
state = DeclarativeWorkflowState(mock_state)
state.initialize()
action_def = {
"kind": "Confirmation",
"text": "Do you want to continue?",
"property": "Local.confirmed",
"yesLabel": "Yes, continue",
"noLabel": "No, stop",
}
executor = ConfirmationExecutor(action_def)
# Execute
await executor.handle_action(ActionTrigger(), mock_context)
# Verify request_info was called with ExternalInputRequest
mock_context.request_info.assert_called_once()
request = mock_context.request_info.call_args[0][0]
assert isinstance(request, ExternalInputRequest)
assert request.request_type == "confirmation"
assert "continue" in request.message.lower()
@_requires_powerfx
class TestParseValueExecutor:
@@ -100,8 +100,8 @@ class TestGraphBasedWorkflowExecution:
{
"kind": "Foreach",
"id": "process_items",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "show_item", "activity": {"text": "=Local.item"}},
],
@@ -131,8 +131,8 @@ class TestGraphBasedWorkflowExecution:
{
"kind": "Foreach",
"id": "loop",
"itemsSource": "=Local.items",
"iteratorVariable": "Local.item",
"source": "=Local.items",
"itemName": "item",
"actions": [
{"kind": "SendActivity", "id": "step_1", "activity": {"text": '="1-" & Local.item'}},
{"kind": "SendActivity", "id": "step_2", "activity": {"text": '="2-" & Local.item'}},
@@ -151,14 +151,14 @@ class TestGraphBasedWorkflowExecution:
assert outputs == ["1-A", "2-A", "3-A", "1-B", "2-B", "3-B"]
@pytest.mark.asyncio
async def test_workflow_with_switch(self):
"""Test workflow with Switch/ConditionGroup."""
async def test_workflow_with_condition_group(self):
"""Test workflow with ConditionGroup."""
yaml_def = {
"name": "switch_workflow",
"name": "condition_group_workflow",
"actions": [
{"kind": "SetValue", "id": "set_level", "path": "Local.level", "value": 2},
{
"kind": "Switch",
"kind": "ConditionGroup",
"id": "check_level",
"conditions": [
{
@@ -174,7 +174,7 @@ class TestGraphBasedWorkflowExecution:
],
},
],
"else": [
"elseActions": [
{"kind": "SendActivity", "id": "default", "activity": {"text": "Other level"}},
],
},
@@ -122,14 +122,16 @@ actions:
- cherry
itemName: fruit
actions:
- kind: AppendValue
path: Local.fruits
value: processed
- kind: SendActivity
activity:
text: processed
""")
_result = await workflow.run({}) # noqa: F841
# The foreach should have processed 3 items
# We can check this by examining the workflow outputs
result = await workflow.run({})
outputs = result.get_outputs()
# The foreach should have processed 3 items, emitting "processed" each time.
processed_outputs = [o for o in outputs if "processed" in str(o)]
assert len(processed_outputs) == 3
@pytest.mark.asyncio
async def test_execute_if_workflow(self):
@@ -556,28 +558,27 @@ actions:
@_requires_powerfx
class TestWorkflowFactorySwitch:
"""Tests for Switch/Case action."""
class TestWorkflowFactoryConditionGroup:
"""Tests for ConditionGroup action."""
@pytest.mark.asyncio
async def test_switch_with_matching_case(self):
"""Test Switch with a matching case."""
async def test_condition_group_with_matching_condition(self):
"""Test ConditionGroup with a matching condition."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: switch-test
name: condition-group-test
actions:
- kind: SetValue
path: Local.color
value: red
- kind: Switch
value: =Local.color
cases:
- match: red
- kind: ConditionGroup
conditions:
- condition: =Local.color = "red"
actions:
- kind: SendActivity
activity:
text: Color is red
- match: blue
- condition: =Local.color = "blue"
actions:
- kind: SendActivity
activity:
@@ -590,29 +591,28 @@ actions:
assert any("Color is red" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_switch_with_default(self):
"""Test Switch falling through to default."""
async def test_condition_group_with_else_actions(self):
"""Test ConditionGroup falling through to elseActions when no condition matches."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: switch-default-test
name: condition-group-else-test
actions:
- kind: SetValue
path: Local.color
value: green
- kind: Switch
value: =Local.color
cases:
- match: red
- kind: ConditionGroup
conditions:
- condition: =Local.color = "red"
actions:
- kind: SendActivity
activity:
text: Red
- match: blue
- condition: =Local.color = "blue"
actions:
- kind: SendActivity
activity:
text: Blue
default:
elseActions:
- kind: SendActivity
activity:
text: Unknown color
@@ -653,54 +653,273 @@ actions:
assert any("Done" in str(o) for o in outputs)
class TestRenamedAliasKindsAreUnknown:
"""Tests that the previously-accepted ``Switch``/``Goto`` kind names are now unknown.
YAML that still names one of these kinds falls through the existing
unknown-kind warning path (the action is silently skipped) instead
of being routed to ``ConditionGroup``/``GotoAction``.
"""
@pytest.mark.asyncio
async def test_append_value(self):
"""Test AppendValue action."""
async def test_switch_kind_is_unknown(self, caplog):
"""A workflow whose YAML uses kind: Switch logs an unknown-kind warning."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: append-test
with caplog.at_level(
"WARNING",
logger="agent_framework_declarative._workflows._declarative_builder",
):
workflow = factory.create_workflow_from_yaml("""
name: switch-alias-removed
actions:
- kind: SetValue
path: Local.list
value: []
- kind: AppendValue
path: Local.list
value: first
- kind: AppendValue
path: Local.list
value: second
- kind: Switch
value: =Local.color
cases:
- match: red
actions:
- kind: SendActivity
activity:
text: Color is red
- kind: SendActivity
activity:
text: Done
""")
result = await workflow.run({})
result = await workflow.run({})
# Switch is no longer a recognised kind -> warning emitted + action skipped.
assert any("Unknown action kind 'Switch'" in record.getMessage() for record in caplog.records)
# The trailing SendActivity still runs so the workflow completes successfully.
outputs = result.get_outputs()
assert any("Done" in str(o) for o in outputs)
@pytest.mark.asyncio
async def test_emit_event(self):
"""Test EmitEvent action."""
async def test_goto_kind_is_unknown(self, caplog):
"""A workflow whose YAML uses kind: Goto logs an unknown-kind warning."""
factory = WorkflowFactory()
with caplog.at_level(
"WARNING",
logger="agent_framework_declarative._workflows._declarative_builder",
):
workflow = factory.create_workflow_from_yaml("""
name: goto-alias-removed
actions:
- id: target
kind: SendActivity
activity:
text: Arrived
- kind: Goto
target: target
""")
result = await workflow.run({})
# Goto is no longer a recognised kind -> warning emitted + action skipped.
assert any("Unknown action kind 'Goto'" in record.getMessage() for record in caplog.records)
# The first SendActivity still emits its output.
outputs = result.get_outputs()
assert any("Arrived" in str(o) for o in outputs)
class TestDroppedShapesAreRejected:
"""Tests that previously-accepted alternate YAML shapes are now rejected at validation.
``ConditionGroup`` no longer accepts the ``value``/``cases`` shape and
``Foreach`` no longer accepts the ``items`` field. Both kinds raise a
``ValueError`` from the builder when the required field is missing.
"""
def test_condition_group_with_cases_raises(self):
"""ConditionGroup using value/cases (no conditions) must fail validation."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "cg-cases-rejected",
"actions": [
{
"kind": "ConditionGroup",
"value": "=Local.color",
"cases": [
{"match": "red", "actions": [{"kind": "SendActivity", "activity": {"text": "Red"}}]},
],
}
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
with pytest.raises(ValueError, match="conditions"):
builder.build()
def test_foreach_with_items_raises(self):
"""Foreach using items (no source) must fail validation."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "fe-items-rejected",
"actions": [
{
"kind": "Foreach",
"items": "=Local.list",
"actions": [{"kind": "SendActivity", "activity": {"text": "hi"}}],
}
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
with pytest.raises(ValueError, match="source"):
builder.build()
def test_condition_group_with_else_field_raises(self):
"""ConditionGroup with an ``else`` field must fail fast and point at ``elseActions``."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "cg-else-rejected",
"actions": [
{
"kind": "ConditionGroup",
"conditions": [
{
"condition": "=Local.x = 1",
"actions": [{"kind": "SendActivity", "activity": {"text": "one"}}],
},
],
"else": [{"kind": "SendActivity", "activity": {"text": "other"}}],
}
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
with pytest.raises(ValueError, match="elseActions"):
builder.build()
def test_condition_group_with_default_field_raises(self):
"""ConditionGroup with a ``default`` field must fail fast and point at ``elseActions``."""
from agent_framework_declarative._workflows._declarative_builder import DeclarativeWorkflowBuilder
yaml_def = {
"name": "cg-default-rejected",
"actions": [
{
"kind": "ConditionGroup",
"conditions": [
{
"condition": "=Local.x = 1",
"actions": [{"kind": "SendActivity", "activity": {"text": "one"}}],
},
],
"default": [{"kind": "SendActivity", "activity": {"text": "other"}}],
}
],
}
builder = DeclarativeWorkflowBuilder(yaml_def)
with pytest.raises(ValueError, match="elseActions"):
builder.build()
class TestQuestionAndRequestExternalInputShapes:
"""Tests for accepted YAML shapes of ``Question`` and ``RequestExternalInput``.
Both kinds accept either a nested ``{question|prompt: {text: ...}}`` form
or a top-level alternate (``text``/``message``) for the prompt content,
and either ``variable`` or top-level ``property`` for the destination path.
Missing both spellings of a required field raises during validation.
"""
def test_question_nested_question_text_builds(self):
"""A workflow whose Question uses nested ``question.text`` builds without error."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: emit-event-test
name: question-nested
actions:
- kind: EmitEvent
event:
name: test_event
data:
message: Hello
- kind: SendActivity
activity:
text: Event emitted
- kind: Question
question:
text: "What is your name?"
variable: Local.userName
default: "Guest"
""")
assert workflow is not None
def test_request_external_input_nested_prompt_text_builds(self):
"""A workflow whose RequestExternalInput uses nested ``prompt.text`` builds without error."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: rei-nested
actions:
- kind: RequestExternalInput
prompt:
text: "Please approve"
variable: Local.approved
default: pending
""")
assert workflow is not None
def test_question_missing_question_raises(self):
"""A Question action missing both `question` and the `text` alternate must fail validation."""
factory = WorkflowFactory()
with pytest.raises((ValueError, DeclarativeWorkflowError), match="question"):
factory.create_workflow_from_yaml("""
name: question-missing-question
actions:
- kind: Question
variable: Local.x
""")
result = await workflow.run({})
outputs = result.get_outputs()
def test_question_missing_variable_raises(self):
"""A Question action missing both `variable` and the `property` alternate must fail validation."""
factory = WorkflowFactory()
with pytest.raises((ValueError, DeclarativeWorkflowError), match="variable"):
factory.create_workflow_from_yaml("""
name: question-missing-variable
actions:
- kind: Question
question:
text: "Hi"
""")
# Workflow should complete
assert any("Event emitted" in str(o) for o in outputs)
def test_request_external_input_missing_prompt_raises(self):
"""RequestExternalInput missing both `prompt` and the `message` alternate must fail validation."""
factory = WorkflowFactory()
with pytest.raises((ValueError, DeclarativeWorkflowError), match="prompt"):
factory.create_workflow_from_yaml("""
name: rei-missing-prompt
actions:
- kind: RequestExternalInput
variable: Local.x
""")
def test_request_external_input_missing_variable_raises(self):
"""RequestExternalInput missing both `variable` and the `property` alternate must fail validation."""
factory = WorkflowFactory()
with pytest.raises((ValueError, DeclarativeWorkflowError), match="variable"):
factory.create_workflow_from_yaml("""
name: rei-missing-variable
actions:
- kind: RequestExternalInput
prompt:
text: "Hi"
""")
def test_question_top_level_field_names_accepted(self):
"""Top-level ``text`` + ``property`` + ``defaultValue`` are accepted on Question."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: question-legacy
actions:
- kind: Question
text: "What is your name?"
property: Local.userName
defaultValue: "Guest"
""")
assert workflow is not None
def test_request_external_input_top_level_field_names_accepted(self):
"""Top-level ``message`` + ``property`` are accepted on RequestExternalInput."""
factory = WorkflowFactory()
workflow = factory.create_workflow_from_yaml("""
name: rei-legacy
actions:
- kind: RequestExternalInput
message: "Please approve"
property: Local.approved
""")
assert workflow is not None
class TestWorkflowFactoryYamlErrors:
@@ -227,7 +227,6 @@ class TestHandlerCoverage:
"OnConversationStart", # Trigger kind, not an action
"ConditionGroup", # Decomposed into evaluator/join nodes
"GotoAction", # Resolved as graph edges, not executor nodes
"Goto", # Alias for GotoAction
}
missing_executors = all_action_kinds - registered_executors - structural_kinds