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 42aec8c49bb..b586b086692 100644 --- a/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py +++ b/python/packages/declarative/agent_framework_declarative/_workflows/_declarative_base.py @@ -734,14 +734,14 @@ def _eval_custom_function(self, formula: str) -> Any | None: evaluated_args: list[str] = [] for arg in args: arg = arg.strip() - if arg.startswith('"') and arg.endswith('"'): + if re.fullmatch(r'"(?:[^"]|"")*"', arg): # String literal - evaluated_args.append(arg[1:-1]) + evaluated_args.append(arg[1:-1].replace('""', '"')) elif arg.startswith("'") and arg.endswith("'"): # Single-quoted string literal evaluated_args.append(arg[1:-1]) else: - # Variable reference - evaluate it + # Variable reference or expression - evaluate it result = self.eval(f"={arg}") evaluated_args.append(str(result) if result is not None else "") return "".join(evaluated_args) diff --git a/python/packages/declarative/tests/test_graph_executors.py b/python/packages/declarative/tests/test_graph_executors.py index c6b451e6f5a..f7e0edd252c 100644 --- a/python/packages/declarative/tests/test_graph_executors.py +++ b/python/packages/declarative/tests/test_graph_executors.py @@ -1810,6 +1810,29 @@ def test_recovered_outer_array_wins_over_nested_object(self): class TestPowerFxConditionalImport: """The _declarative_base module should be importable without dotnet/powerfx.""" + @pytest.mark.parametrize("function", ["Concat", "Concatenate"]) + @pytest.mark.parametrize( + ("arguments", "expected"), + [ + ('"hello", " world"', "hello world"), + ('"", ""', ""), + ('"a""b", "c"', 'a"bc'), + ('"a,b(c)", "d"', "a,b(c)d"), + ("'legacy', ' strings'", "legacy strings"), + ], + ) + def test_concatenation_literals_without_engine( + self, monkeypatch: pytest.MonkeyPatch, function: str, arguments: str, expected: str + ) -> None: + """String-only concatenation retains its engine-independent fast path.""" + import agent_framework_declarative._workflows._declarative_base as base_mod + + state = DeclarativeWorkflowState(State()) + state.initialize() + monkeypatch.setattr(base_mod, "Engine", None) + + assert state.eval(f"={function}({arguments})") == expected + def test_import_guard_exists(self): """The powerfx import must be wrapped in try/except.""" import agent_framework_declarative._workflows._declarative_base as base_mod diff --git a/python/packages/declarative/tests/test_graph_workflow_integration.py b/python/packages/declarative/tests/test_graph_workflow_integration.py index 830570ae0c9..87246b01fc7 100644 --- a/python/packages/declarative/tests/test_graph_workflow_integration.py +++ b/python/packages/declarative/tests/test_graph_workflow_integration.py @@ -197,6 +197,47 @@ async def test_workflow_with_condition_group(self): class TestWorkflowFactory: """Tests for WorkflowFactory.""" + @pytest.mark.parametrize("function", ["Concat", "Concatenate"]) + @pytest.mark.parametrize( + ("arguments", "expected"), + [ + ('"hello", " world"', "hello world"), + ('"", ""', ""), + ('"a""b", "c"', 'a"bc'), + ('"""", """"', '""'), + ('"a" & "b", "c"', "abc"), + ('"a" & Local.word & "b", "c"', "aWORDbc"), + ('"a,b(c)", "d"', "a,b(c)d"), + ('"a"",b(c)", "d"', 'a",b(c)d'), + ('"路径", "与文本"', "路径与文本"), + ('"a\\b", "c"', "a\\bc"), + ('Local.word, "!"', "WORD!"), + ('Concatenate("a""b", "c"), "d"', 'a"bcd'), + ('MessageText(Local.messages), "!"', "hello!"), + ('Local.missing, "!"', "!"), + ], + ) + async def test_concatenation_arguments(self, function: str, arguments: str, expected: str) -> None: + """Render literals and expressions through factory-created workflow state.""" + workflow = WorkflowFactory().create_workflow_from_definition({ + "name": "concatenation_arguments", + "actions": [ + {"kind": "SetValue", "id": "set_word", "path": "Local.word", "value": "WORD"}, + { + "kind": "SetValue", + "id": "set_messages", + "path": "Local.messages", + "value": [{"role": "user", "text": "hello"}], + }, + {"kind": "SendActivity", "id": "send", "activity": {"text": f"={function}({arguments})"}}, + ], + }) + + events = await workflow.run({}) + + # SendActivity omits empty rendered activities. + assert events.get_outputs() == ([expected] if expected else []) + def test_factory_creates_workflow(self): """Test creating workflow.""" factory = WorkflowFactory()