From f304064ca6bc8c03244dfef24d52769807db708b Mon Sep 17 00:00:00 2001 From: Xiaoping Liao <106010272+kyletser@users.noreply.github.com> Date: Mon, 24 Aug 2026 12:53:28 +0800 Subject: [PATCH] Fix declarative DevUI message input --- .../_workflows/_declarative_base.py | 10 ++-- .../_workflows/_executors_control_flow.py | 1 + .../declarative/tests/test_graph_coverage.py | 14 ++++++ .../tests/test_workflow_factory.py | 46 +++++++++++++++++++ .../devui/workflow_declarative/workflow.yaml | 34 ++++++-------- 5 files changed, 82 insertions(+), 23 deletions(-) diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py index f4567578853..da6c416e633 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -1084,7 +1084,7 @@ async def _ensure_state_initialized( Follows .NET's DefaultTransform pattern - accepts any input type: - dict/Mapping: Used directly as workflow.inputs - str: Converted to {"input": value} - - list[Message]: Treated as the agent-facing message contract + - Message or list[Message]: Treated as the agent-facing message contract (e.g. from WorkflowAgent / as_agent()). The prior conversation history is stored in ``Conversation.messages``/ ``Conversation.history`` and mirrored to @@ -1113,9 +1113,11 @@ async def _ensure_state_initialized( if isinstance(trigger, dict): # Structured inputs - use directly state.initialize(trigger) # type: ignore - elif isinstance(trigger, list) and all(isinstance(m, Message) for m in trigger): # pyright: ignore[reportUnknownVariableType] - # list[Message] (e.g. from WorkflowAgent / as_agent()). - messages_list = cast(list[Message], trigger) + elif isinstance(trigger, Message) or ( + isinstance(trigger, list) and all(isinstance(m, Message) for m in trigger) # pyright: ignore[reportUnknownVariableType] + ): + # Message (e.g. from DevUI) or list[Message] (e.g. from WorkflowAgent / as_agent()). + messages_list = [trigger] if isinstance(trigger, Message) else cast(list[Message], trigger) # Detect continuation: if the workflow's shared state already # carries declarative data from a prior turn (because the host diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py index 6aca5682e75..5db554574f4 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_control_flow.py @@ -400,6 +400,7 @@ async def handle_action( self, trigger: dict[str, Any] | str + | Message | list[Message] | ActionTrigger | ActionComplete diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index ec83d94a1aa..90a72c0c3da 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -9,6 +9,7 @@ from unittest.mock import AsyncMock, MagicMock import pytest +from agent_framework import Message from agent_framework_declarative._workflows import ( ActionComplete, @@ -1522,6 +1523,19 @@ async def test_ensure_state_initialized_with_string_input(self, mock_context, mo inputs = state.get("Workflow.Inputs") assert inputs == {"input": "string trigger"} + async def test_ensure_state_initialized_with_message_input(self, mock_context, mock_state): + """Test _ensure_state_initialized with a single Message input.""" + from agent_framework_declarative._workflows._executors_control_flow import JoinExecutor + + executor = JoinExecutor({"kind": "Entry"}) + message = Message(role="user", contents=["message trigger"], message_id="message-1") + await executor.handle_action(message, mock_context) + + state = DeclarativeWorkflowState(mock_state) + assert state.get("Workflow.Inputs") == {"input": "message trigger"} + assert state.get("System.LastMessage") == {"Text": "message trigger", "Id": "message-1"} + assert state.get("System.LastMessageText") == "message trigger" + async def test_ensure_state_initialized_with_custom_object(self, mock_context, mock_state): """Test _ensure_state_initialized with custom object converts to string.""" from agent_framework_declarative._workflows._executors_basic import ( diff --git a/python/packages/declarative/tests/test_workflow_factory.py b/python/packages/declarative/tests/test_workflow_factory.py index c40633dae1b..5f31f7099cb 100644 --- a/python/packages/declarative/tests/test_workflow_factory.py +++ b/python/packages/declarative/tests/test_workflow_factory.py @@ -2,10 +2,12 @@ """Unit tests for WorkflowFactory.""" +from pathlib import Path from typing import Any, cast from unittest.mock import patch import pytest +from agent_framework import Message from agent_framework_declarative._feature_usage import FeatureIndex from agent_framework_declarative._workflows._errors import DeclarativeWorkflowError @@ -87,6 +89,50 @@ def test_valid_workflow_marks_declarative_workflow_used(self): mark_feature_used.assert_called_once_with(FeatureIndex.DECLARATIVE_WORKFLOW) +class TestWorkflowFactoryMessageInput: + """Tests for declarative workflows started with a single Message.""" + + async def test_entry_join_executor_initializes_workflow_inputs_message(self): + """Regression test for #7285: Entry JoinExecutor must accept a single Message input.""" + from agent_framework_declarative._workflows._declarative_base import DECLARATIVE_STATE_KEY + + factory = WorkflowFactory() + workflow = factory.create_workflow_from_yaml(""" +name: entry-message-inputs-test +actions: + - kind: SendActivity + activity: + text: received +""") + + result = await workflow.run(Message(role="user", contents=["25"], message_id="message-25")) + outputs = result.get_outputs() + assert any("received" in str(output) for output in outputs) + + state_data = workflow._runner.state.get(DECLARATIVE_STATE_KEY) + assert isinstance(state_data, dict) + assert state_data["Inputs"]["input"] == "25" + assert state_data["System"]["LastMessage"] == {"Text": "25", "Id": "message-25"} + assert state_data["System"]["LastMessageText"] == "25" + + @pytest.mark.parametrize( + ("age", "category"), + [(8, "child"), (16, "teenager"), (25, "adult"), (70, "senior")], + ) + @_requires_powerfx + async def test_devui_declarative_workflow_categorizes_message_input(self, age: int, category: str): + """Regression test for #7285: The DevUI sample must categorize chat message input by age.""" + workflow_path = ( + Path(__file__).parents[3] / "samples" / "02-agents" / "devui" / "workflow_declarative" / "workflow.yaml" + ) + workflow = WorkflowFactory().create_workflow_from_yaml_path(workflow_path) + + result = await workflow.run(Message(role="user", contents=[str(age)])) + outputs = result.get_outputs() + + assert any(f"categorized as: {category}" in str(output) for output in outputs) + + @_requires_powerfx class TestWorkflowFactoryExecution: """Tests for workflow execution.""" diff --git a/python/samples/02-agents/devui/workflow_declarative/workflow.yaml b/python/samples/02-agents/devui/workflow_declarative/workflow.yaml index 947f1688389..2ac3d2dcf49 100644 --- a/python/samples/02-agents/devui/workflow_declarative/workflow.yaml +++ b/python/samples/02-agents/devui/workflow_declarative/workflow.yaml @@ -1,52 +1,48 @@ name: conditional-workflow -description: Demonstrates conditional branching based on user input - -inputs: - age: - type: integer - description: The user's age in years +description: Demonstrates conditional branching based on age entered in DevUI chat actions: - - kind: SetValue + - kind: ParseValue id: get_age displayName: Get user age - path: turn.age - value: =inputs.age + variable: Local.age + value: =System.LastMessage.Text + valueType: number - kind: If id: check_age displayName: Check age category - condition: =turn.age < 13 + condition: =Local.age < 13 then: - kind: SetValue - path: turn.category + path: Local.category value: child - kind: SendActivity activity: text: "Welcome, young one! Here are some fun activities for kids." else: - kind: If - condition: =turn.age < 20 + condition: =Local.age < 20 then: - kind: SetValue - path: turn.category + path: Local.category value: teenager - kind: SendActivity activity: text: "Hey there! Check out these cool things for teens." else: - kind: If - condition: =turn.age < 65 + condition: =Local.age < 65 then: - kind: SetValue - path: turn.category + path: Local.category value: adult - kind: SendActivity activity: text: "Welcome! Here are our professional services." else: - kind: SetValue - path: turn.category + path: Local.category value: senior - kind: SendActivity activity: @@ -56,9 +52,9 @@ actions: id: summary displayName: Send category summary activity: - text: '=Concat("You have been categorized as: ", turn.category)' + text: '=Concat("You have been categorized as: ", Local.category)' - kind: SetValue id: set_output - path: workflow.outputs.category - value: =turn.category + path: Workflow.Outputs.category + value: =Local.category