diff --git a/README.md b/README.md
index 35c4386d..7226f201 100644
--- a/README.md
+++ b/README.md
@@ -35,7 +35,7 @@ To get started, add the [Microsoft.Azure.Functions.Worker.Extensions.DurableTask
-
+
```
diff --git a/src/Abstractions/Converters/JsonDataConverter.cs b/src/Abstractions/Converters/JsonDataConverter.cs
index eeca67a3..cf17e792 100644
--- a/src/Abstractions/Converters/JsonDataConverter.cs
+++ b/src/Abstractions/Converters/JsonDataConverter.cs
@@ -14,6 +14,7 @@ public class JsonDataConverter : DataConverter
static readonly JsonSerializerOptions DefaultOptions = new()
{
IncludeFields = true,
+ Converters = { new ObjectToInferredTypesConverter() },
};
readonly JsonSerializerOptions? options;
diff --git a/src/Abstractions/Converters/ObjectToInferredTypesConverter.cs b/src/Abstractions/Converters/ObjectToInferredTypesConverter.cs
new file mode 100644
index 00000000..099ab4bf
--- /dev/null
+++ b/src/Abstractions/Converters/ObjectToInferredTypesConverter.cs
@@ -0,0 +1,83 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using System.Text.Json.Serialization;
+
+namespace Microsoft.DurableTask.Converters;
+
+///
+/// A custom JSON converter that deserializes JSON tokens into inferred .NET types instead of JsonElement.
+///
+///
+/// When deserializing to object type, System.Text.Json defaults to returning JsonElement instances.
+/// This converter infers the appropriate .NET type based on the JSON token type:
+/// - JSON strings become
+/// - JSON numbers become , , or
+/// - JSON booleans become
+/// - JSON objects become where TValue is
+/// - JSON arrays become []
+/// - JSON null becomes null
+/// This is particularly useful when working with where TValue is
+/// , ensuring that complex types are preserved as dictionaries rather than JsonElement.
+///
+internal sealed class ObjectToInferredTypesConverter : JsonConverter
+{
+ ///
+ public override object? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
+ {
+ switch (reader.TokenType)
+ {
+ case JsonTokenType.True:
+ case JsonTokenType.False:
+ return reader.GetBoolean();
+ case JsonTokenType.Number:
+ if (reader.TryGetInt32(out int intValue))
+ {
+ return intValue;
+ }
+
+ if (reader.TryGetInt64(out long longValue))
+ {
+ return longValue;
+ }
+
+ return reader.GetDouble();
+ case JsonTokenType.String:
+ return reader.GetString();
+ case JsonTokenType.StartObject:
+ Dictionary dictionary = new();
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndObject)
+ {
+ if (reader.TokenType != JsonTokenType.PropertyName)
+ {
+ throw new JsonException("Expected property name.");
+ }
+
+ string propertyName = reader.GetString() ?? throw new JsonException("Property name cannot be null.");
+ reader.Read();
+ dictionary[propertyName] = this.Read(ref reader, typeof(object), options);
+ }
+
+ return dictionary;
+ case JsonTokenType.StartArray:
+ List list = new();
+ while (reader.Read() && reader.TokenType != JsonTokenType.EndArray)
+ {
+ list.Add(this.Read(ref reader, typeof(object), options));
+ }
+
+ return list.ToArray();
+ case JsonTokenType.Null:
+ return null;
+ default:
+ throw new JsonException($"Unsupported JSON token type: {reader.TokenType}");
+ }
+ }
+
+ ///
+ public override void Write(Utf8JsonWriter writer, object value, JsonSerializerOptions options)
+ {
+ JsonSerializer.Serialize(writer, value, value?.GetType() ?? typeof(object), options);
+ }
+}
diff --git a/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs
new file mode 100644
index 00000000..b1222d4b
--- /dev/null
+++ b/src/Analyzers/Activities/FunctionNotFoundAnalyzer.cs
@@ -0,0 +1,294 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Collections.Concurrent;
+using System.Collections.Immutable;
+using Microsoft.CodeAnalysis;
+using Microsoft.CodeAnalysis.CSharp;
+using Microsoft.CodeAnalysis.Diagnostics;
+using Microsoft.CodeAnalysis.Operations;
+
+namespace Microsoft.DurableTask.Analyzers.Activities;
+
+///
+/// Analyzer that detects calls to non-existent activities and sub-orchestrations.
+///
+[DiagnosticAnalyzer(LanguageNames.CSharp)]
+public sealed class FunctionNotFoundAnalyzer : DiagnosticAnalyzer
+{
+ ///
+ /// The diagnostic ID for the diagnostic that reports when an activity call references a function that doesn't exist.
+ ///
+ public const string ActivityNotFoundDiagnosticId = "DURABLE2003";
+
+ ///
+ /// The diagnostic ID for the diagnostic that reports when a sub-orchestration call references a function that doesn't exist.
+ ///
+ public const string SubOrchestrationNotFoundDiagnosticId = "DURABLE2004";
+
+ static readonly LocalizableString ActivityNotFoundTitle = new LocalizableResourceString(nameof(Resources.ActivityNotFoundAnalyzerTitle), Resources.ResourceManager, typeof(Resources));
+ static readonly LocalizableString ActivityNotFoundMessageFormat = new LocalizableResourceString(nameof(Resources.ActivityNotFoundAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));
+
+ static readonly LocalizableString SubOrchestrationNotFoundTitle = new LocalizableResourceString(nameof(Resources.SubOrchestrationNotFoundAnalyzerTitle), Resources.ResourceManager, typeof(Resources));
+ static readonly LocalizableString SubOrchestrationNotFoundMessageFormat = new LocalizableResourceString(nameof(Resources.SubOrchestrationNotFoundAnalyzerMessageFormat), Resources.ResourceManager, typeof(Resources));
+
+ static readonly DiagnosticDescriptor ActivityNotFoundRule = new(
+ ActivityNotFoundDiagnosticId,
+ ActivityNotFoundTitle,
+ ActivityNotFoundMessageFormat,
+ AnalyzersCategories.Activity,
+ DiagnosticSeverity.Warning,
+ customTags: [WellKnownDiagnosticTags.CompilationEnd],
+ isEnabledByDefault: true);
+
+ static readonly DiagnosticDescriptor SubOrchestrationNotFoundRule = new(
+ SubOrchestrationNotFoundDiagnosticId,
+ SubOrchestrationNotFoundTitle,
+ SubOrchestrationNotFoundMessageFormat,
+ AnalyzersCategories.Orchestration,
+ DiagnosticSeverity.Warning,
+ customTags: [WellKnownDiagnosticTags.CompilationEnd],
+ isEnabledByDefault: true);
+
+ ///
+ public override ImmutableArray SupportedDiagnostics => [ActivityNotFoundRule, SubOrchestrationNotFoundRule];
+
+ ///
+ public override void Initialize(AnalysisContext context)
+ {
+ context.EnableConcurrentExecution();
+ context.ConfigureGeneratedCodeAnalysis(GeneratedCodeAnalysisFlags.None);
+
+ context.RegisterCompilationStartAction(context =>
+ {
+ KnownTypeSymbols knownSymbols = new(context.Compilation);
+
+ if (knownSymbols.TaskOrchestrationContext == null ||
+ knownSymbols.Task == null || knownSymbols.TaskT == null)
+ {
+ // Core symbols not available in this compilation, skip analysis
+ return;
+ }
+
+ // Activity-related symbols (may be null if activities aren't used)
+ IMethodSymbol? taskActivityRunAsync = knownSymbols.TaskActivityBase?.GetMembers("RunAsync").OfType().SingleOrDefault();
+
+ // Search for Activity and Sub-Orchestrator invocations
+ ConcurrentBag activityInvocations = [];
+ ConcurrentBag subOrchestrationInvocations = [];
+
+ context.RegisterOperationAction(
+ ctx =>
+ {
+ ctx.CancellationToken.ThrowIfCancellationRequested();
+
+ if (ctx.Operation is not IInvocationOperation invocationOperation)
+ {
+ return;
+ }
+
+ IMethodSymbol targetMethod = invocationOperation.TargetMethod;
+
+ // Check for CallActivityAsync
+ if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallActivityAsync"))
+ {
+ string? activityName = ExtractFunctionName(invocationOperation, "name", ctx);
+ if (activityName != null)
+ {
+ activityInvocations.Add(new FunctionInvocation(activityName, invocationOperation.Syntax));
+ }
+ }
+
+ // Check for CallSubOrchestratorAsync
+ if (targetMethod.IsEqualTo(knownSymbols.TaskOrchestrationContext, "CallSubOrchestratorAsync"))
+ {
+ string? orchestratorName = ExtractFunctionName(invocationOperation, "orchestratorName", ctx);
+ if (orchestratorName != null)
+ {
+ subOrchestrationInvocations.Add(new FunctionInvocation(orchestratorName, invocationOperation.Syntax));
+ }
+ }
+ },
+ OperationKind.Invocation);
+
+ // Search for Activity definitions
+ ConcurrentBag activityNames = [];
+ ConcurrentBag orchestratorNames = [];
+
+ // Search for Durable Functions Activities and Orchestrators definitions (via [Function] attribute)
+ context.RegisterSymbolAction(
+ ctx =>
+ {
+ ctx.CancellationToken.ThrowIfCancellationRequested();
+
+ if (ctx.Symbol is not IMethodSymbol methodSymbol)
+ {
+ return;
+ }
+
+ // Check for Activity defined via [ActivityTrigger]
+ if (knownSymbols.ActivityTriggerAttribute != null &&
+ methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.ActivityTriggerAttribute) &&
+ knownSymbols.FunctionNameAttribute != null &&
+ methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string functionName))
+ {
+ activityNames.Add(functionName);
+ }
+
+ // Check for Orchestrator defined via [OrchestrationTrigger]
+ if (knownSymbols.FunctionOrchestrationAttribute != null &&
+ methodSymbol.ContainsAttributeInAnyMethodArguments(knownSymbols.FunctionOrchestrationAttribute) &&
+ knownSymbols.FunctionNameAttribute != null &&
+ methodSymbol.TryGetSingleValueFromAttribute(knownSymbols.FunctionNameAttribute, out string orchestratorFunctionName))
+ {
+ orchestratorNames.Add(orchestratorFunctionName);
+ }
+ },
+ SymbolKind.Method);
+
+ // Search for TaskActivity definitions (class-based syntax)
+ context.RegisterSyntaxNodeAction(
+ ctx =>
+ {
+ ctx.CancellationToken.ThrowIfCancellationRequested();
+
+ if (ctx.ContainingSymbol is not INamedTypeSymbol classSymbol)
+ {
+ return;
+ }
+
+ if (classSymbol.IsAbstract)
+ {
+ return;
+ }
+
+ // Check for TaskActivity derived classes
+ if (knownSymbols.TaskActivityBase != null && taskActivityRunAsync != null &&
+ ClassOverridesMethod(classSymbol, taskActivityRunAsync))
+ {
+ activityNames.Add(classSymbol.Name);
+ }
+
+ // Check for ITaskOrchestrator implementations (class-based orchestrators)
+ if (knownSymbols.TaskOrchestratorInterface != null &&
+ classSymbol.AllInterfaces.Any(i => SymbolEqualityComparer.Default.Equals(i, knownSymbols.TaskOrchestratorInterface)))
+ {
+ orchestratorNames.Add(classSymbol.Name);
+ }
+ },
+ SyntaxKind.ClassDeclaration);
+
+ // Search for Func/Action activities directly registered through DurableTaskRegistry
+ context.RegisterOperationAction(
+ ctx =>
+ {
+ ctx.CancellationToken.ThrowIfCancellationRequested();
+
+ if (ctx.Operation is not IInvocationOperation invocation)
+ {
+ return;
+ }
+
+ if (knownSymbols.DurableTaskRegistry == null ||
+ !SymbolEqualityComparer.Default.Equals(invocation.Type, knownSymbols.DurableTaskRegistry))
+ {
+ return;
+ }
+
+ // Handle AddActivityFunc registrations
+ if (invocation.TargetMethod.Name == "AddActivityFunc")
+ {
+ string? name = ExtractFunctionName(invocation, "name", ctx);
+ if (name != null)
+ {
+ activityNames.Add(name);
+ }
+ }
+
+ // Handle AddOrchestratorFunc registrations
+ if (invocation.TargetMethod.Name == "AddOrchestratorFunc")
+ {
+ string? name = ExtractFunctionName(invocation, "name", ctx);
+ if (name != null)
+ {
+ orchestratorNames.Add(name);
+ }
+ }
+ },
+ OperationKind.Invocation);
+
+ // At the end of the compilation, we correlate the invocations with the definitions
+ context.RegisterCompilationEndAction(ctx =>
+ {
+ // Create lookup sets for faster searching
+ HashSet definedActivities = new(activityNames);
+ HashSet definedOrchestrators = new(orchestratorNames);
+
+ // Report diagnostics for activities not found
+ foreach (FunctionInvocation invocation in activityInvocations.Where(i => !definedActivities.Contains(i.Name)))
+ {
+ Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic(
+ ActivityNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name);
+ ctx.ReportDiagnostic(diagnostic);
+ }
+
+ // Report diagnostics for sub-orchestrators not found
+ foreach (FunctionInvocation invocation in subOrchestrationInvocations.Where(i => !definedOrchestrators.Contains(i.Name)))
+ {
+ Diagnostic diagnostic = RoslynExtensions.BuildDiagnostic(
+ SubOrchestrationNotFoundRule, invocation.InvocationSyntaxNode, invocation.Name);
+ ctx.ReportDiagnostic(diagnostic);
+ }
+ });
+ });
+ }
+
+ static string? ExtractFunctionName(IInvocationOperation invocationOperation, string parameterName, OperationAnalysisContext ctx)
+ {
+ IArgumentOperation? nameArgumentOperation = invocationOperation.Arguments.SingleOrDefault(a => a.Parameter?.Name == parameterName);
+ if (nameArgumentOperation == null)
+ {
+ return null;
+ }
+
+ SemanticModel? semanticModel = ctx.Operation.SemanticModel;
+ if (semanticModel == null)
+ {
+ return null;
+ }
+
+ // extracts the constant value from the argument (e.g.: it can be a nameof, string literal or const field)
+ Optional constant = semanticModel.GetConstantValue(nameArgumentOperation.Value.Syntax);
+ if (!constant.HasValue)
+ {
+ // not a constant value, we cannot correlate this invocation to an existent function in compile time
+ return null;
+ }
+
+ return constant.Value?.ToString();
+ }
+
+ static bool ClassOverridesMethod(INamedTypeSymbol classSymbol, IMethodSymbol methodToFind)
+ {
+ INamedTypeSymbol? baseType = classSymbol;
+ while (baseType != null)
+ {
+ if (baseType.GetMembers().OfType()
+ .Any(method => SymbolEqualityComparer.Default.Equals(method.OverriddenMethod?.OriginalDefinition, methodToFind)))
+ {
+ return true;
+ }
+
+ baseType = baseType.BaseType;
+ }
+
+ return false;
+ }
+
+ readonly struct FunctionInvocation(string name, SyntaxNode invocationSyntaxNode)
+ {
+ public string Name { get; } = name;
+
+ public SyntaxNode InvocationSyntaxNode { get; } = invocationSyntaxNode;
+ }
+}
diff --git a/src/Analyzers/AnalyzerReleases.Shipped.md b/src/Analyzers/AnalyzerReleases.Shipped.md
index c2504981..b3ea2041 100644
--- a/src/Analyzers/AnalyzerReleases.Shipped.md
+++ b/src/Analyzers/AnalyzerReleases.Shipped.md
@@ -1,6 +1,15 @@
; Shipped analyzer releases
; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+## Release 0.2.0
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
+DURABLE2003 | Activity | Warning | **FunctionNotFoundAnalyzer**: Warns when an activity function call references a name that does not match any defined activity in the compilation.
+DURABLE2004 | Orchestration | Warning | **FunctionNotFoundAnalyzer**: Warns when a sub-orchestration call references a name that does not match any defined orchestrator in the compilation.
+
## Release 0.1.0
### New Rules
diff --git a/src/Analyzers/AnalyzerReleases.Unshipped.md b/src/Analyzers/AnalyzerReleases.Unshipped.md
index 7d6ac616..d9cf2bd7 100644
--- a/src/Analyzers/AnalyzerReleases.Unshipped.md
+++ b/src/Analyzers/AnalyzerReleases.Unshipped.md
@@ -1,2 +1,7 @@
; Unshipped analyzer release
-; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
\ No newline at end of file
+; https://github.com/dotnet/roslyn/blob/main/src/RoslynAnalyzers/Microsoft.CodeAnalysis.Analyzers/ReleaseTrackingAnalyzers.Help.md
+
+### New Rules
+
+Rule ID | Category | Severity | Notes
+--------|----------|----------|-------
\ No newline at end of file
diff --git a/src/Analyzers/Analyzers.csproj b/src/Analyzers/Analyzers.csproj
index dad1b98a..be79830c 100644
--- a/src/Analyzers/Analyzers.csproj
+++ b/src/Analyzers/Analyzers.csproj
@@ -11,7 +11,7 @@
- 0.1.0
+ 0.2.0
.NET Analyzers for the Durable Task SDK.
en
diff --git a/src/Analyzers/Resources.resx b/src/Analyzers/Resources.resx
index 5f8d5219..ee14969a 100644
--- a/src/Analyzers/Resources.resx
+++ b/src/Analyzers/Resources.resx
@@ -198,4 +198,16 @@
Use '{0}' instead of '{1}'
+
+ The activity function '{0}' was not found in the current compilation. Ensure the activity is defined and the name matches exactly.
+
+
+ Activity function not found
+
+
+ The sub-orchestration '{0}' was not found in the current compilation. Ensure the orchestrator is defined and the name matches exactly.
+
+
+ Sub-orchestration not found
+
\ No newline at end of file
diff --git a/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs b/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs
index 9edced26..ec66a44b 100644
--- a/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs
+++ b/src/Extensions/AzureBlobPayloads/PayloadStore/BlobPayloadStore.cs
@@ -2,7 +2,9 @@
// Licensed under the MIT License.
using System.IO.Compression;
+using System.Net;
using System.Text;
+using Azure;
using Azure.Core;
using Azure.Storage.Blobs;
using Azure.Storage.Blobs.Models;
@@ -117,20 +119,30 @@ public override async Task DownloadAsync(string token, CancellationToken
BlobClient blob = this.containerClient.GetBlobClient(name);
- using BlobDownloadStreamingResult result = await blob.DownloadStreamingAsync(cancellationToken: cancellationToken);
- Stream contentStream = result.Content;
- bool isGzip = string.Equals(
- result.Details.ContentEncoding, ContentEncodingGzip, StringComparison.OrdinalIgnoreCase);
+ try
+ {
+ using BlobDownloadStreamingResult result = await blob.DownloadStreamingAsync(cancellationToken: cancellationToken);
+ Stream contentStream = result.Content;
+ bool isGzip = string.Equals(
+ result.Details.ContentEncoding, ContentEncodingGzip, StringComparison.OrdinalIgnoreCase);
+
+ if (isGzip)
+ {
+ using GZipStream decompressed = new(contentStream, CompressionMode.Decompress);
+ using StreamReader reader = new(decompressed, Encoding.UTF8);
+ return await ReadToEndAsync(reader, cancellationToken);
+ }
- if (isGzip)
+ using StreamReader uncompressedReader = new(contentStream, Encoding.UTF8);
+ return await ReadToEndAsync(uncompressedReader, cancellationToken);
+ }
+ catch (RequestFailedException ex) when (ex.Status == (int)HttpStatusCode.NotFound)
{
- using GZipStream decompressed = new(contentStream, CompressionMode.Decompress);
- using StreamReader reader = new(decompressed, Encoding.UTF8);
- return await ReadToEndAsync(reader, cancellationToken);
+ throw new InvalidOperationException(
+ $"The blob '{name}' was not found in container '{container}'. " +
+ "The payload may have been deleted or the container was never created.",
+ ex);
}
-
- using StreamReader uncompressedReader = new(contentStream, Encoding.UTF8);
- return await ReadToEndAsync(uncompressedReader, cancellationToken);
}
///
diff --git a/src/Generators/DurableTaskSourceGenerator.cs b/src/Generators/DurableTaskSourceGenerator.cs
index 986f7ec3..19a24cc9 100644
--- a/src/Generators/DurableTaskSourceGenerator.cs
+++ b/src/Generators/DurableTaskSourceGenerator.cs
@@ -325,6 +325,9 @@ static void AddOrchestratorFunctionDeclaration(StringBuilder sourceBuilder, Dura
static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator)
{
sourceBuilder.AppendLine($@"
+ ///
+ /// Schedules a new instance of the orchestrator.
+ ///
///
public static Task ScheduleNew{orchestrator.TaskName}InstanceAsync(
this IOrchestrationSubmitter client, {orchestrator.InputParameter}, StartOrchestrationOptions? options = null)
@@ -336,6 +339,9 @@ static void AddOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTy
static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo orchestrator)
{
sourceBuilder.AppendLine($@"
+ ///
+ /// Calls the sub-orchestrator.
+ ///
///
public static Task<{orchestrator.OutputType}> Call{orchestrator.TaskName}Async(
this TaskOrchestrationContext context, {orchestrator.InputParameter}, TaskOptions? options = null)
@@ -347,6 +353,10 @@ static void AddSubOrchestratorCallMethod(StringBuilder sourceBuilder, DurableTas
static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeInfo activity)
{
sourceBuilder.AppendLine($@"
+ ///
+ /// Calls the activity.
+ ///
+ ///
public static Task<{activity.OutputType}> Call{activity.TaskName}Async(this TaskOrchestrationContext ctx, {activity.InputParameter}, TaskOptions? options = null)
{{
return ctx.CallActivityAsync<{activity.OutputType}>(""{activity.TaskName}"", input, options);
@@ -356,6 +366,10 @@ static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableTaskTypeIn
static void AddActivityCallMethod(StringBuilder sourceBuilder, DurableFunction activity)
{
sourceBuilder.AppendLine($@"
+ ///
+ /// Calls the activity.
+ ///
+ ///
public static Task<{activity.ReturnType}> Call{activity.Name}Async(this TaskOrchestrationContext ctx, {activity.Parameter}, TaskOptions? options = null)
{{
return ctx.CallActivityAsync<{activity.ReturnType}>(""{activity.Name}"", {activity.Parameter.Name}, options);
diff --git a/src/Generators/Generators.csproj b/src/Generators/Generators.csproj
index ff7cffe2..c6149c2b 100644
--- a/src/Generators/Generators.csproj
+++ b/src/Generators/Generators.csproj
@@ -21,7 +21,6 @@
1.0.0
- preview.1
diff --git a/test/Abstractions.Tests/JsonDataConverterTests.cs b/test/Abstractions.Tests/JsonDataConverterTests.cs
new file mode 100644
index 00000000..2251c792
--- /dev/null
+++ b/test/Abstractions.Tests/JsonDataConverterTests.cs
@@ -0,0 +1,307 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using System.Text.Json;
+using Microsoft.DurableTask.Converters;
+
+namespace Microsoft.DurableTask.Tests;
+
+public class JsonDataConverterTests
+{
+ readonly JsonDataConverter converter = JsonDataConverter.Default;
+
+ [Fact]
+ public void Serialize_Null_ReturnsNull()
+ {
+ // Act
+ string? result = this.converter.Serialize(null);
+
+ // Assert
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public void Deserialize_Null_ReturnsNull()
+ {
+ // Act
+ object? result = this.converter.Deserialize(null, typeof(object));
+
+ // Assert
+ result.Should().BeNull();
+ }
+
+ [Fact]
+ public void RoundTrip_SimpleTypes_PreservesTypes()
+ {
+ // Arrange
+ Dictionary input = new()
+ {
+ { "string", "value" },
+ { "int", 42 },
+ { "long", 9223372036854775807L },
+ { "double", 3.14 },
+ { "bool", true },
+ { "null", null! },
+ };
+
+ // Act
+ string serialized = this.converter.Serialize(input)!;
+ Dictionary? result = this.converter.Deserialize>(serialized);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!["string"].Should().BeOfType().And.Be("value");
+ result["int"].Should().BeOfType().And.Be(42);
+
+ // Note: Large integers that don't fit in int32 will be deserialized as long
+ result["long"].Should().Match(o => o is long || o is double);
+
+ result["double"].Should().BeOfType().And.Be(3.14);
+ result["bool"].Should().BeOfType().And.Be(true);
+ result["null"].Should().BeNull();
+ }
+
+ [Fact]
+ public void RoundTrip_NestedDictionary_PreservesStructure()
+ {
+ // Arrange
+ Dictionary input = new()
+ {
+ {
+ "nested", new Dictionary
+ {
+ { "key1", "value1" },
+ { "key2", 123 },
+ }
+ },
+ };
+
+ // Act
+ string serialized = this.converter.Serialize(input)!;
+ Dictionary? result = this.converter.Deserialize>(serialized);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!["nested"].Should().BeOfType>();
+ Dictionary nested = (Dictionary)result["nested"];
+ nested["key1"].Should().BeOfType().And.Be("value1");
+ nested["key2"].Should().BeOfType().And.Be(123);
+ }
+
+ [Fact]
+ public void RoundTrip_Array_PreservesElements()
+ {
+ // Arrange
+ Dictionary input = new()
+ {
+ { "array", new object[] { "string", 42, true } },
+ };
+
+ // Act
+ string serialized = this.converter.Serialize(input)!;
+ Dictionary? result = this.converter.Deserialize>(serialized);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!["array"].Should().BeOfType();
+ object[] array = (object[])result["array"];
+ array.Should().HaveCount(3);
+ array[0].Should().BeOfType().And.Be("string");
+ array[1].Should().BeOfType().And.Be(42);
+ array[2].Should().BeOfType().And.Be(true);
+ }
+
+ [Fact]
+ public void RoundTrip_ComplexObject_PreservesStructure()
+ {
+ // Arrange - simulate the issue described in the GitHub issue
+ Dictionary input = new()
+ {
+ { "ComponentContext", new { Name = "TestComponent", Id = 123 } },
+ { "PlanResult", new { Status = "Success", Count = 5 } },
+ };
+
+ // Act
+ string serialized = this.converter.Serialize(input)!;
+ Dictionary? result = this.converter.Deserialize>(serialized);
+
+ // Assert
+ result.Should().NotBeNull();
+
+ // The anonymous objects should be deserialized as dictionaries, not JsonElements
+ result!["ComponentContext"].Should().BeOfType>();
+ Dictionary componentContext = (Dictionary)result["ComponentContext"];
+ componentContext["Name"].Should().BeOfType().And.Be("TestComponent");
+ componentContext["Id"].Should().BeOfType().And.Be(123);
+
+ result["PlanResult"].Should().BeOfType>();
+ Dictionary planResult = (Dictionary)result["PlanResult"];
+ planResult["Status"].Should().BeOfType().And.Be("Success");
+ planResult["Count"].Should().BeOfType().And.Be(5);
+ }
+
+ [Fact]
+ public void Deserialize_JsonWithoutConverter_ProducesJsonElements()
+ {
+ // Arrange - use standard System.Text.Json without our custom converter
+ JsonSerializerOptions standardOptions = new() { IncludeFields = true };
+ Dictionary input = new()
+ {
+ { "key", "value" },
+ };
+ string json = JsonSerializer.Serialize(input, standardOptions);
+
+ // Act
+ Dictionary? result = JsonSerializer.Deserialize>(json, standardOptions);
+
+ // Assert - without our converter, values are JsonElements
+ result.Should().NotBeNull();
+ result!["key"].Should().BeOfType();
+ }
+
+ [Fact]
+ public void Deserialize_JsonWithConverter_ProducesConcreteTypes()
+ {
+ // Arrange
+ Dictionary input = new()
+ {
+ { "key", "value" },
+ };
+ string json = this.converter.Serialize(input)!;
+
+ // Act
+ Dictionary? result = this.converter.Deserialize>(json);
+
+ // Assert - with our converter, values are concrete types
+ result.Should().NotBeNull();
+ result!["key"].Should().BeOfType().And.Be("value");
+ }
+
+ [Fact]
+ public void RoundTrip_RecordType_PreservesProperties()
+ {
+ // Arrange
+ TestRecord input = new("TestValue", 42, new Dictionary
+ {
+ { "nested", "data" },
+ });
+
+ // Act
+ string serialized = this.converter.Serialize(input)!;
+ TestRecord? result = this.converter.Deserialize(serialized);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.Name.Should().Be("TestValue");
+ result.Value.Should().Be(42);
+ result.Properties.Should().ContainKey("nested");
+ result.Properties["nested"].Should().BeOfType().And.Be("data");
+ }
+
+ [Fact]
+ public void RoundTrip_CustomTypeWithObjectField_PreservesType()
+ {
+ // Arrange - custom type with object field
+ CustomTypeWithObjectField input = new()
+ {
+ Name = "Test",
+ Data = new { Setting = "value", Count = 10 },
+ };
+
+ // Act
+ string serialized = this.converter.Serialize(input)!;
+ CustomTypeWithObjectField? result = this.converter.Deserialize(serialized);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.Name.Should().Be("Test");
+
+ // The object field should be deserialized as Dictionary
+ result.Data.Should().BeOfType>();
+ Dictionary data = (Dictionary)result.Data!;
+ data["Setting"].Should().BeOfType().And.Be("value");
+ data["Count"].Should().BeOfType().And.Be(10);
+ }
+
+ [Fact]
+ public void RoundTrip_CustomTypeWithObjectArray_PreservesTypes()
+ {
+ // Arrange
+ CustomTypeWithObjectArray input = new()
+ {
+ Items = new object[] { "string", 42, true, new { Nested = "value" } },
+ };
+
+ // Act
+ string serialized = this.converter.Serialize(input)!;
+ CustomTypeWithObjectArray? result = this.converter.Deserialize(serialized);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.Items.Should().NotBeNull().And.HaveCount(4);
+ result.Items![0].Should().BeOfType().And.Be("string");
+ result.Items[1].Should().BeOfType().And.Be(42);
+ result.Items[2].Should().BeOfType().And.Be(true);
+ result.Items[3].Should().BeOfType>();
+
+ Dictionary nested = (Dictionary)result.Items[3];
+ nested["Nested"].Should().BeOfType().And.Be("value");
+ }
+
+ [Fact]
+ public void RoundTrip_DeeplyNestedCustomTypes_PreservesStructure()
+ {
+ // Arrange - simulate complex activity input with multiple levels of nesting
+ ActivityInput input = new()
+ {
+ Metadata = new Dictionary
+ {
+ { "ComponentContext", new { Name = "loganalytics", Config = new { Setting = "value" } } },
+ { "PlanResult", new { Success = true, Items = new[] { "item1", "item2" } } },
+ },
+ };
+
+ // Act
+ string serialized = this.converter.Serialize(input)!;
+ ActivityInput? result = this.converter.Deserialize(serialized);
+
+ // Assert
+ result.Should().NotBeNull();
+ result!.Metadata.Should().HaveCount(2);
+
+ // Check ComponentContext
+ result.Metadata["ComponentContext"].Should().BeOfType>();
+ Dictionary component = (Dictionary)result.Metadata["ComponentContext"];
+ component["Name"].Should().BeOfType().And.Be("loganalytics");
+ component["Config"].Should().BeOfType>();
+ Dictionary config = (Dictionary)component["Config"];
+ config["Setting"].Should().BeOfType().And.Be("value");
+
+ // Check PlanResult
+ result.Metadata["PlanResult"].Should().BeOfType>();
+ Dictionary plan = (Dictionary)result.Metadata["PlanResult"];
+ plan["Success"].Should().BeOfType().And.Be(true);
+ plan["Items"].Should().BeOfType();
+ object[] items = (object[])plan["Items"];
+ items[0].Should().BeOfType().And.Be("item1");
+ items[1].Should().BeOfType().And.Be("item2");
+ }
+
+ record TestRecord(string Name, int Value, Dictionary Properties);
+
+ class CustomTypeWithObjectField
+ {
+ public string Name { get; set; } = "";
+ public object? Data { get; set; }
+ }
+
+ class CustomTypeWithObjectArray
+ {
+ public object[]? Items { get; set; }
+ }
+
+ class ActivityInput
+ {
+ public Dictionary Metadata { get; set; } = new();
+ }
+}
diff --git a/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs b/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs
new file mode 100644
index 00000000..7dff2e38
--- /dev/null
+++ b/test/Analyzers.Tests/Activities/FunctionNotFoundAnalyzerTests.cs
@@ -0,0 +1,374 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+using Microsoft.CodeAnalysis.Testing;
+using Microsoft.DurableTask.Analyzers.Activities;
+using VerifyCS = Microsoft.DurableTask.Analyzers.Tests.Verifiers.CSharpAnalyzerVerifier;
+
+namespace Microsoft.DurableTask.Analyzers.Tests.Activities;
+
+public class FunctionNotFoundAnalyzerTests
+{
+ // ==================== Activity Tests ====================
+
+ [Fact]
+ public async Task DurableFunctionActivityInvocationWithMatchingActivity_NoDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ await context.CallActivityAsync(nameof(SayHello), ""Tokyo"");
+}
+
+[Function(nameof(SayHello))]
+void SayHello([ActivityTrigger] string name)
+{
+}
+");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task DurableFunctionActivityInvocationWithMismatchedName_ReportsDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ await {|#0:context.CallActivityAsync(""SayHallo"", ""Tokyo"")|};
+}
+
+[Function(nameof(SayHello))]
+void SayHello([ActivityTrigger] string name)
+{
+}
+");
+ DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("SayHallo");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task DurableFunctionActivityInvocationWithNonExistentActivity_ReportsDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ await {|#0:context.CallActivityAsync(""NonExistentActivity"", ""Tokyo"")|};
+}
+");
+ DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("NonExistentActivity");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task TaskActivityInvocationWithMatchingClassBasedActivity_NoDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapTaskOrchestrator(@"
+public class Caller {
+ async Task Method(TaskOrchestrationContext context)
+ {
+ await context.CallActivityAsync(nameof(MyActivity), ""Tokyo"");
+ }
+}
+
+public class MyActivity : TaskActivity
+{
+ public override Task RunAsync(TaskActivityContext context, string cityName)
+ {
+ return Task.FromResult(cityName);
+ }
+}
+");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task TaskActivityInvocationWithMismatchedName_ReportsDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapTaskOrchestrator(@"
+public class Caller {
+ async Task Method(TaskOrchestrationContext context)
+ {
+ await {|#0:context.CallActivityAsync(""MyActiviti"", ""Tokyo"")|};
+ }
+}
+
+public class MyActivity : TaskActivity
+{
+ public override Task RunAsync(TaskActivityContext context, string cityName)
+ {
+ return Task.FromResult(cityName);
+ }
+}
+");
+ DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("MyActiviti");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task LambdaActivityInvocationWithMatchingActivity_NoDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapFuncOrchestrator(@"
+tasks.AddOrchestratorFunc(""HelloSequence"", async context =>
+ await context.CallActivityAsync(""SayHello"", ""Tokyo""));
+
+tasks.AddActivityFunc(""SayHello"", (context, city) => { });
+");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task LambdaActivityInvocationWithMismatchedName_ReportsDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapFuncOrchestrator(@"
+tasks.AddOrchestratorFunc(""HelloSequence"", async context =>
+ await {|#0:context.CallActivityAsync(""SayHallo"", ""Tokyo"")|});
+
+tasks.AddActivityFunc(""SayHello"", (context, city) => { });
+");
+ DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("SayHallo");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task ActivityInvocationWithConstVariableName_ReportsDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+const string activityName = ""WrongActivityName"";
+
+async Task Method(TaskOrchestrationContext context)
+{
+ await {|#0:context.CallActivityAsync(activityName, ""Tokyo"")|};
+}
+
+[Function(nameof(SayHello))]
+void SayHello([ActivityTrigger] string name)
+{
+}
+");
+ DiagnosticResult expected = BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("WrongActivityName");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task ActivityInvocationWithNonConstVariable_NoDiagnostic()
+ {
+ // Arrange - When using a non-const variable, we cannot determine the value at compile-time
+ // so no diagnostic is reported (to avoid false positives)
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ string activityName = ""NonExistentActivity"";
+ await context.CallActivityAsync(activityName, ""Tokyo"");
+}
+");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
+ }
+
+ // ==================== Sub-Orchestration Tests ====================
+
+ [Fact]
+ public async Task SubOrchestrationInvocationWithMatchingOrchestration_NoDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input"");
+}
+
+[Function(nameof(ChildOrchestration))]
+async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context)
+{
+ await Task.CompletedTask;
+}
+");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task SubOrchestrationInvocationWithMismatchedName_ReportsDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ await {|#0:context.CallSubOrchestratorAsync(""ChildOrchestration_WrongName"", ""input"")|};
+}
+
+[Function(nameof(ChildOrchestration))]
+async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context)
+{
+ await Task.CompletedTask;
+}
+");
+ DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("ChildOrchestration_WrongName");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task SubOrchestrationInvocationWithNonExistentOrchestrator_ReportsDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ await {|#0:context.CallSubOrchestratorAsync(""NonExistentOrchestrator"", ""input"")|};
+}
+");
+ DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("NonExistentOrchestrator");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task SubOrchestrationInvocationWithClassBasedOrchestrator_NoDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapTaskOrchestrator(@"
+public class ParentOrchestration : TaskOrchestrator
+{
+ public override async Task RunAsync(TaskOrchestrationContext context, string input)
+ {
+ await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input"");
+ return ""done"";
+ }
+}
+
+public class ChildOrchestration : TaskOrchestrator
+{
+ public override Task RunAsync(TaskOrchestrationContext context, string input)
+ {
+ return Task.FromResult(input);
+ }
+}
+");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task SubOrchestrationInvocationWithLambdaOrchestrator_NoDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapFuncOrchestrator(@"
+tasks.AddOrchestratorFunc(""ParentOrchestration"", async context =>
+ await context.CallSubOrchestratorAsync(""ChildOrchestration"", ""input""));
+
+tasks.AddOrchestratorFunc(""ChildOrchestration"", context => Task.CompletedTask);
+");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task SubOrchestrationInvocationWithMismatchedLambdaOrchestrator_ReportsDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapFuncOrchestrator(@"
+tasks.AddOrchestratorFunc(""ParentOrchestration"", async context =>
+ await {|#0:context.CallSubOrchestratorAsync(""ChildOrchestration_WrongName"", ""input"")|});
+
+tasks.AddOrchestratorFunc(""ChildOrchestration"", context => Task.CompletedTask);
+");
+ DiagnosticResult expected = BuildSubOrchestrationNotFoundDiagnostic().WithLocation(0).WithArguments("ChildOrchestration_WrongName");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ [Fact]
+ public async Task SubOrchestrationInvocationWithTypedResult_NoDiagnostic()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ int result = await context.CallSubOrchestratorAsync(nameof(ChildOrchestration), ""input"");
+}
+
+[Function(nameof(ChildOrchestration))]
+async Task ChildOrchestration([OrchestrationTrigger] TaskOrchestrationContext context)
+{
+ return 42;
+}
+");
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code);
+ }
+
+ [Fact]
+ public async Task MultipleInvocationsWithSomeMissingFunctions_ReportsMultipleDiagnostics()
+ {
+ // Arrange
+ string code = Wrapper.WrapDurableFunctionOrchestration(@"
+async Task Method(TaskOrchestrationContext context)
+{
+ await context.CallActivityAsync(nameof(ExistingActivity), ""input"");
+ await {|#0:context.CallActivityAsync(""MissingActivity"", ""input"")|};
+ await context.CallSubOrchestratorAsync(nameof(ExistingOrchestrator), ""input"");
+ await {|#1:context.CallSubOrchestratorAsync(""MissingOrchestrator"", ""input"")|};
+}
+
+[Function(nameof(ExistingActivity))]
+void ExistingActivity([ActivityTrigger] string name) { }
+
+[Function(nameof(ExistingOrchestrator))]
+async Task ExistingOrchestrator([OrchestrationTrigger] TaskOrchestrationContext context)
+{
+ await Task.CompletedTask;
+}
+");
+ DiagnosticResult[] expected =
+ [
+ BuildActivityNotFoundDiagnostic().WithLocation(0).WithArguments("MissingActivity"),
+ BuildSubOrchestrationNotFoundDiagnostic().WithLocation(1).WithArguments("MissingOrchestrator")
+ ];
+
+ // Act & Assert
+ await VerifyCS.VerifyDurableTaskAnalyzerAsync(code, expected);
+ }
+
+ static DiagnosticResult BuildActivityNotFoundDiagnostic()
+ {
+ return VerifyCS.Diagnostic(FunctionNotFoundAnalyzer.ActivityNotFoundDiagnosticId);
+ }
+
+ static DiagnosticResult BuildSubOrchestrationNotFoundDiagnostic()
+ {
+ return VerifyCS.Diagnostic(FunctionNotFoundAnalyzer.SubOrchestrationNotFoundDiagnosticId);
+ }
+}
diff --git a/test/Generators.Tests/AzureFunctionsTests.cs b/test/Generators.Tests/AzureFunctionsTests.cs
index 6ae9523d..d9d7fad0 100644
--- a/test/Generators.Tests/AzureFunctionsTests.cs
+++ b/test/Generators.Tests/AzureFunctionsTests.cs
@@ -27,6 +27,10 @@ public class Calculator
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: @"
+///
+/// Calls the activity.
+///
+///
public static Task CallIdentityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null)
{
return ctx.CallActivityAsync(""Identity"", input, options);
@@ -57,6 +61,10 @@ public class Calculator
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: @"
+///
+/// Calls the activity.
+///
+///
public static Task CallIdentityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null)
{
return ctx.CallActivityAsync(""Identity"", input, options);
@@ -92,6 +100,10 @@ public class Calculator
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: @"
+///
+/// Calls the activity.
+///
+///
public static Task CallIdentityAsync(this TaskOrchestrationContext ctx, AzureFunctionsTests.Input input, TaskOptions? options = null)
{
return ctx.CallActivityAsync(""Identity"", input, options);
@@ -141,6 +153,10 @@ public class MyActivity : TaskActivity<{inputType}, {outputType}>
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: $@"
+///
+/// Calls the activity.
+///
+///
public static Task<{outputType}> CallMyActivityAsync(this TaskOrchestrationContext ctx, {inputType} input, TaskOptions? options = null)
{{
return ctx.CallActivityAsync<{outputType}>(""MyActivity"", input, options);
@@ -214,6 +230,9 @@ public class MyOrchestrator : TaskOrchestrator<{inputType}, {outputType}>
.ContinueWith(t => ({outputType})(t.Result ?? default({outputType})!), TaskContinuationOptions.ExecuteSynchronously);
}}
+///
+/// Schedules a new instance of the orchestrator.
+///
///
public static Task ScheduleNewMyOrchestratorInstanceAsync(
this IOrchestrationSubmitter client, {expectedInputParameter}, StartOrchestrationOptions? options = null)
@@ -221,6 +240,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync(
return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options);
}}
+///
+/// Calls the sub-orchestrator.
+///
///
public static Task<{outputType}> CallMyOrchestratorAsync(
this TaskOrchestrationContext context, {expectedInputParameter}, TaskOptions? options = null)
@@ -291,6 +313,9 @@ public abstract class MyOrchestratorBase : TaskOrchestrator<{inputType}, {output
.ContinueWith(t => ({outputType})(t.Result ?? default({outputType})!), TaskContinuationOptions.ExecuteSynchronously);
}}
+///
+/// Schedules a new instance of the orchestrator.
+///
///
public static Task ScheduleNewMyOrchestratorInstanceAsync(
this IOrchestrationSubmitter client, {expectedInputParameter}, StartOrchestrationOptions? options = null)
@@ -298,6 +323,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync(
return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options);
}}
+///
+/// Calls the sub-orchestrator.
+///
///
public static Task<{outputType}> CallMyOrchestratorAsync(
this TaskOrchestrationContext context, {expectedInputParameter}, TaskOptions? options = null)
@@ -493,6 +521,9 @@ public static Task MyOrchestrator([OrchestrationTrigger] TaskOrchestrati
.ContinueWith(t => (string)(t.Result ?? default(string)!), TaskContinuationOptions.ExecuteSynchronously);
}}
+///
+/// Schedules a new instance of the orchestrator.
+///
///
public static Task ScheduleNewMyOrchestratorInstanceAsync(
this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null)
@@ -500,6 +531,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync(
return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options);
}}
+///
+/// Calls the sub-orchestrator.
+///
///
public static Task CallMyOrchestratorAsync(
this TaskOrchestrationContext context, int input, TaskOptions? options = null)
@@ -507,6 +541,10 @@ public static Task CallMyOrchestratorAsync(
return context.CallSubOrchestratorAsync(""MyOrchestrator"", input, options);
}}
+///
+/// Calls the activity.
+///
+///
public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null)
{{
return ctx.CallActivityAsync(""MyActivity"", input, options);
diff --git a/test/Generators.Tests/ClassBasedSyntaxTests.cs b/test/Generators.Tests/ClassBasedSyntaxTests.cs
index d0cf51d5..c3abc6c5 100644
--- a/test/Generators.Tests/ClassBasedSyntaxTests.cs
+++ b/test/Generators.Tests/ClassBasedSyntaxTests.cs
@@ -31,6 +31,9 @@ class MyOrchestrator : TaskOrchestrator<{type}, string>
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: $@"
+///
+/// Schedules a new instance of the orchestrator.
+///
///
public static Task ScheduleNewMyOrchestratorInstanceAsync(
this IOrchestrationSubmitter client, {input}, StartOrchestrationOptions? options = null)
@@ -38,6 +41,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync(
return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options);
}}
+///
+/// Calls the sub-orchestrator.
+///
///
public static Task CallMyOrchestratorAsync(
this TaskOrchestrationContext context, {input}, TaskOptions? options = null)
@@ -80,6 +86,9 @@ abstract class MyOrchestratorBase : TaskOrchestrator
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: @"
+///
+/// Schedules a new instance of the orchestrator.
+///
///
public static Task ScheduleNewMyOrchestratorInstanceAsync(
this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null)
@@ -87,6 +96,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync(
return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options);
}
+///
+/// Calls the sub-orchestrator.
+///
///
public static Task CallMyOrchestratorAsync(
this TaskOrchestrationContext context, int input, TaskOptions? options = null)
@@ -129,6 +141,10 @@ class MyActivity : TaskActivity<{type}, string>
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: $@"
+///
+/// Calls the activity.
+///
+///
public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, {input}, TaskOptions? options = null)
{{
return ctx.CallActivityAsync(""MyActivity"", input, options);
@@ -169,6 +185,10 @@ public class MyClass { }
string expectedOutput = TestHelpers.WrapAndFormat(
generatedClassName: "GeneratedDurableTaskExtensions",
methodList: @"
+///
+/// Calls the activity.
+///
+///
public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, MyNS.MyClass input, TaskOptions? options = null)
{
return ctx.CallActivityAsync(""MyActivity"", input, options);
@@ -211,6 +231,10 @@ public class MyClass { }
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: @"
+///
+/// Calls the activity.
+///
+///
public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, MyNS.MyClass input, TaskOptions? options = null)
{
return ctx.CallActivityAsync(""MyActivity"", input, options);
@@ -250,6 +274,10 @@ abstract class MyActivityBase : TaskActivity
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: @"
+///
+/// Calls the activity.
+///
+///
public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null)
{
return ctx.CallActivityAsync(""MyActivity"", input, options);
@@ -440,6 +468,9 @@ class MyEntity : TaskEntity
string expectedOutput = TestHelpers.WrapAndFormat(
GeneratedClassName,
methodList: @"
+///
+/// Schedules a new instance of the orchestrator.
+///
///
public static Task ScheduleNewMyOrchestratorInstanceAsync(
this IOrchestrationSubmitter client, int input, StartOrchestrationOptions? options = null)
@@ -447,6 +478,9 @@ public static Task ScheduleNewMyOrchestratorInstanceAsync(
return client.ScheduleNewOrchestrationInstanceAsync(""MyOrchestrator"", input, options);
}
+///
+/// Calls the sub-orchestrator.
+///
///
public static Task CallMyOrchestratorAsync(
this TaskOrchestrationContext context, int input, TaskOptions? options = null)
@@ -454,6 +488,10 @@ public static Task CallMyOrchestratorAsync(
return context.CallSubOrchestratorAsync(""MyOrchestrator"", input, options);
}
+///
+/// Calls the activity.
+///
+///
public static Task CallMyActivityAsync(this TaskOrchestrationContext ctx, int input, TaskOptions? options = null)
{
return ctx.CallActivityAsync(""MyActivity"", input, options);
diff --git a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs
index 666d7e25..8203c57f 100644
--- a/test/Grpc.IntegrationTests/OrchestrationPatterns.cs
+++ b/test/Grpc.IntegrationTests/OrchestrationPatterns.cs
@@ -1195,6 +1195,180 @@ async Task OrchestratorFunc(TaskOrchestrationContext ctx, int counter)
Assert.Equal(EventCount, metadata.ReadOutputAs());
}
+ [Fact]
+ public async Task CatchingActivityExceptionsByType()
+ {
+ TaskName orchestratorName = nameof(CatchingActivityExceptionsByType);
+ TaskName throwInvalidOpActivityName = "ThrowInvalidOp";
+ TaskName throwArgumentActivityName = "ThrowArgument";
+ TaskName successActivityName = "Success";
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(b =>
+ {
+ b.AddTasks(tasks => tasks
+ .AddOrchestratorFunc(orchestratorName, async ctx =>
+ {
+ List results = new();
+
+ // Test 1: Catch InvalidOperationException
+ try
+ {
+ await ctx.CallActivityAsync(throwInvalidOpActivityName);
+ results.Add("No exception thrown");
+ }
+ catch (TaskFailedException ex) when (ex.FailureDetails?.IsCausedBy() == true)
+ {
+ results.Add("Caught InvalidOperationException");
+ }
+ catch (TaskFailedException)
+ {
+ results.Add("Caught wrong exception type");
+ }
+
+ // Test 2: Catch ArgumentException
+ try
+ {
+ await ctx.CallActivityAsync(throwArgumentActivityName);
+ results.Add("No exception thrown");
+ }
+ catch (TaskFailedException ex) when (ex.FailureDetails?.IsCausedBy() == true)
+ {
+ results.Add("Caught ArgumentException");
+ }
+ catch (TaskFailedException)
+ {
+ results.Add("Caught wrong exception type");
+ }
+
+ // Test 3: Successful activity should not throw
+ try
+ {
+ string result = await ctx.CallActivityAsync(successActivityName);
+ results.Add(result);
+ }
+ catch (TaskFailedException)
+ {
+ results.Add("Unexpected exception");
+ }
+
+ // Test 4: Catch with base Exception type
+ try
+ {
+ await ctx.CallActivityAsync(throwInvalidOpActivityName);
+ results.Add("No exception thrown");
+ }
+ catch (TaskFailedException ex) when (ex.FailureDetails?.IsCausedBy() == true)
+ {
+ results.Add("Caught base Exception");
+ }
+
+ return results;
+ })
+ .AddActivityFunc(throwInvalidOpActivityName, (TaskActivityContext ctx) =>
+ {
+ throw new InvalidOperationException("Invalid operation");
+ })
+ .AddActivityFunc(throwArgumentActivityName, (TaskActivityContext ctx) =>
+ {
+ throw new ArgumentException("Invalid argument");
+ })
+ .AddActivityFunc(successActivityName, (TaskActivityContext ctx) =>
+ {
+ return "Success";
+ }));
+ });
+
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName);
+ OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync(
+ instanceId, getInputsAndOutputs: true, this.TimeoutToken);
+ Assert.NotNull(metadata);
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus);
+
+ List? results = metadata.ReadOutputAs>();
+ Assert.NotNull(results);
+ Assert.Equal(4, results!.Count);
+ Assert.Equal("Caught InvalidOperationException", results[0]);
+ Assert.Equal("Caught ArgumentException", results[1]);
+ Assert.Equal("Success", results[2]);
+ Assert.Equal("Caught base Exception", results[3]);
+ }
+
// TODO: Test for multiple external events with the same name
- // TODO: Test for catching activity exceptions of specific types
+
+ [Fact]
+ public async Task ActivityInput_ComplexTypesInDictionary_PreservesTypes()
+ {
+ // Previously, when passing Dictionary to activities, the object values
+ // would be deserialized as JsonElement instead of their original types.
+ // This test verifies that complex types are now properly preserved.
+ TaskName orchestratorName = "ComplexInputOrchestrator";
+ TaskName activityName = "ComplexInputActivity";
+
+ Dictionary capturedInput = null!;
+
+ await using HostTestLifetime server = await this.StartWorkerAsync(b =>
+ {
+ b.AddTasks(tasks => tasks
+ .AddOrchestratorFunc(orchestratorName, async (ctx, _) =>
+ {
+ Dictionary complexInput = new()
+ {
+ { "StringValue", "test" },
+ { "IntValue", 42 },
+ { "BoolValue", true },
+ { "NestedObject", new { Name = "TestObject", Id = 123 } },
+ { "ArrayValue", new object[] { "item1", 2, false } },
+ };
+
+ return await ctx.CallActivityAsync(activityName, complexInput);
+ })
+ .AddActivityFunc, string>(activityName, (ctx, input) =>
+ {
+ capturedInput = input;
+ return Task.FromResult("success");
+ }));
+ });
+
+ string instanceId = await server.Client.ScheduleNewOrchestrationInstanceAsync(orchestratorName);
+ OrchestrationMetadata metadata = await server.Client.WaitForInstanceCompletionAsync(
+ instanceId, getInputsAndOutputs: true, this.TimeoutToken);
+
+ Assert.NotNull(metadata);
+ Assert.Equal(OrchestrationRuntimeStatus.Completed, metadata.RuntimeStatus);
+ Assert.Equal("success", metadata.ReadOutputAs());
+
+ // Verify that the input was correctly deserialized with concrete types, not JsonElement
+ Assert.NotNull(capturedInput);
+
+ // String should remain a string
+ Assert.IsType(capturedInput["StringValue"]);
+ Assert.Equal("test", capturedInput["StringValue"]);
+
+ // Int should remain an int
+ Assert.IsType(capturedInput["IntValue"]);
+ Assert.Equal(42, capturedInput["IntValue"]);
+
+ // Bool should remain a bool
+ Assert.IsType(capturedInput["BoolValue"]);
+ Assert.Equal(true, capturedInput["BoolValue"]);
+
+ // Nested object should be a Dictionary, not JsonElement
+ Assert.IsType>(capturedInput["NestedObject"]);
+ Dictionary nestedObject = (Dictionary)capturedInput["NestedObject"];
+ Assert.IsType(nestedObject["Name"]);
+ Assert.Equal("TestObject", nestedObject["Name"]);
+ Assert.IsType(nestedObject["Id"]);
+ Assert.Equal(123, nestedObject["Id"]);
+
+ // Array should be object[], not JsonElement
+ Assert.IsType(capturedInput["ArrayValue"]);
+ object[] arrayValue = (object[])capturedInput["ArrayValue"];
+ Assert.Equal(3, arrayValue.Length);
+ Assert.IsType(arrayValue[0]);
+ Assert.Equal("item1", arrayValue[0]);
+ Assert.IsType(arrayValue[1]);
+ Assert.Equal(2, arrayValue[1]);
+ Assert.IsType(arrayValue[2]);
+ Assert.Equal(false, arrayValue[2]);
+ }
}