Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion dotnet/src/Microsoft.Agents.AI.DurableTask/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Release History
# Release History

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

looks like encoding changed for this file?


## [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))

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -151,8 +151,13 @@ internal static Type ResolveInputType(string? inputTypeName, ISet<Type> 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);

Expand All @@ -161,8 +166,6 @@ internal static Type ResolveInputType(string? inputTypeName, ISet<Type> 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))
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Resolves persisted assembly-qualified type-name strings to a loaded <see cref="Type"/>,
/// tolerating differences in assembly version, culture, and public key token between the
/// persisted name and the currently loaded assemblies. Results are cached.
/// </summary>
Comment thread
peibekwe marked this conversation as resolved.
internal static class DurableTaskTypeResolver
{
private static readonly ConcurrentDictionary<string, Type?> s_cache = new();

/// <summary>
/// Resolves <paramref name="typeName"/> using a qualified <see cref="Type.GetType(string, bool)"/>
/// lookup, then a partial-name fallback that strips embedded version, culture, and public key
/// token qualifiers.
/// </summary>
[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);
});
Comment thread
peibekwe marked this conversation as resolved.
}
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies that <see cref="DurableActivityExecutor.ResolveInputType(string?, ISet{Type})"/>
/// 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.
/// </summary>
public sealed class DurableActivityExecutorResolveInputTypeTests
{
[Fact]
public void ResolveInputType_NullInput_ReturnsFirstSupportedType()
{
Type result = DurableActivityExecutor.ResolveInputType(null, new HashSet<Type> { typeof(int) });

Assert.Equal(typeof(int), result);
}
Comment thread
peibekwe marked this conversation as resolved.

[Fact]
public void ResolveInputType_EmptyInputAndNoSupportedTypes_FallsBackToString()
{
Type result = DurableActivityExecutor.ResolveInputType(string.Empty, new HashSet<Type>());

Assert.Equal(typeof(string), result);
}

[Fact]
public void ResolveInputType_LoadedAssemblyQualifiedName_ReturnsSupportedType()
{
Type supported = typeof(ChatMessage);
ISet<Type> supportedTypes = new HashSet<Type> { 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<Type> supportedTypes = new HashSet<Type> { supported };

Type result = DurableActivityExecutor.ResolveInputType(mutated, supportedTypes);

Assert.Same(supported, result);
}

[Fact]
public void ResolveInputType_MutatedGenericArgumentVersion_ReturnsSupportedType()
{
Type supported = typeof(List<ChatMessage>);
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<Type> supportedTypes = new HashSet<Type> { supported };

Type result = DurableActivityExecutor.ResolveInputType(mutated, supportedTypes);

Assert.Same(supported, result);
}

[Fact]
public void ResolveInputType_ShortNameMatch_ReturnsSupportedType()
{
Type supported = typeof(ChatMessage);
ISet<Type> supportedTypes = new HashSet<Type> { supported };

Type result = DurableActivityExecutor.ResolveInputType(supported.Name, supportedTypes);

Assert.Same(supported, result);
}

[Fact]
public void ResolveInputType_FullNameMatch_ReturnsSupportedType()
{
Type supported = typeof(ChatMessage);
ISet<Type> supportedTypes = new HashSet<Type> { supported };

Type result = DurableActivityExecutor.ResolveInputType(supported.FullName, supportedTypes);

Assert.Same(supported, result);
}

[Fact]
public void ResolveInputType_StringFallback_ReturnsFirstSupportedTypeWhenStringUnsupported()
{
ISet<Type> supportedTypes = new HashSet<Type> { typeof(int) };

Type result = DurableActivityExecutor.ResolveInputType(typeof(string).AssemblyQualifiedName, supportedTypes);

Assert.Equal(typeof(int), result);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<DurableTaskClient> mockClient = new("test");
mockClient.Setup(c => c.GetInstanceAsync(InstanceId, true, It.IsAny<CancellationToken>()))
.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<WorkflowEvent> events = [];
await foreach (WorkflowEvent evt in run.WatchStreamAsync())
{
events.Add(evt);
}

// Assert
Assert.Equal(2, events.Count);
DurableHaltRequestedEvent haltResult = Assert.IsType<DurableHaltRequestedEvent>(events[0]);
Assert.Equal("exec-1", haltResult.ExecutorId);
DurableWorkflowCompletedEvent completedResult = Assert.IsType<DurableWorkflowCompletedEvent>(events[1]);
Assert.Equal("final", completedResult.Result);
}

[Fact]
public async Task WatchStreamAsync_IncrementalEvents_YieldsOnlyNewEventsPerPollAsync()
{
Expand Down
Original file line number Diff line number Diff line change
@@ -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;

/// <summary>
/// Verifies that <see cref="DurableTaskTypeResolver.Resolve(string)"/> resolves persisted
/// assembly-qualified type-name strings to a loaded <see cref="Type"/> across assembly
/// version, culture, and public key token mutations.
/// </summary>
public sealed class DurableTaskTypeResolverTests
{
[Fact]
public void Resolve_LoadedAssemblyQualifiedName_ReturnsLiveType()
{
Type live = typeof(List<ChatMessage>);
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<ChatMessage>);
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);
}
}
Loading