diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md index 1fa375e8a91..c22b1f3cd0d 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md @@ -1,7 +1,8 @@ -# Release History +# Release History ## [Unreleased] +- Fix issue with resuming checkpoint after package version upgrade ([#6670](https://github.com/microsoft/agent-framework/pull/6670)) - Bind MCP threadId to the current agent and guard cross-agent session dispatch ([#6531](https://github.com/microsoft/agent-framework/pull/6531)) - Added support for durable workflows ([#4436](https://github.com/microsoft/agent-framework/pull/4436)) diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs index c9e9a1b125a..116c6aa1e6f 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableActivityExecutor.cs @@ -151,8 +151,13 @@ internal static Type ResolveInputType(string? inputTypeName, ISet supporte return supportedTypes.FirstOrDefault() ?? typeof(string); } + Type? loadedType = DurableTaskTypeResolver.Resolve(inputTypeName); + if (loadedType is not null && supportedTypes.Contains(loadedType)) + { + return loadedType; + } + Type? matchedType = supportedTypes.FirstOrDefault(t => - t.AssemblyQualifiedName == inputTypeName || t.FullName == inputTypeName || t.Name == inputTypeName); @@ -161,8 +166,6 @@ internal static Type ResolveInputType(string? inputTypeName, ISet supporte return matchedType; } - Type? loadedType = Type.GetType(inputTypeName); - // Fall back if type is string or string[] but executor doesn't support it if (loadedType is not null && !supportedTypes.Contains(loadedType)) { diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs index 6cacf871e02..d05d01b4549 100644 --- a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableStreamingWorkflowRun.cs @@ -383,7 +383,7 @@ private static bool TryParseWorkflowResult(string? serializedOutput, [NotNullWhe if (wrapper?.TypeName is not null && wrapper.Data is not null) { - Type? eventType = Type.GetType(wrapper.TypeName); + Type? eventType = DurableTaskTypeResolver.Resolve(wrapper.TypeName); if (eventType is not null) { return DeserializeEventByType(eventType, wrapper.Data); diff --git a/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableTaskTypeResolver.cs b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableTaskTypeResolver.cs new file mode 100644 index 00000000000..c8dd74ef59b --- /dev/null +++ b/dotnet/src/Microsoft.Agents.AI.DurableTask/Workflows/DurableTaskTypeResolver.cs @@ -0,0 +1,39 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System.Collections.Concurrent; +using System.Diagnostics.CodeAnalysis; +using Microsoft.Agents.AI.Workflows.Checkpointing; + +namespace Microsoft.Agents.AI.DurableTask.Workflows; + +/// +/// Resolves persisted assembly-qualified type-name strings to a loaded , +/// tolerating differences in assembly version, culture, and public key token between the +/// persisted name and the currently loaded assemblies. Results are cached. +/// +internal static class DurableTaskTypeResolver +{ + private static readonly ConcurrentDictionary s_cache = new(); + + /// + /// Resolves using a qualified + /// lookup, then a partial-name fallback that strips embedded version, culture, and public key + /// token qualifiers. + /// + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Workflow message and event types are registered at startup.")] + [UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Workflow message and event types are registered at startup.")] + internal static Type? Resolve(string typeName) + => s_cache.GetOrAdd(typeName, static name => + { + Type? type = Type.GetType(name, throwOnError: false); + if (type is not null) + { + return type; + } + + string normalized = TypeId.NormalizeTypeName(name); + return ReferenceEquals(normalized, name) + ? null + : Type.GetType(normalized, throwOnError: false); + }); +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorResolveInputTypeTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorResolveInputTypeTests.cs new file mode 100644 index 00000000000..bf6c47ad3f7 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableActivityExecutorResolveInputTypeTests.cs @@ -0,0 +1,104 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +/// +/// Verifies that +/// matches persisted assembly-qualified type-name strings against the executor's supported +/// input types even when the persisted name carries a different assembly version, culture, +/// or public key token than the loaded assemblies. +/// +public sealed class DurableActivityExecutorResolveInputTypeTests +{ + [Fact] + public void ResolveInputType_NullInput_ReturnsFirstSupportedType() + { + Type result = DurableActivityExecutor.ResolveInputType(null, new HashSet { typeof(int) }); + + Assert.Equal(typeof(int), result); + } + + [Fact] + public void ResolveInputType_EmptyInputAndNoSupportedTypes_FallsBackToString() + { + Type result = DurableActivityExecutor.ResolveInputType(string.Empty, new HashSet()); + + Assert.Equal(typeof(string), result); + } + + [Fact] + public void ResolveInputType_LoadedAssemblyQualifiedName_ReturnsSupportedType() + { + Type supported = typeof(ChatMessage); + ISet supportedTypes = new HashSet { supported }; + + Type result = DurableActivityExecutor.ResolveInputType(supported.AssemblyQualifiedName, supportedTypes); + + Assert.Same(supported, result); + } + + [Fact] + public void ResolveInputType_MutatedAssemblyVersion_ReturnsSupportedType() + { + Type supported = typeof(ChatMessage); + string simpleAssemblyName = supported.Assembly.GetName().Name!; + string mutated = $"{supported.FullName}, {simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; + ISet supportedTypes = new HashSet { supported }; + + Type result = DurableActivityExecutor.ResolveInputType(mutated, supportedTypes); + + Assert.Same(supported, result); + } + + [Fact] + public void ResolveInputType_MutatedGenericArgumentVersion_ReturnsSupportedType() + { + Type supported = typeof(List); + string outerSimple = supported.Assembly.GetName().Name!; + string innerSimple = typeof(ChatMessage).Assembly.GetName().Name!; + string mutated = + $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimple}, " + + "Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]], " + + $"{outerSimple}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; + ISet supportedTypes = new HashSet { supported }; + + Type result = DurableActivityExecutor.ResolveInputType(mutated, supportedTypes); + + Assert.Same(supported, result); + } + + [Fact] + public void ResolveInputType_ShortNameMatch_ReturnsSupportedType() + { + Type supported = typeof(ChatMessage); + ISet supportedTypes = new HashSet { supported }; + + Type result = DurableActivityExecutor.ResolveInputType(supported.Name, supportedTypes); + + Assert.Same(supported, result); + } + + [Fact] + public void ResolveInputType_FullNameMatch_ReturnsSupportedType() + { + Type supported = typeof(ChatMessage); + ISet supportedTypes = new HashSet { supported }; + + Type result = DurableActivityExecutor.ResolveInputType(supported.FullName, supportedTypes); + + Assert.Same(supported, result); + } + + [Fact] + public void ResolveInputType_StringFallback_ReturnsFirstSupportedTypeWhenStringUnsupported() + { + ISet supportedTypes = new HashSet { typeof(int) }; + + Type result = DurableActivityExecutor.ResolveInputType(typeof(string).AssemblyQualifiedName, supportedTypes); + + Assert.Equal(typeof(int), result); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs index 8aef99e3e1a..404ed3496d6 100644 --- a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableStreamingWorkflowRunTests.cs @@ -373,6 +373,55 @@ public async Task WatchStreamAsync_EventsInCustomStatus_YieldsEventsBeforeComple Assert.Equal("final", completedResult.Result); } + [Fact] + public async Task WatchStreamAsync_EventTypeNameHasMutatedAssemblyVersion_StillDeserializesAsync() + { + // Arrange — model what happens after a package upgrade: the persisted TypedPayload.TypeName + // carries an assembly Version= that no longer matches any loaded assembly. + DurableHaltRequestedEvent haltEvent = new("exec-1"); + Type eventType = haltEvent.GetType(); + string outerSimpleName = eventType.Assembly.GetName().Name!; + string mutatedTypeName = $"{eventType.FullName}, {outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; + TypedPayload wrapper = new() + { + TypeName = mutatedTypeName, + Data = JsonSerializer.Serialize(haltEvent, eventType, DurableSerialization.Options) + }; + string serializedEvent = JsonSerializer.Serialize(wrapper, DurableWorkflowJsonContext.Default.TypedPayload); + string customStatus = SerializeCustomStatus([serializedEvent]); + string serializedOutput = SerializeWorkflowResult("final", []); + + int callCount = 0; + Mock mockClient = new("test"); + mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny())) + .ReturnsAsync(() => + { + callCount++; + if (callCount == 1) + { + return CreateMetadata(OrchestrationRuntimeStatus.Running, serializedCustomStatus: customStatus); + } + + return CreateMetadata(OrchestrationRuntimeStatus.Completed, serializedOutput: serializedOutput); + }); + + DurableStreamingWorkflowRun run = new(mockClient.Object, InstanceId, CreateTestWorkflow()); + + // Act + List events = []; + await foreach (WorkflowEvent evt in run.WatchStreamAsync()) + { + events.Add(evt); + } + + // Assert + Assert.Equal(2, events.Count); + DurableHaltRequestedEvent haltResult = Assert.IsType(events[0]); + Assert.Equal("exec-1", haltResult.ExecutorId); + DurableWorkflowCompletedEvent completedResult = Assert.IsType(events[1]); + Assert.Equal("final", completedResult.Result); + } + [Fact] public async Task WatchStreamAsync_IncrementalEvents_YieldsOnlyNewEventsPerPollAsync() { diff --git a/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableTaskTypeResolverTests.cs b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableTaskTypeResolverTests.cs new file mode 100644 index 00000000000..624042fc765 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.DurableTask.UnitTests/Workflows/DurableTaskTypeResolverTests.cs @@ -0,0 +1,75 @@ +// Copyright (c) Microsoft. All rights reserved. + +using Microsoft.Agents.AI.DurableTask.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.DurableTask.UnitTests.Workflows; + +/// +/// Verifies that resolves persisted +/// assembly-qualified type-name strings to a loaded across assembly +/// version, culture, and public key token mutations. +/// +public sealed class DurableTaskTypeResolverTests +{ + [Fact] + public void Resolve_LoadedAssemblyQualifiedName_ReturnsLiveType() + { + Type live = typeof(List); + string aqn = live.AssemblyQualifiedName!; + + Type? resolved = DurableTaskTypeResolver.Resolve(aqn); + + Assert.Same(live, resolved); + } + + [Fact] + public void Resolve_MutatedOuterAssemblyVersion_ReturnsLiveType() + { + Type live = typeof(ChatMessage); + string outerSimpleName = live.Assembly.GetName().Name!; + string mutated = $"{live.FullName}, {outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; + + Type? resolved = DurableTaskTypeResolver.Resolve(mutated); + + Assert.Same(live, resolved); + } + + [Fact] + public void Resolve_MutatedGenericArgumentVersion_ReturnsLiveType() + { + Type live = typeof(List); + string outerSimpleName = live.Assembly.GetName().Name!; + string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!; + string mutated = + $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, " + + "Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]], " + + $"{outerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; + + Type? resolved = DurableTaskTypeResolver.Resolve(mutated); + + Assert.Same(live, resolved); + } + + [Fact] + public void Resolve_UnknownType_ReturnsNull() + { + Type? resolved = DurableTaskTypeResolver.Resolve( + "Some.Unknown.Namespace.MissingType, Some.Unloaded.Assembly, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null"); + + Assert.Null(resolved); + } + + [Fact] + public void Resolve_CachesResults() + { + Type live = typeof(ChatMessage); + string aqn = live.AssemblyQualifiedName!; + + Type? first = DurableTaskTypeResolver.Resolve(aqn); + Type? second = DurableTaskTypeResolver.Resolve(aqn); + + Assert.Same(first, second); + Assert.Same(live, second); + } +}