diff --git a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py index 7ce1d0f089..2646ae191d 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py @@ -224,6 +224,13 @@ class SendActivityExecutor(DeclarativeActionExecutor): """Executor for the SendActivity action. Sends a text message or activity as workflow output. + Authored text starting with ``=`` is evaluated and its result is emitted as data. + Other authored text supports ``{Variable.Path}`` template interpolation. + + Expression results are not interpolated again. To migrate text that relied on + a second pass, author the template directly (``Hello, {Local.name}!``) or build + the final text in the expression (``="Hello, " & Local.name & "!"``). + Both forms work as a string activity or as a mapping's ``text`` field. """ @handler @@ -244,11 +251,7 @@ async def handle_action( text = activity if isinstance(text, str): - # First evaluate any =expression syntax - text = state.eval_if_expression(text) - # Then interpolate any {Variable.Path} template syntax - if isinstance(text, str): - text = state.interpolate_string(text) + text = state.eval_if_expression(text) if text.startswith("=") else state.interpolate_string(text) # Yield the text as workflow output if text: diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index 2d0b5cae2b..a2f5fd89a4 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -556,8 +556,20 @@ async def test_clear_all_variables_executor(self, mock_context, mock_state): assert state.get("Local.a") is None assert state.get("Local.b") is None - async def test_send_activity_with_dict_activity(self, mock_context, mock_state): - """Test SendActivityExecutor with dict activity containing text field.""" + @_requires_powerfx + @pytest.mark.parametrize( + "activity", + [ + "Hello, {Local.name}!", + {"text": "Hello, {Local.name}!"}, + '="Hello, " & Local.name & "!"', + {"text": '="Hello, " & Local.name & "!"'}, + ], + ) + async def test_send_activity_with_authored_greeting( + self, mock_context: MagicMock, mock_state: MagicMock, activity: str | dict[str, str] + ) -> None: + """Authored templates and explicit expressions support the same greeting.""" from agent_framework_declarative._workflows._executors_basic import ( SendActivityExecutor, ) @@ -568,12 +580,12 @@ async def test_send_activity_with_dict_activity(self, mock_context, mock_state): action_def = { "kind": "SendActivity", - "activity": {"text": "Hello, {Local.name}!"}, + "activity": activity, } executor = SendActivityExecutor(action_def) await executor.handle_action(ActionTrigger(), mock_context) - mock_context.yield_output.assert_called_once_with("Hello, Alice!") + mock_context.yield_output.assert_awaited_once_with("Hello, Alice!") async def test_send_activity_with_string_activity(self, mock_context, mock_state): """Test SendActivityExecutor with string activity.""" @@ -613,6 +625,27 @@ async def test_send_activity_with_expression(self, mock_context, mock_state): mock_context.yield_output.assert_called_once_with("Dynamic message") + @_requires_powerfx + @pytest.mark.parametrize("activity", ["=Local.msg", {"text": "=Local.msg"}]) + async def test_send_activity_preserves_expression_result( + self, mock_context: MagicMock, mock_state: MagicMock, activity: str | dict[str, str] + ) -> None: + """Expression results are output data, not authored templates.""" + from agent_framework_declarative._workflows._executors_basic import ( + SendActivityExecutor, + ) + + state = DeclarativeWorkflowState(mock_state) + state.initialize() + message = "Keep {Local.marker} as text." + state.set("Local.msg", message) + state.set("Local.marker", "fixture value") + + executor = SendActivityExecutor({"kind": "SendActivity", "activity": activity}) + await executor.handle_action(ActionTrigger(), mock_context) + + mock_context.yield_output.assert_awaited_once_with(message) + # --------------------------------------------------------------------------- # Agent Executors Tests - Covering _executors_agents.py gaps