From 457076dec0915ab409631830dfa9fef77b0515f5 Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Wed, 4 Nov 2026 08:07:57 +0800 Subject: [PATCH 1/3] .NET: fix(dotnet): parse declarative workflow formulas and values with InvariantCulture --- .../Extensions/ObjectExtensions.cs | 18 ++++--- .../Extensions/TemplateExtensions.cs | 6 ++- .../PowerFx/WorkflowExpressionEngine.cs | 7 ++- .../DeclarativeWorkflowTest.cs | 52 +++++++++++++++++++ .../Extensions/ObjectExtensionsTests.cs | 20 +++++++ .../PowerFx/WorkflowExpressionEngineTests.cs | 23 ++++++++ 6 files changed, 115 insertions(+), 11 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs index c4ca4faa3b..24612e1b18 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/ObjectExtensions.cs @@ -3,6 +3,7 @@ using System; using System.Collections; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Text.Json; using Microsoft.Agents.AI.Workflows.Declarative.Kit; @@ -152,7 +153,7 @@ _ when typeof(TimeSpan).IsAssignableFrom(targetType.Type) => ConvertToTimeSpan() sourceValue switch { null => null, - string s => int.Parse(s), + string s => int.Parse(s, CultureInfo.InvariantCulture), int i => i, long l => Convert.ToInt32(l), decimal c => Convert.ToInt32(c), @@ -166,7 +167,7 @@ _ when typeof(TimeSpan).IsAssignableFrom(targetType.Type) => ConvertToTimeSpan() sourceValue switch { null => null, - string s => long.Parse(s), + string s => long.Parse(s, CultureInfo.InvariantCulture), int i => i, long l => l, decimal c => Convert.ToInt64(c), @@ -180,7 +181,7 @@ _ when typeof(TimeSpan).IsAssignableFrom(targetType.Type) => ConvertToTimeSpan() sourceValue switch { null => null, - string s => decimal.Parse(s), + string s => decimal.Parse(s, CultureInfo.InvariantCulture), int i => i, long l => l, decimal c => c, @@ -194,7 +195,7 @@ _ when typeof(TimeSpan).IsAssignableFrom(targetType.Type) => ConvertToTimeSpan() sourceValue switch { null => null, - string s => double.Parse(s), + string s => double.Parse(s, CultureInfo.InvariantCulture), int i => i, long l => l, decimal c => Convert.ToDouble(c), @@ -208,7 +209,7 @@ _ when typeof(TimeSpan).IsAssignableFrom(targetType.Type) => ConvertToTimeSpan() sourceValue switch { null => null, - string s => DateTime.Parse(s), + string s => DateTime.Parse(s, CultureInfo.InvariantCulture), int i => new DateTime(i), long l => new DateTime(l), decimal c => new DateTime(Convert.ToInt64(c)), @@ -222,7 +223,7 @@ _ when typeof(TimeSpan).IsAssignableFrom(targetType.Type) => ConvertToTimeSpan() sourceValue switch { null => null, - string s => TimeSpan.Parse(s), + string s => TimeSpan.Parse(s, CultureInfo.InvariantCulture), int i => TimeSpan.FromTicks(i), long l => TimeSpan.FromTicks(l), decimal c => TimeSpan.FromTicks(Convert.ToInt64(c)), @@ -253,8 +254,9 @@ _ when typeof(TimeSpan).IsAssignableFrom(targetType.Type) => ConvertToTimeSpan() { null => null, string sourceText => sourceText, - DateTime dateTime => dateTime.ToString("o"), // ISO 8601 - TimeSpan timeSpan => timeSpan.ToString("c"), // Constant ("c") format + DateTime dateTime => dateTime.ToString("o", CultureInfo.InvariantCulture), // ISO 8601 + TimeSpan timeSpan => timeSpan.ToString("c", CultureInfo.InvariantCulture), // Constant ("c") format + IFormattable formattable => formattable.ToString(null, CultureInfo.InvariantCulture), _ => $"{sourceValue}", }; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/TemplateExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/TemplateExtensions.cs index 9ed12100b6..1e30f058a9 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/TemplateExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/TemplateExtensions.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Globalization; using System.Linq; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx; @@ -26,14 +27,15 @@ public static string Format(this RecalcEngine engine, TemplateSegment segment) if (segment is ExpressionSegment { Expression: not null } expressionSegment) { + ParserOptions options = new() { Culture = CultureInfo.InvariantCulture }; if (expressionSegment.Expression.ExpressionText is not null) { - return engine.Eval(expressionSegment.Expression.ExpressionText).Format(); + return engine.Eval(expressionSegment.Expression.ExpressionText, options: options).Format(); } if (expressionSegment.Expression.VariableReference is not null) { - return engine.Eval(expressionSegment.Expression.VariableReference.ToString()).Format(); + return engine.Eval(expressionSegment.Expression.VariableReference.ToString(), options: options).Format(); } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs index b2a5f00fb8..d5b99eacf6 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/PowerFx/WorkflowExpressionEngine.cs @@ -4,6 +4,7 @@ using System.Collections.Generic; using System.Collections.Immutable; using System.Diagnostics; +using System.Globalization; using System.Linq; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.ObjectModel; @@ -24,7 +25,11 @@ internal sealed class WorkflowExpressionEngine public WorkflowExpressionEngine(WorkflowFormulaState state) { this._state = state; - this._parserOptions = state.AllowsSideEffects ? new ParserOptions { AllowsSideEffects = true } : null; + this._parserOptions = new ParserOptions + { + Culture = CultureInfo.InvariantCulture, + AllowsSideEffects = state.AllowsSideEffects, + }; } public EvaluationResult GetValue(BoolExpression boolean) => this.Evaluate(boolean); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index b51b50b96c..3ebc07e6da 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Reflection; @@ -57,6 +58,57 @@ public async Task ResumeAsync_LegacyCheckpointPreservesNextAssignmentAsync(bool Assert.Equal(clear ? null : "updated", state.Get("TestValue").ToObject()); } + [Theory] + [InlineData("de-DE")] + [InlineData("fr-FR")] + public async Task DeclarativeWorkflowExecution_UsesInvariantCulture_ForFormulasAndNumberParsing(string cultureName) + { + CultureInfo originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + + const string Yaml = """ + kind: Workflow + trigger: + kind: OnConversationStart + id: repro + actions: + - kind: ParseValue + id: parse_price + variable: Local.Price + value: ="3.14" + valueType: Number + - kind: SetVariable + id: set_max + variable: Local.Max + value: =Max(1, 2) + - kind: SendActivity + id: send_max + activity: "Max is {Local.Max}" + """; + + using StringReader yamlReader = new(Yaml); + DeclarativeWorkflowOptions options = new(new MockAgentProvider().Object); + Workflow workflow = DeclarativeWorkflowBuilder.Build(yamlReader, options); + + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "start"); + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + Assert.False(evt is WorkflowErrorEvent, (evt as WorkflowErrorEvent)?.Exception?.ToString()); + Assert.False(evt is ExecutorFailedEvent, (evt as ExecutorFailedEvent)?.Data?.ToString()); + } + + WorkflowFormulaState state = GetRootState(workflow); + Assert.Equal(3.14D, Convert.ToDouble(state.Get("Price").ToObject(), CultureInfo.InvariantCulture)); + Assert.Equal(2.0D, Convert.ToDouble(state.Get("Max").ToObject(), CultureInfo.InvariantCulture)); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + [Theory] [InlineData(false)] [InlineData(true)] diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/ObjectExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/ObjectExtensionsTests.cs index 2f87b0d273..0d71669099 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/ObjectExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/ObjectExtensionsTests.cs @@ -2,6 +2,7 @@ using System; using System.Collections.Generic; +using System.Globalization; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.Kit; @@ -85,6 +86,25 @@ public void ConvertFromString() VerifyConversion($"{now.TimeOfDay:c}", typeof(TimeSpan), now.TimeOfDay); } + [Theory] + [InlineData("de-DE")] + [InlineData("fr-FR")] + public void ConvertNumberFromStringUsesInvariantCulture(string cultureName) + { + CultureInfo originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + VerifyConversion("3.14", typeof(double), 3.14D); + VerifyConversion("3.14", typeof(decimal), 3.14M); + VerifyConversion(3.14D, typeof(string), "3.14"); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + [Fact] public void ConvertJson() { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs index 31c0ee2e2b..3de7ae2d40 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/WorkflowExpressionEngineTests.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Immutable; using System.Diagnostics; +using System.Globalization; using System.IO; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.AI.Workflows.Declarative.PowerFx; @@ -455,6 +456,28 @@ public void NumberExpressionGetValueForFormula() => NumberExpression.Expression("31.1 + 2.2"), expectedValue: 33.3); + [Theory] + [InlineData("de-DE")] + [InlineData("fr-FR")] + public void NumberExpressionGetValueFormulaUsesInvariantCulture(string cultureName) + { + CultureInfo originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + this.EvaluateExpression( + NumberExpression.Expression("Max(1, 2)"), + expectedValue: 2); + this.EvaluateExpression( + NumberExpression.Expression("31.1 + 2.2"), + expectedValue: 33.3); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + #endregion #region DataValueExpression Tests From 461cb19f783b0ed4e1c3c564bf720b085508970c Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Wed, 4 Nov 2026 14:53:11 +0800 Subject: [PATCH 2/3] test(dotnet): add culture-sensitive regression tests for RecalcEngine.Format --- .../Extensions/TemplateExtensionsTests.cs | 23 +++++++++++++++++++ .../PowerFx/TemplateExtensionsTests.cs | 23 +++++++++++++++++++ 2 files changed, 46 insertions(+) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TemplateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TemplateExtensionsTests.cs index 9dfe650b73..2add9a7b85 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TemplateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Extensions/TemplateExtensionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Globalization; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx; @@ -138,4 +139,26 @@ public void FormatTextSegmentWithValue() // Assert Assert.Equal("Hello World", result); } + + [Theory] + [InlineData("de-DE")] + [InlineData("fr-FR")] + public void FormatExpressionSegmentWithFormulaUsesInvariantCulture(string cultureName) + { + CultureInfo originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + RecalcEngine engine = new(); + ExpressionSegment segment = new(ValueExpression.Expression("Max(1, 2)")); + + string result = engine.Format(segment); + + Assert.Equal("2", result); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs index de7f045052..4baef0ee5d 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/PowerFx/TemplateExtensionsTests.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System.Collections.Generic; +using System.Globalization; using Microsoft.Agents.AI.Workflows.Declarative.Extensions; using Microsoft.Agents.ObjectModel; using Microsoft.PowerFx.Types; @@ -94,6 +95,28 @@ public void FormatExpressionSegment() Assert.Equal("2", result); } + [Theory] + [InlineData("de-DE")] + [InlineData("fr-FR")] + public void FormatExpressionSegmentUsesInvariantCulture(string cultureName) + { + CultureInfo originalCulture = CultureInfo.CurrentCulture; + try + { + CultureInfo.CurrentCulture = new CultureInfo(cultureName); + ExpressionSegment expressionSegment = new(ValueExpression.Expression("Max(1, 2)")); + TemplateLine line = new([expressionSegment]); + + string? result = this.Engine.Format(line); + + Assert.Equal("2", result); + } + finally + { + CultureInfo.CurrentCulture = originalCulture; + } + } + [Fact] public void FormatVariableSegment() { From 38b1a5e649de1762a8b89352d4dbe15c1d1dbf6d Mon Sep 17 00:00:00 2001 From: Prognosticate X Date: Fri, 6 Nov 2026 09:13:31 +0800 Subject: [PATCH 3/3] style(dotnet): add Async suffix to async test method to satisfy IDE1006 naming rule --- .../DeclarativeWorkflowTest.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs index 3ebc07e6da..2c34e31670 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/DeclarativeWorkflowTest.cs @@ -61,7 +61,7 @@ public async Task ResumeAsync_LegacyCheckpointPreservesNextAssignmentAsync(bool [Theory] [InlineData("de-DE")] [InlineData("fr-FR")] - public async Task DeclarativeWorkflowExecution_UsesInvariantCulture_ForFormulasAndNumberParsing(string cultureName) + public async Task DeclarativeWorkflowExecution_UsesInvariantCulture_ForFormulasAndNumberParsingAsync(string cultureName) { CultureInfo originalCulture = CultureInfo.CurrentCulture; try