diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/TypeId.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/TypeId.cs index 79436773d9d..73b8dfb3814 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/TypeId.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/TypeId.cs @@ -1,7 +1,10 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.IO; +using System.Reflection; using System.Text.Json.Serialization; +using System.Text.RegularExpressions; using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows.Checkpointing; @@ -11,7 +14,7 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing; /// public sealed class TypeId : IEquatable { - /// + /// public string AssemblyName { get; } /// @@ -46,6 +49,11 @@ public override bool Equals(object? obj) => this.Equals(obj as TypeId); /// + /// + /// Compares the type full name and the simple assembly name. Version, culture, and public key + /// token are ignored both in and in any assembly-qualified generic + /// arguments embedded in . + /// public bool Equals(TypeId? other) { if (other is null) @@ -58,11 +66,27 @@ public bool Equals(TypeId? other) return true; } - return this.AssemblyName == other.AssemblyName && this.TypeName == other.TypeName; + if (this.NormalizedTypeName != other.NormalizedTypeName) + { + return false; + } + + if (string.Equals(this.AssemblyName, other.AssemblyName, StringComparison.Ordinal)) + { + return true; + } + + string? thisSimpleName = this.SimpleAssemblyName; + string? otherSimpleName = other.SimpleAssemblyName; + + return thisSimpleName is not null + && string.Equals(thisSimpleName, otherSimpleName, StringComparison.Ordinal); } /// - public override int GetHashCode() => HashCode.Combine(this.AssemblyName, this.TypeName); + /// Hashes the normalized type name and the simple assembly name. + public override int GetHashCode() + => HashCode.Combine(this.SimpleAssemblyName, this.NormalizedTypeName); /// public static bool operator ==(TypeId? left, TypeId? right) => left is null ? right is null : left.Equals(right); @@ -73,13 +97,27 @@ public bool Equals(TypeId? other) /// /// Determines whether the specified type matches both the assembly name and type name represented by this instance. /// + /// + /// Compares the type full name and the simple assembly name. Version, culture, and public key + /// token are ignored both in and in any assembly-qualified generic + /// arguments embedded in . + /// /// The type to compare against the stored assembly and type names. Cannot be null. - /// true if the specified type's assembly and type names are equal to those stored in this instance; otherwise, - /// false. + /// true if the specified type's assembly simple name and normalized type full name are equal to those stored + /// in this instance; otherwise, false. public bool IsMatch(Type type) { - return this.AssemblyName == type.Assembly.FullName - && this.TypeName == type.FullName; + string? runtimeNormalizedTypeName = type.FullName is null ? null : NormalizeTypeName(type.FullName); + if (this.NormalizedTypeName != runtimeNormalizedTypeName) + { + return false; + } + + string? storedSimpleName = this.SimpleAssemblyName; + string? runtimeSimpleName = type.Assembly.GetName().Name; + + return storedSimpleName is not null + && string.Equals(storedSimpleName, runtimeSimpleName, StringComparison.Ordinal); } /// @@ -113,4 +151,64 @@ public bool IsMatchPolymorphic(Type type) /// public override string ToString() => $"{this.TypeName}, {this.AssemblyName}"; + + /// + /// The simple assembly name parsed from , lazily computed and cached. + /// + internal string? SimpleAssemblyName + => field ??= GetSimpleAssemblyName(this.AssemblyName); + + /// + /// The type full name with embedded assembly-qualified generic arguments stripped of + /// version, culture, and public key token. Lazily computed and cached. + /// + internal string NormalizedTypeName + => field ??= NormalizeTypeName(this.TypeName); + + private static readonly Regex s_assemblyQualifierPattern = new( + @", Version=[^,\]]+, Culture=[^,\]]+, PublicKeyToken=[^,\]]+", + RegexOptions.Compiled | RegexOptions.CultureInvariant); + + /// + /// Removes , Version=..., , Culture=..., and , PublicKeyToken=... triplets + /// from . Returns the input unchanged when no triplet is present. + /// + internal static string NormalizeTypeName(string typeName) + { + if (typeName.IndexOf("Version=", StringComparison.Ordinal) < 0) + { + return typeName; + } + + return s_assemblyQualifierPattern.Replace(typeName, string.Empty); + } + + /// + /// Returns the simple assembly name parsed from an -style string, + /// or when both parsing and the substring fallback fail. + /// + internal static string? GetSimpleAssemblyName(string assemblyFullName) + { + if (string.IsNullOrEmpty(assemblyFullName)) + { + return null; + } + + try + { + string? parsed = new AssemblyName(assemblyFullName).Name; + if (!string.IsNullOrEmpty(parsed)) + { + return parsed; + } + } + catch (Exception ex) when (ex is FileLoadException or ArgumentException) + { + // Fall through to substring fallback. + } + + int comma = assemblyFullName.IndexOf(','); + string fallback = (comma < 0 ? assemblyFullName : assemblyFullName.Substring(0, comma)).Trim(); + return fallback.Length == 0 ? null : fallback; + } } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 812bda11509..d7234ab1e02 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.Linq; @@ -297,12 +298,14 @@ private async ValueTask SendMessagesWithResponseConversionAs /// interface assignment succeeds. /// [UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")] + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")] + [UnconditionalSuppressMessage("Trimming", "IL2073:Members annotated with 'DynamicallyAccessedMembersAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")] private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope) { envelope = null; TypeId requestType = request.PortInfo.RequestType; - Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false); + Type? concreteType = ResolveTypeLenient(requestType); if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType)) { return false; @@ -317,6 +320,20 @@ private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen( return true; } + /// Caches results keyed by . + private static readonly ConcurrentDictionary s_envelopeTypeCache = new(); + + /// + /// Resolves a to a loaded using partial-name binding, + /// which matches any loaded assembly with the same simple name regardless of version. Results + /// are cached. + /// + [UnconditionalSuppressMessage("Trimming", "IL2026:Members annotated with 'RequiresUnreferencedCodeAttribute' require dynamic access", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")] + [UnconditionalSuppressMessage("Trimming", "IL2057:Unrecognized value passed to the parameter of method", Justification = "Higher-layer envelope types are explicitly preserved by the package that defines them.")] + internal static Type? ResolveTypeLenient(TypeId typeId) + => s_envelopeTypeCache.GetOrAdd(typeId, static id => + Type.GetType($"{id.NormalizedTypeName}, {id.SimpleAssemblyName}", throwOnError: false)); + /// /// Creates the workflow-facing request content surfaced in response updates. /// diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointVersionToleranceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointVersionToleranceTests.cs new file mode 100644 index 00000000000..e4c80bf7ff9 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/CheckpointVersionToleranceTests.cs @@ -0,0 +1,135 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Agents.AI.Workflows.InProc; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Verifies that a checkpoint serialized through can be restored +/// after every Version=X.Y.Z.W substring in the persisted JSON is rewritten to a different value. +/// +public class CheckpointVersionToleranceTests +{ + private sealed class EchoExecutor() : Executor("Echo") + { + protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) + => protocolBuilder.ConfigureRoutes(routeBuilder => + routeBuilder.AddHandler((msg, ctx) => ctx.SendMessageAsync(msg))); + } + + [Theory] + [InlineData(ExecutionEnvironment.InProcess_OffThread)] + [InlineData(ExecutionEnvironment.InProcess_Lockstep)] + internal async Task Test_Checkpoint_Resumes_AfterAssemblyVersionRewriteAsync(ExecutionEnvironment environment) + { + // Arrange + RequestPort requestPort = RequestPort.Create("TestPort"); + EchoExecutor echo = new(); + + Workflow workflow = new WorkflowBuilder(requestPort) + .AddEdge(requestPort, echo) + .Build(); + + VersionMutatingJsonStore store = new(); + CheckpointManager checkpointManager = CheckpointManager.CreateJson(store); + InProcessExecutionEnvironment env = environment.ToWorkflowExecutionEnvironment(); + + // Run the workflow and capture a checkpoint. + CheckpointInfo? checkpoint = null; + await using (StreamingRun firstRun = await env.WithCheckpointing(checkpointManager) + .RunStreamingAsync(workflow, "Hello")) + { + await foreach (WorkflowEvent evt in firstRun.WatchStreamAsync(blockOnPendingRequest: false)) + { + if (evt is SuperStepCompletedEvent step && step.CompletionInfo?.Checkpoint is { } cp) + { + checkpoint = cp; + } + } + } + + checkpoint.Should().NotBeNull(); + store.MutationApplied.Should().BeFalse(); + + // Resume against the mutated store, which rewrites every Version=X.Y.Z.W in the persisted JSON. + Func resume = async () => + { + await using StreamingRun resumed = await env.WithCheckpointing(checkpointManager) + .ResumeStreamingAsync(workflow, checkpoint!); + using CancellationTokenSource cts = new(TimeSpan.FromSeconds(10)); + await foreach (WorkflowEvent _ in resumed.WatchStreamAsync(blockOnPendingRequest: false, cts.Token)) + { + } + }; + + await resume.Should().NotThrowAsync("resume must succeed when persisted assembly versions differ from loaded ones"); + store.MutationApplied.Should().BeTrue(); + } + + /// + /// JSON checkpoint store that rewrites every Version=N.N.N.N token in the persisted + /// payload at retrieval time. + /// + private sealed class VersionMutatingJsonStore : JsonCheckpointStore + { + private static readonly Regex s_versionPattern = new(@"Version=\d+\.\d+\.\d+\.\d+", RegexOptions.Compiled); + + private readonly Dictionary> _store = []; + + public string ReplacementVersion { get; init; } = "99.0.0.0"; + + public bool MutationApplied { get; private set; } + + public override ValueTask CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null) + { + if (!this._store.TryGetValue(sessionId, out Dictionary? sessionStore)) + { + sessionStore = this._store[sessionId] = []; + } + + CheckpointInfo info = new(sessionId); + sessionStore[info.CheckpointId] = value.Clone(); + return new ValueTask(info); + } + + public override ValueTask RetrieveCheckpointAsync(string sessionId, CheckpointInfo key) + { + if (!this._store.TryGetValue(sessionId, out Dictionary? sessionStore) + || !sessionStore.TryGetValue(key.CheckpointId, out JsonElement raw)) + { + throw new KeyNotFoundException($"Could not retrieve checkpoint with id {key.CheckpointId} for session {sessionId}"); + } + + string rawText = raw.GetRawText(); + string mutatedText = s_versionPattern.Replace(rawText, $"Version={this.ReplacementVersion}"); + + if (!ReferenceEquals(rawText, mutatedText) && rawText != mutatedText) + { + this.MutationApplied = true; + } + + using JsonDocument doc = JsonDocument.Parse(mutatedText); + return new ValueTask(doc.RootElement.Clone()); + } + + public override ValueTask> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null) + { + if (!this._store.TryGetValue(sessionId, out Dictionary? sessionStore)) + { + return new ValueTask>(Array.Empty()); + } + + IEnumerable infos = sessionStore.Keys.Select(id => new CheckpointInfo(sessionId, id)); + return new ValueTask>(infos); + } + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TypeIdVersionToleranceTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TypeIdVersionToleranceTests.cs new file mode 100644 index 00000000000..ef3379dca5d --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TypeIdVersionToleranceTests.cs @@ -0,0 +1,230 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Verifies that and +/// compare on the type full name and the simple assembly name, ignoring version, culture, +/// and public key token both in the outer assembly name and in any assembly-qualified generic +/// arguments embedded in the type full name. +/// +public class TypeIdVersionToleranceTests +{ + [SuppressMessage("Performance", "CA1812", Justification = "Used via typeof() only; never instantiated.")] + private sealed class Probe + { + } + + private static string ProbeSimpleAssemblyName => typeof(Probe).Assembly.GetName().Name!; + private static string ProbeTypeFullName => typeof(Probe).FullName!; + + [Fact] + public void Test_IsMatch_RoundTripsRealType() + { + TypeId id = new(typeof(Probe)); + + id.IsMatch(typeof(Probe)).Should().BeTrue(); + id.IsMatch().Should().BeTrue(); + } + + [Fact] + public void Test_IsMatch_IgnoresAssemblyVersion() + { + string assemblyName = $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; + TypeId id = new(assemblyName, ProbeTypeFullName); + + id.IsMatch(typeof(Probe)).Should().BeTrue("version differences in AssemblyName must not affect matching"); + } + + [Fact] + public void Test_IsMatch_IgnoresCultureAndPublicKeyToken() + { + string assemblyName = $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=en-US, PublicKeyToken=abcdef0123456789"; + TypeId id = new(assemblyName, ProbeTypeFullName); + + id.IsMatch(typeof(Probe)).Should().BeTrue(); + } + + [Fact] + public void Test_IsMatch_AcceptsSimpleAssemblyNameOnly() + { + TypeId id = new(ProbeSimpleAssemblyName, ProbeTypeFullName); + + id.IsMatch(typeof(Probe)).Should().BeTrue(); + } + + [Fact] + public void Test_IsMatch_RejectsDifferentSimpleAssemblyName() + { + TypeId id = new( + assemblyName: "Some.Completely.Different.Assembly, Version=1.0.0.0", + typeName: ProbeTypeFullName); + + id.IsMatch(typeof(Probe)).Should().BeFalse("different simple assembly names must not match"); + } + + [Fact] + public void Test_IsMatch_RejectsDifferentTypeName() + { + TypeId id = new( + assemblyName: $"{ProbeSimpleAssemblyName}, Version=99.0.0.0", + typeName: "Some.Other.Namespace.Probe"); + + id.IsMatch(typeof(Probe)).Should().BeFalse("different type names must not match"); + } + + [Fact] + public void Test_IsMatch_ToleratesMalformedAssemblyName() + { + TypeId id = new( + assemblyName: $"{ProbeSimpleAssemblyName}, Version=not-a-version, Culture=??, PublicKeyToken=???", + typeName: ProbeTypeFullName); + + id.IsMatch(typeof(Probe)).Should().BeTrue("the substring fallback recovers the simple name when AssemblyName parsing fails"); + } + + [Fact] + public void Test_IsMatchPolymorphic_IgnoresAssemblyVersion() + { + TypeId id = new( + assemblyName: $"{typeof(object).Assembly.GetName().Name}, Version=99.0.0.0", + typeName: typeof(object).FullName!); + + id.IsMatchPolymorphic(typeof(Probe)).Should().BeTrue("IsMatchPolymorphic uses the same comparison rules as IsMatch"); + } + + [Fact] + public void Test_Equals_IgnoresAssemblyVersion() + { + TypeId v1 = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName); + TypeId v2 = new($"{ProbeSimpleAssemblyName}, Version=2.0.0.0", ProbeTypeFullName); + + v1.Equals(v2).Should().BeTrue(); + (v1 == v2).Should().BeTrue(); + v1.GetHashCode().Should().Be(v2.GetHashCode()); + } + + [Fact] + public void Test_Equals_RejectsDifferentSimpleAssemblyName() + { + TypeId a = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName); + TypeId b = new("Some.Other.Assembly, Version=1.0.0.0", ProbeTypeFullName); + + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void Test_Equals_RejectsDifferentTypeName() + { + TypeId a = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", ProbeTypeFullName); + TypeId b = new($"{ProbeSimpleAssemblyName}, Version=1.0.0.0", "Some.Other.Type"); + + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void Test_Dictionary_LookupAcrossVersions() + { + TypeId live = new(typeof(Probe)); + TypeId mutated = new( + assemblyName: $"{ProbeSimpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null", + typeName: ProbeTypeFullName); + + Dictionary map = new() { [live] = "value" }; + map.TryGetValue(mutated, out string? value).Should().BeTrue(); + value.Should().Be("value"); + + HashSet set = new() { live }; + set.Contains(mutated).Should().BeTrue(); + } + + [Fact] + public void Test_Equals_TreatsIdenticalStringsAsEqual() + { + TypeId a = new(typeof(Probe)); + TypeId b = new(typeof(Probe)); + + a.Equals(b).Should().BeTrue(); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void Test_NormalizeTypeName_ReturnsInputWhenNoAssemblyQualifier() + { + const string TypeName = "Microsoft.Agents.AI.Workflows.Checkpointing.TypeId"; + + TypeId.NormalizeTypeName(TypeName).Should().BeSameAs(TypeName); + } + + [Fact] + public void Test_NormalizeTypeName_StripsVersionCultureAndPublicKeyTokenTriplets() + { + const string TypeName = "System.Collections.Generic.List`1[[Some.Type, Some.Asm, Version=1.2.3.4, Culture=neutral, PublicKeyToken=abcdef0123456789]]"; + const string Expected = "System.Collections.Generic.List`1[[Some.Type, Some.Asm]]"; + + TypeId.NormalizeTypeName(TypeName).Should().Be(Expected); + } + + [Fact] + public void Test_NormalizeTypeName_StripsTripletsFromNestedGenericArguments() + { + const string TypeName = "System.Collections.Generic.Dictionary`2[[System.String, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089],[System.Collections.Generic.List`1[[Some.Type, Some.Asm, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null]], mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089]]"; + const string Expected = "System.Collections.Generic.Dictionary`2[[System.String, mscorlib],[System.Collections.Generic.List`1[[Some.Type, Some.Asm]], mscorlib]]"; + + TypeId.NormalizeTypeName(TypeName).Should().Be(Expected); + } + + [Fact] + public void Test_IsMatch_IgnoresVersionInGenericArguments() + { + Type live = typeof(List); + string simpleAssemblyName = live.Assembly.GetName().Name!; + + // Hand-craft a TypeName as if persisted under a different version of the generic + // argument assembly (Microsoft.Extensions.AI.Abstractions). + string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!; + string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]"; + + TypeId id = new(simpleAssemblyName, mutatedTypeName); + + id.IsMatch(live).Should().BeTrue("version differences inside generic argument names must not affect matching"); + } + + [Fact] + public void Test_Equals_IgnoresVersionInGenericArguments() + { + Type live = typeof(List); + TypeId fromLive = new(live); + + string simpleAssemblyName = live.Assembly.GetName().Name!; + string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!; + string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]"; + TypeId fromMutated = new(simpleAssemblyName, mutatedTypeName); + + fromLive.Equals(fromMutated).Should().BeTrue(); + fromLive.GetHashCode().Should().Be(fromMutated.GetHashCode()); + } + + [Fact] + public void Test_Dictionary_LookupAcrossGenericArgumentVersions() + { + Type live = typeof(List); + TypeId fromLive = new(live); + + string simpleAssemblyName = live.Assembly.GetName().Name!; + string innerArgSimpleName = typeof(ChatMessage).Assembly.GetName().Name!; + string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerArgSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]"; + TypeId fromMutated = new(simpleAssemblyName, mutatedTypeName); + + Dictionary map = new() { [fromLive] = "value" }; + map.TryGetValue(fromMutated, out string? value).Should().BeTrue(); + value.Should().Be("value"); + } +} diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowSessionResolveTypeLenientTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowSessionResolveTypeLenientTests.cs new file mode 100644 index 00000000000..23158d28dba --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowSessionResolveTypeLenientTests.cs @@ -0,0 +1,67 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Verifies that resolves a +/// to a loaded even when the stored assembly +/// name carries a different Version= than the loaded assembly. +/// +public class WorkflowSessionResolveTypeLenientTests +{ + [SuppressMessage("Performance", "CA1812", Justification = "Instantiated via Type.GetType in the production code path under test.")] + private sealed class TestEnvelope : IExternalRequestEnvelope + { + AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => null; + + object IExternalRequestEnvelope.CreateResponse(IList messages) => messages; + } + + [Fact] + public void Test_ResolveTypeLenient_ResolvesWhenAssemblyNameMatchesLoadedVersion() + { + Type live = typeof(TestEnvelope); + TypeId id = new(live); + + WorkflowSession.ResolveTypeLenient(id).Should().Be(live); + } + + [Fact] + public void Test_ResolveTypeLenient_ResolvesAcrossAssemblyVersionMutation() + { + Type live = typeof(TestEnvelope); + string simpleAssemblyName = live.Assembly.GetName().Name!; + string mutatedAssemblyName = $"{simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null"; + TypeId mutated = new(mutatedAssemblyName, live.FullName!); + + WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live); + } + + [Fact] + public void Test_ResolveTypeLenient_ReturnsNullForUnknownType() + { + TypeId id = new("Some.Unloaded.Assembly", "Some.Unknown.Type"); + + WorkflowSession.ResolveTypeLenient(id).Should().BeNull(); + } + + [Fact] + public void Test_ResolveTypeLenient_ResolvesAcrossGenericArgumentVersionMutation() + { + Type live = typeof(List); + string outerSimpleName = live.Assembly.GetName().Name!; + string innerSimpleName = typeof(ChatMessage).Assembly.GetName().Name!; + string mutatedTypeName = $"System.Collections.Generic.List`1[[Microsoft.Extensions.AI.ChatMessage, {innerSimpleName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null]]"; + + TypeId mutated = new(outerSimpleName, mutatedTypeName); + + WorkflowSession.ResolveTypeLenient(mutated).Should().Be(live); + } +}