Fix issue with resuming checkpoint after package version upgrade (#6636)

This commit is contained in:
Peter Ibekwe
2026-06-22 08:18:22 -07:00
committed by GitHub
parent a7381d8bef
commit 2f5a76ab1d
5 changed files with 555 additions and 8 deletions
@@ -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;
/// </summary>
public sealed class TypeId : IEquatable<TypeId>
{
/// <inheritdoc cref="System.Reflection.Assembly.FullName"/>
/// <inheritdoc cref="Assembly.FullName"/>
public string AssemblyName { get; }
/// <inheritdoc cref="Type.FullName"/>
@@ -46,6 +49,11 @@ public sealed class TypeId : IEquatable<TypeId>
=> this.Equals(obj as TypeId);
/// <inheritdoc />
/// <remarks>
/// Compares the type full name and the simple assembly name. Version, culture, and public key
/// token are ignored both in <see cref="AssemblyName"/> and in any assembly-qualified generic
/// arguments embedded in <see cref="TypeName"/>.
/// </remarks>
public bool Equals(TypeId? other)
{
if (other is null)
@@ -58,11 +66,27 @@ public sealed class TypeId : IEquatable<TypeId>
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);
}
/// <inheritdoc />
public override int GetHashCode() => HashCode.Combine(this.AssemblyName, this.TypeName);
/// <remarks>Hashes the normalized type name and the simple assembly name.</remarks>
public override int GetHashCode()
=> HashCode.Combine(this.SimpleAssemblyName, this.NormalizedTypeName);
/// <inheritdoc />
public static bool operator ==(TypeId? left, TypeId? right) => left is null ? right is null : left.Equals(right);
@@ -73,13 +97,27 @@ public sealed class TypeId : IEquatable<TypeId>
/// <summary>
/// Determines whether the specified type matches both the assembly name and type name represented by this instance.
/// </summary>
/// <remarks>
/// Compares the type full name and the simple assembly name. Version, culture, and public key
/// token are ignored both in <see cref="AssemblyName"/> and in any assembly-qualified generic
/// arguments embedded in <see cref="TypeName"/>.
/// </remarks>
/// <param name="type">The type to compare against the stored assembly and type names. Cannot be null.</param>
/// <returns>true if the specified type's assembly and type names are equal to those stored in this instance; otherwise,
/// false.</returns>
/// <returns>true if the specified type's assembly simple name and normalized type full name are equal to those stored
/// in this instance; otherwise, false.</returns>
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);
}
/// <summary>
@@ -113,4 +151,64 @@ public sealed class TypeId : IEquatable<TypeId>
/// <inheritdoc/>
public override string ToString() => $"{this.TypeName}, {this.AssemblyName}";
/// <summary>
/// The simple assembly name parsed from <see cref="AssemblyName"/>, lazily computed and cached.
/// </summary>
internal string? SimpleAssemblyName
=> field ??= GetSimpleAssemblyName(this.AssemblyName);
/// <summary>
/// The type full name with embedded assembly-qualified generic arguments stripped of
/// version, culture, and public key token. Lazily computed and cached.
/// </summary>
internal string NormalizedTypeName
=> field ??= NormalizeTypeName(this.TypeName);
private static readonly Regex s_assemblyQualifierPattern = new(
@", Version=[^,\]]+, Culture=[^,\]]+, PublicKeyToken=[^,\]]+",
RegexOptions.Compiled | RegexOptions.CultureInvariant);
/// <summary>
/// Removes <c>, Version=...</c>, <c>, Culture=...</c>, and <c>, PublicKeyToken=...</c> triplets
/// from <paramref name="typeName"/>. Returns the input unchanged when no triplet is present.
/// </summary>
internal static string NormalizeTypeName(string typeName)
{
if (typeName.IndexOf("Version=", StringComparison.Ordinal) < 0)
{
return typeName;
}
return s_assemblyQualifierPattern.Replace(typeName, string.Empty);
}
/// <summary>
/// Returns the simple assembly name parsed from an <see cref="Assembly.FullName"/>-style string,
/// or <see langword="null"/> when both parsing and the substring fallback fail.
/// </summary>
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;
}
}
@@ -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 @@ internal sealed class WorkflowSession : AgentSession
/// interface assignment succeeds.
/// </summary>
[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 @@ internal sealed class WorkflowSession : AgentSession
return true;
}
/// <summary>Caches <see cref="ResolveTypeLenient"/> results keyed by <see cref="TypeId"/>.</summary>
private static readonly ConcurrentDictionary<TypeId, Type?> s_envelopeTypeCache = new();
/// <summary>
/// Resolves a <see cref="TypeId"/> to a loaded <see cref="Type"/> using partial-name binding,
/// which matches any loaded assembly with the same simple name regardless of version. Results
/// are cached.
/// </summary>
[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));
/// <summary>
/// Creates the workflow-facing request content surfaced in response updates.
/// </summary>
@@ -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;
/// <summary>
/// Verifies that a checkpoint serialized through <see cref="JsonCheckpointStore"/> can be restored
/// after every <c>Version=X.Y.Z.W</c> substring in the persisted JSON is rewritten to a different value.
/// </summary>
public class CheckpointVersionToleranceTests
{
private sealed class EchoExecutor() : Executor("Echo")
{
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder)
=> protocolBuilder.ConfigureRoutes(routeBuilder =>
routeBuilder.AddHandler<string>((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<string, string> requestPort = RequestPort.Create<string, string>("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<Task> 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();
}
/// <summary>
/// JSON checkpoint store that rewrites every <c>Version=N.N.N.N</c> token in the persisted
/// payload at retrieval time.
/// </summary>
private sealed class VersionMutatingJsonStore : JsonCheckpointStore
{
private static readonly Regex s_versionPattern = new(@"Version=\d+\.\d+\.\d+\.\d+", RegexOptions.Compiled);
private readonly Dictionary<string, Dictionary<string, JsonElement>> _store = [];
public string ReplacementVersion { get; init; } = "99.0.0.0";
public bool MutationApplied { get; private set; }
public override ValueTask<CheckpointInfo> CreateCheckpointAsync(string sessionId, JsonElement value, CheckpointInfo? parent = null)
{
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore))
{
sessionStore = this._store[sessionId] = [];
}
CheckpointInfo info = new(sessionId);
sessionStore[info.CheckpointId] = value.Clone();
return new ValueTask<CheckpointInfo>(info);
}
public override ValueTask<JsonElement> RetrieveCheckpointAsync(string sessionId, CheckpointInfo key)
{
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? 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<JsonElement>(doc.RootElement.Clone());
}
public override ValueTask<IEnumerable<CheckpointInfo>> RetrieveIndexAsync(string sessionId, CheckpointInfo? withParent = null)
{
if (!this._store.TryGetValue(sessionId, out Dictionary<string, JsonElement>? sessionStore))
{
return new ValueTask<IEnumerable<CheckpointInfo>>(Array.Empty<CheckpointInfo>());
}
IEnumerable<CheckpointInfo> infos = sessionStore.Keys.Select(id => new CheckpointInfo(sessionId, id));
return new ValueTask<IEnumerable<CheckpointInfo>>(infos);
}
}
}
@@ -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;
/// <summary>
/// Verifies that <see cref="TypeId.IsMatch(Type)"/> and <see cref="TypeId.Equals(object?)"/>
/// 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.
/// </summary>
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<Probe>().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<TypeId, string> map = new() { [live] = "value" };
map.TryGetValue(mutated, out string? value).Should().BeTrue();
value.Should().Be("value");
HashSet<TypeId> 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<ChatMessage>);
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<ChatMessage>);
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<ChatMessage>);
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<TypeId, string> map = new() { [fromLive] = "value" };
map.TryGetValue(fromMutated, out string? value).Should().BeTrue();
value.Should().Be("value");
}
}
@@ -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;
/// <summary>
/// Verifies that <see cref="WorkflowSession.ResolveTypeLenient(TypeId)"/> resolves a
/// <see cref="TypeId"/> to a loaded <see cref="Type"/> even when the stored assembly
/// name carries a different <c>Version=</c> than the loaded assembly.
/// </summary>
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<ChatMessage> 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<ChatMessage>);
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);
}
}