Compare commits

...

1 Commits

Author SHA1 Message Date
Peter Ibekwe d8d64ffe2b Fix workflow session bug 2026-07-09 16:05:36 -07:00
3 changed files with 149 additions and 101 deletions
@@ -1,7 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
@@ -248,7 +247,7 @@ internal sealed class WorkflowSession : AgentSession
hasMatchedResponseForStartExecutor |= string.Equals(responseExecutorId, this._workflow.StartExecutorId, StringComparison.Ordinal);
}
object normalizedResponseContent = NormalizeResponseContentForDelivery(content, pendingRequest);
object normalizedResponseContent = this.NormalizeResponseContentForDelivery(content, pendingRequest);
externalResponses.Add((pendingRequest.CreateResponse(normalizedResponseContent), pendingRequest.RequestId));
(matchedContentIds ??= new(StringComparer.Ordinal)).Add(contentId);
}
@@ -288,24 +287,19 @@ internal sealed class WorkflowSession : AgentSession
hasMatchedResponseForStartExecutor);
}
/// <summary>
/// Resolves the concrete request payload type from <see cref="RequestPortInfo.RequestType"/>
/// and returns it as an <see cref="IExternalRequestEnvelope"/> if the type implements that
/// abstraction. Resolving via the concrete <see cref="TypeId"/> (rather than asking the
/// PortableValue to deserialize directly to <see cref="IExternalRequestEnvelope"/>) is
/// required because checkpointed payloads round-trip as JSON which cannot be deserialized
/// to an interface; the concrete type populates the deserialization cache so subsequent
/// 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)
// Resolves the request payload as an envelope using the request types declared by the
// workflow's request ports.
private bool TryGetRequestEnvelope(ExternalRequest request, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
=> TryGetRequestEnvelope(request, this._workflow.Ports, out envelope);
// Returns true and the envelope when the request's port declares a type that implements the
// envelope contract and the payload deserializes to it; otherwise returns false so the
// request is delivered as ordinary content.
internal static bool TryGetRequestEnvelope(ExternalRequest request, IReadOnlyDictionary<string, RequestPort> ports, [NotNullWhen(true)] out IExternalRequestEnvelope? envelope)
{
envelope = null;
TypeId requestType = request.PortInfo.RequestType;
Type? concreteType = ResolveTypeLenient(requestType);
Type? concreteType = ResolveEnvelopeType(request.PortInfo, ports);
if (concreteType is null || !typeof(IExternalRequestEnvelope).IsAssignableFrom(concreteType))
{
return false;
@@ -320,28 +314,27 @@ 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();
// Returns the request type declared by the port that owns the request when it matches the
// type recorded on the request; otherwise returns null.
internal static Type? ResolveEnvelopeType(RequestPortInfo portInfo, IReadOnlyDictionary<string, RequestPort> ports)
{
if (ports.TryGetValue(portInfo.PortId, out RequestPort? port)
&& portInfo.RequestType.IsMatch(port.Request))
{
return port.Request;
}
/// <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));
return null;
}
/// <summary>
/// Creates the workflow-facing request content surfaced in response updates.
/// </summary>
private static AIContent CreateRequestContentForDelivery(ExternalRequest request)
private AIContent CreateRequestContentForDelivery(ExternalRequest request)
{
// If the request payload is a higher-layer envelope (e.g., a declarative
// ExternalInputRequest), surface its inner FCC/TARC to the host on the wire.
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
if (this.TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
{
AIContent? inner = envelope.GetInnerRequestContent();
if (inner is ToolApprovalRequestContent toolApprovalRequest)
@@ -368,12 +361,12 @@ internal sealed class WorkflowSession : AgentSession
/// <summary>
/// Rewrites workflow-facing response content back to the original agent-owned content ID.
/// </summary>
private static object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
private object NormalizeResponseContentForDelivery(AIContent content, ExternalRequest request)
{
// If the request payload is a higher-layer envelope, recover the original
// CallId/RequestId from the inner content and ask the envelope to wrap the
// response back into its paired response type for delivery to the request port.
if (TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
if (this.TryGetRequestEnvelope(request, out IExternalRequestEnvelope? envelope))
{
AIContent? inner = envelope.GetInnerRequestContent();
AIContent payload = (content, inner) switch
@@ -484,7 +477,7 @@ internal sealed class WorkflowSession : AgentSession
break;
case RequestInfoEvent requestInfo:
AIContent requestContent = CreateRequestContentForDelivery(requestInfo.Request);
AIContent requestContent = this.CreateRequestContentForDelivery(requestInfo.Request);
// Track the pending request so we can convert incoming responses back to ExternalResponse.
// External callers respond using the workflow-facing request ID, which is always RequestId.
@@ -1,67 +0,0 @@
// 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);
}
}
@@ -0,0 +1,122 @@
// Copyright (c) Microsoft. All rights reserved.
using System;
using System.Collections.Generic;
using FluentAssertions;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.UnitTests;
/// <summary>
/// Verifies that <see cref="WorkflowSession"/> recognizes an external request as an envelope
/// only when the request's port declares a type that matches the type recorded on the request,
/// resolving that type from the live workflow ports rather than the recorded assembly name.
/// </summary>
public class WorkflowSessionTests
{
private sealed class TestEnvelope : IExternalRequestEnvelope
{
AIContent? IExternalRequestEnvelope.GetInnerRequestContent() => null;
object IExternalRequestEnvelope.CreateResponse(IList<ChatMessage> messages) => messages;
}
[Fact]
public void ResolveEnvelopeType_ReturnsPortTypeWhenPortDeclaresMatchingType()
{
Type live = typeof(TestEnvelope);
RequestPort port = new("port-1", live, typeof(object));
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
RequestPortInfo portInfo = new(new TypeId(live), new TypeId(typeof(object)), port.Id);
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().Be(live);
}
[Fact]
public void ResolveEnvelopeType_ResolvesAcrossAssemblyVersionMutation()
{
Type live = typeof(TestEnvelope);
RequestPort port = new("port-1", live, typeof(object));
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
string simpleAssemblyName = live.Assembly.GetName().Name!;
string mutatedAssemblyName = $"{simpleAssemblyName}, Version=99.0.0.0, Culture=neutral, PublicKeyToken=null";
RequestPortInfo portInfo = new(new TypeId(mutatedAssemblyName, live.FullName!), new TypeId(typeof(object)), port.Id);
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().Be(live);
}
[Fact]
public void ResolveEnvelopeType_ResolvesAcrossGenericArgumentVersionMutation()
{
Type live = typeof(List<ChatMessage>);
RequestPort port = new("port-1", live, typeof(object));
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
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]]";
RequestPortInfo portInfo = new(new TypeId(outerSimpleName, mutatedTypeName), new TypeId(typeof(object)), port.Id);
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().Be(live);
}
[Fact]
public void ResolveEnvelopeType_ReturnsNullWhenPortIdIsUnknown()
{
Dictionary<string, RequestPort> ports = new()
{
["port-1"] = new RequestPort("port-1", typeof(TestEnvelope), typeof(object)),
};
RequestPortInfo portInfo = new(new TypeId(typeof(TestEnvelope)), new TypeId(typeof(object)), "missing-port");
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().BeNull();
}
[Fact]
public void ResolveEnvelopeType_ReturnsNullWhenRecordedTypeDoesNotMatchPortType()
{
Dictionary<string, RequestPort> ports = new()
{
["port-1"] = new RequestPort("port-1", typeof(TestEnvelope), typeof(object)),
};
RequestPortInfo portInfo = new(new TypeId("Some.Unloaded.Assembly", "Some.Unknown.Type"), new TypeId(typeof(object)), "port-1");
WorkflowSession.ResolveEnvelopeType(portInfo, ports).Should().BeNull();
}
[Fact]
public void TryGetRequestEnvelope_ReturnsEnvelopeWhenPortDeclaresEnvelopeType()
{
RequestPort port = new("port-1", typeof(TestEnvelope), typeof(object));
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
ExternalRequest request = ExternalRequest.Create(port, new TestEnvelope());
WorkflowSession.TryGetRequestEnvelope(request, ports, out IExternalRequestEnvelope? envelope).Should().BeTrue();
envelope.Should().BeOfType<TestEnvelope>();
}
[Fact]
public void TryGetRequestEnvelope_ReturnsFalseWhenPortTypeIsNotEnvelope()
{
RequestPort port = new("port-1", typeof(string), typeof(object));
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
ExternalRequest request = ExternalRequest.Create(port, "not-an-envelope");
WorkflowSession.TryGetRequestEnvelope(request, ports, out IExternalRequestEnvelope? envelope).Should().BeFalse();
envelope.Should().BeNull();
}
[Fact]
public void TryGetRequestEnvelope_ReturnsFalseWhenRecordedTypeDoesNotMatchPortType()
{
RequestPort port = new("port-1", typeof(TestEnvelope), typeof(object));
Dictionary<string, RequestPort> ports = new() { [port.Id] = port };
RequestPortInfo recordedPortInfo = new(new TypeId("Some.Unloaded.Assembly", "Some.Unknown.Type"), new TypeId(typeof(object)), port.Id);
ExternalRequest request = new(recordedPortInfo, "req-1", new PortableValue(new TestEnvelope()));
WorkflowSession.TryGetRequestEnvelope(request, ports, out IExternalRequestEnvelope? envelope).Should().BeFalse();
envelope.Should().BeNull();
}
}