From e52b5c7473b59a9ddf270d07b15f45400d3e74f3 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 18 Sep 2026 12:17:14 +0200 Subject: [PATCH 1/4] Python: preserve expression results in SendActivity output Emit evaluated expressions as data while retaining authored-template interpolation. Add regression coverage for string and mapping activity forms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- .../_workflows/_executors_basic.py | 8 +++---- .../declarative/tests/test_graph_coverage.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) 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 7ce1d0f0899..7b9ea94d19f 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,8 @@ 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. """ @handler @@ -244,11 +246,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 2d0b5cae2b1..e0acb7bf5c4 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -613,6 +613,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 From d4a5af0716a4a4005b75fb4e831573a5436e6d9f Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 18 Sep 2026 12:17:14 +0200 Subject: [PATCH 2/4] Python: preserve expression results in SendActivity output Emit evaluated expressions as data while retaining authored-template interpolation. Add regression coverage for string and mapping activity forms. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> (cherry picked from commit e52b5c7473b59a9ddf270d07b15f45400d3e74f3) --- .../_workflows/_executors_basic.py | 8 +++---- .../declarative/tests/test_graph_coverage.py | 21 +++++++++++++++++++ 2 files changed, 24 insertions(+), 5 deletions(-) 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 7ce1d0f0899..7b9ea94d19f 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,8 @@ 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. """ @handler @@ -244,11 +246,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 2d0b5cae2b1..e0acb7bf5c4 100644 --- a/python/packages/declarative/tests/test_graph_coverage.py +++ b/python/packages/declarative/tests/test_graph_coverage.py @@ -613,6 +613,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 From 7c682a0051de18ae4d1a871fec9f837c284202fc Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 18 Sep 2026 14:12:38 +0200 Subject: [PATCH 3/4] Python: document and test SendActivity output migration Cover authored templates and explicit expressions in both activity forms, and document the intentional single-pass expression output contract. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/packages/declarative/README.md | 26 +++++++++++++++++++ .../declarative/tests/test_graph_coverage.py | 20 +++++++++++--- 2 files changed, 42 insertions(+), 4 deletions(-) diff --git a/python/packages/declarative/README.md b/python/packages/declarative/README.md index 42a2a2bc305..f70f479baa3 100644 --- a/python/packages/declarative/README.md +++ b/python/packages/declarative/README.md @@ -21,6 +21,32 @@ This package ships at two different stability levels: The declarative packages provides support for building agents based on a declarative yaml specification. +## SendActivity expression output + +**Breaking change:** `SendActivity` no longer applies template interpolation to +the result of an expression. Authored text starting with `=` is evaluated once +and its result is emitted as data. For example, if `Local.message` contains +`Hello, {Local.name}!`, `activity: =Local.message` now outputs those braces +literally. There is no second variable lookup in the returned text. + +If a workflow relied on that second pass, move the template into the activity +definition or construct the final text in the expression. Given `Local.name` +set to `Alice`, either of these activities emits `Hello, Alice!`: + +```yaml +- kind: SendActivity + activity: "Hello, {Local.name}!" +- kind: SendActivity + activity: + text: '="Hello, " & Local.name & "!"' +``` + +Both string activities and mappings with a `text` field support either form. +Existing directly authored templates retain their variable-resolution and +missing-value behavior. Expression recognition still requires `=` to be the +first character; leading whitespace is not removed. Non-string output conversion +and suppression of falsey results are unchanged. + ## HTTP request client ownership and cookies **Breaking change:** The HTTP client created by `DefaultHttpRequestHandler` no longer diff --git a/python/packages/declarative/tests/test_graph_coverage.py b/python/packages/declarative/tests/test_graph_coverage.py index e0acb7bf5c4..a2f5fd89a49 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.""" From a90ed70edd4332e565683bd4296d2432955c2cc7 Mon Sep 17 00:00:00 2001 From: Jose Alvarez Date: Fri, 18 Sep 2026 15:07:41 +0200 Subject: [PATCH 4/4] Python: keep SendActivity guidance with the executor Move the concise migration examples into the class documentation and remove the detailed package README section. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --- python/packages/declarative/README.md | 26 ------------------- .../_workflows/_executors_basic.py | 5 ++++ 2 files changed, 5 insertions(+), 26 deletions(-) diff --git a/python/packages/declarative/README.md b/python/packages/declarative/README.md index f70f479baa3..42a2a2bc305 100644 --- a/python/packages/declarative/README.md +++ b/python/packages/declarative/README.md @@ -21,32 +21,6 @@ This package ships at two different stability levels: The declarative packages provides support for building agents based on a declarative yaml specification. -## SendActivity expression output - -**Breaking change:** `SendActivity` no longer applies template interpolation to -the result of an expression. Authored text starting with `=` is evaluated once -and its result is emitted as data. For example, if `Local.message` contains -`Hello, {Local.name}!`, `activity: =Local.message` now outputs those braces -literally. There is no second variable lookup in the returned text. - -If a workflow relied on that second pass, move the template into the activity -definition or construct the final text in the expression. Given `Local.name` -set to `Alice`, either of these activities emits `Hello, Alice!`: - -```yaml -- kind: SendActivity - activity: "Hello, {Local.name}!" -- kind: SendActivity - activity: - text: '="Hello, " & Local.name & "!"' -``` - -Both string activities and mappings with a `text` field support either form. -Existing directly authored templates retain their variable-resolution and -missing-value behavior. Expression recognition still requires `=` to be the -first character; leading whitespace is not removed. Non-string output conversion -and suppression of falsey results are unchanged. - ## HTTP request client ownership and cookies **Breaking change:** The HTTP client created by `DefaultHttpRequestHandler` no longer 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 7b9ea94d19f..2646ae191de 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_executors_basic.py @@ -226,6 +226,11 @@ class SendActivityExecutor(DeclarativeActionExecutor): 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