mirror of
https://github.com/microsoft/agent-framework.git
synced 2026-06-16 21:04:09 +08:00
Python: Add declarative workflow runtime (#2815)
* Further support for declarative python workflows * Add tests. Clean up for typing and formatting * Improvements and cleanup * Typing cleanup. Improve docstrings * Proper code in docstrings * Fix malformed code-block directive in docstring * Remove dead links * PR feedback * Address PR feedback * Address PR feedback * Remove sl * Update devui frontend * More cleanup * Fix uv lock * Skip Py 3.14 tests as powerfx doesn't support it * Fix mypy error * Fix for tool calls * Removed stale docstring * Fix lint * Standardize on .NET namespaces. Revert DevUI changes (bring in later) * Implement remaining items for Python declarative support to match dotnet
This commit is contained in:
@@ -0,0 +1,348 @@
|
||||
# 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"
|
||||
Reference in New Issue
Block a user