From 3dd342336a99bf075891d9b5dd8180d67eb3b139 Mon Sep 17 00:00:00 2001 From: Sai Kothuri Date: Wed, 10 Jun 2026 21:03:15 -0700 Subject: [PATCH] Fix #6466: make workflow checkpoint TypeId version-insensitive TypeId identity was derived from Assembly.FullName, which embeds Version, Culture, and PublicKeyToken. Any SDK version bump invalidated previously persisted checkpoints, causing restore to throw InvalidDataException. Compare and hash TypeId using only the simple assembly name plus the type full name. The stored AssemblyName field, public property, and ToString output are unchanged, so the serialized checkpoint shape and public API are preserved and legacy checkpoints remain matchable. Also fixes a latent second version-sensitivity in WorkflowSession.TryGetRequestEnvelope, where Type.GetType used the version-qualified assembly name; it now uses the simple name for robust resolution after upgrades. --- .../Checkpointing/TypeId.cs | 33 +++++- .../WorkflowSession.cs | 2 +- .../TypeIdVersionMismatchTests.cs | 105 ++++++++++++++++++ 3 files changed, 136 insertions(+), 4 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/TypeIdVersionMismatchTests.cs 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(); + } +}