diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/TypeId.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/TypeId.cs index 79436773d9d..fb6aca1f693 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/TypeId.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Checkpointing/TypeId.cs @@ -1,6 +1,7 @@ // Copyright (c) Microsoft. All rights reserved. using System; +using System.Reflection; using System.Text.Json.Serialization; using Microsoft.Shared.Diagnostics; @@ -11,12 +12,38 @@ namespace Microsoft.Agents.AI.Workflows.Checkpointing; /// public sealed class TypeId : IEquatable { + private string? _assemblySimpleName; + /// public string AssemblyName { get; } /// public string TypeName { get; } + /// + /// Gets the simple (version-, culture-, and public-key-token-independent) name of the assembly, + /// derived from . This is used for identity comparisons and type + /// resolution so that checkpoints remain compatible across assembly version changes. + /// + internal string AssemblySimpleName => this._assemblySimpleName ??= NormalizeAssemblyName(this.AssemblyName); + + /// + /// Extracts the simple assembly name from a (possibly fully-qualified) assembly name string, + /// discarding version, culture, and public key token. Falls back to the original value if it + /// cannot be parsed. + /// + private static string NormalizeAssemblyName(string assemblyName) + { + try + { + return new AssemblyName(assemblyName).Name ?? assemblyName; + } + catch (Exception) + { + return assemblyName; + } + } + /// /// Initializes a new instance of the class. /// @@ -58,11 +85,11 @@ public bool Equals(TypeId? other) return true; } - return this.AssemblyName == other.AssemblyName && this.TypeName == other.TypeName; + return this.AssemblySimpleName == other.AssemblySimpleName && this.TypeName == other.TypeName; } /// - public override int GetHashCode() => HashCode.Combine(this.AssemblyName, this.TypeName); + public override int GetHashCode() => HashCode.Combine(this.AssemblySimpleName, this.TypeName); /// public static bool operator ==(TypeId? left, TypeId? right) => left is null ? right is null : left.Equals(right); @@ -78,7 +105,7 @@ public bool Equals(TypeId? other) /// false. public bool IsMatch(Type type) { - return this.AssemblyName == type.Assembly.FullName + return this.AssemblySimpleName == type.Assembly.GetName().Name && this.TypeName == type.FullName; } diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs index 812bda11509..10e392ec29d 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs @@ -302,7 +302,7 @@ private static bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen( envelope = null; TypeId requestType = request.PortInfo.RequestType; - Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblyName}", throwOnError: false); + Type? concreteType = Type.GetType($"{requestType.TypeName}, {requestType.AssemblySimpleName}", throwOnError: false); if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType)) { return false; diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TypeIdVersionMismatchTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TypeIdVersionMismatchTests.cs new file mode 100644 index 00000000000..32a91c659d5 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TypeIdVersionMismatchTests.cs @@ -0,0 +1,105 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.Checkpointing; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +/// +/// Verifies that identity is independent of the assembly version, culture, +/// and public key token, so that checkpoints written by one SDK version can be restored by another. +/// See issue #6466. +/// +public class TypeIdVersionMismatchTests +{ + private static string FullNameWithVersion(Type type, string version) + => $"{type.Assembly.GetName().Name}, Version={version}, Culture=neutral, PublicKeyToken=null"; + + [Fact] + public void IsMatch_IgnoresAssemblyVersion() + { + Type type = typeof(TypeIdVersionMismatchTests); + + // Simulate a TypeId that was serialized by a different SDK version. + TypeId legacy = new(FullNameWithVersion(type, "1.8.0.0"), type.FullName!); + + legacy.IsMatch(type).Should().BeTrue(); + } + + [Fact] + public void Equals_IgnoresAssemblyVersion() + { + Type type = typeof(TypeIdVersionMismatchTests); + + TypeId legacy = new(FullNameWithVersion(type, "1.8.0.0"), type.FullName!); + TypeId current = new(type); + + legacy.Equals(current).Should().BeTrue(); + (legacy == current).Should().BeTrue(); + legacy.GetHashCode().Should().Be(current.GetHashCode()); + } + + [Fact] + public void Equals_DifferentVersions_AreEqual() + { + Type type = typeof(TypeIdVersionMismatchTests); + + TypeId a = new(FullNameWithVersion(type, "1.3.0.0"), type.FullName!); + TypeId b = new(FullNameWithVersion(type, "1.10.0.0"), type.FullName!); + + a.Equals(b).Should().BeTrue(); + a.GetHashCode().Should().Be(b.GetHashCode()); + } + + [Fact] + public void DictionaryLookup_SucceedsAcrossVersionForms() + { + Type type = typeof(TypeIdVersionMismatchTests); + + // Map keyed by the current (simple-name) TypeId. + Dictionary map = new() + { + [new TypeId(type)] = "value", + }; + + // Lookup using a legacy fully-qualified TypeId must still resolve. + TypeId legacy = new(FullNameWithVersion(type, "1.8.0.0"), type.FullName!); + + map.TryGetValue(legacy, out string? value).Should().BeTrue(); + value.Should().Be("value"); + } + + [Fact] + public void Equals_DifferentTypeName_NotEqual() + { + Type type = typeof(TypeIdVersionMismatchTests); + + TypeId a = new(type); + TypeId b = new(FullNameWithVersion(type, "1.8.0.0"), typeof(TypeId).FullName!); + + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void Equals_DifferentSimpleAssemblyName_NotEqual() + { + Type type = typeof(TypeIdVersionMismatchTests); + + TypeId a = new(type); + TypeId b = new("Some.Other.Assembly, Version=1.8.0.0, Culture=neutral, PublicKeyToken=null", type.FullName!); + + a.Equals(b).Should().BeFalse(); + } + + [Fact] + public void MalformedAssemblyName_FallsBackToRawValue() + { + // An unparseable assembly name should not throw; it should compare on the raw value. + TypeId a = new("not a valid, assembly name", "Some.Type"); + TypeId b = new("not a valid, assembly name", "Some.Type"); + + a.Equals(b).Should().BeTrue(); + } +}