From 95b66fab2e8fc70cd3e2fb72b78a50ff4f95c764 Mon Sep 17 00:00:00 2001 From: atty57 <99388680+atty57@users.noreply.github.com> Date: Wed, 2 Sep 2026 15:08:51 -0400 Subject: [PATCH] .NET: Surface workflow exceptions instead of a JSON serialization error When a workflow used as an agent fails, the streamed update carries a WorkflowErrorEvent whose Data is the original Exception. The generic workflow-event branch in AgentResponseUpdateExtensions serialized that Data directly, and System.Text.Json rejects Exception.TargetSite (a MethodBase). The resulting NotSupportedException replaced the real failure with "Serialization and deserialization of 'System.Reflection.MethodBase' instances is not supported. Path: $.TargetSite." under the execution_error code. Serialize the exception message instead, mirroring how ExecutorFailedEvent is already handled. The stack trace is deliberately not included. --- .../AgentResponseUpdateExtensions.cs | 9 ++- .../WorkflowErrorEventStreamingTests.cs | 73 +++++++++++++++++++ 2 files changed, 81 insertions(+), 1 deletion(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/WorkflowErrorEventStreamingTests.cs diff --git a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseUpdateExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseUpdateExtensions.cs index 7930cba8380..f6c0cb1ac19 100644 --- a/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseUpdateExtensions.cs +++ b/dotnet/src/Microsoft.Agents.AI.Hosting.OpenAI/Responses/AgentResponseUpdateExtensions.cs @@ -305,7 +305,14 @@ private static StreamingWorkflowEventComplete CreateWorkflowEventResponse(Workfl if (JsonSerializer.IsReflectionEnabledByDefault) { JsonElement? dataElement = null; - if (workflowEvent.Data is not null) + if (workflowEvent.Data is Exception exception) + { + // Exceptions cannot go through System.Text.Json: Exception.TargetSite is a MethodBase, + // which throws NotSupportedException and would surface as a serialization error instead + // of the actual failure. Report the message only; the stack trace is not for clients. + dataElement = JsonSerializer.SerializeToElement(exception.Message, OpenAIHostingJsonContext.Default.String); + } + else if (workflowEvent.Data is not null) { dataElement = JsonSerializer.SerializeToElement(workflowEvent.Data, OpenAIHostingJsonUtilities.DefaultOptions.GetTypeInfo(typeof(object))); } diff --git a/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/WorkflowErrorEventStreamingTests.cs b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/WorkflowErrorEventStreamingTests.cs new file mode 100644 index 00000000000..6ac22ecf3a8 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Hosting.OpenAI.UnitTests/WorkflowErrorEventStreamingTests.cs @@ -0,0 +1,73 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text.Json; +using System.Threading.Tasks; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses; +using Microsoft.Agents.AI.Hosting.OpenAI.Responses.Models; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Microsoft.Agents.AI.Hosting.OpenAI.UnitTests; + +/// +/// Regression tests for a failing workflow used as an agent: the emitted update carries a +/// as its raw representation, whose data is the original +/// . Streaming that update must not try to JSON-serialize the exception, +/// which fails on Exception.TargetSite and hides the actual workflow failure. +/// +public sealed class WorkflowErrorEventStreamingTests +{ + [Fact] + public async Task WorkflowErrorEvent_IsStreamedWithoutSerializationFailureAsync() + { + // Arrange: the update a failing workflow-as-agent produces. + const string ErrorMessage = "Executor 'AggregatorExecutor' cannot send messages of type 'RawAggregate'."; + var update = new AgentResponseUpdate(ChatRole.Assistant, [new ErrorContent(ErrorMessage)]) + { + RawRepresentation = new WorkflowErrorEvent(Thrown(ErrorMessage)) + }; + + var request = new CreateResponse { Input = "Hello", Stream = true }; + var context = new AgentInvocationContext(new IdGenerator("resp_1", "conv_1")); + + // Act + List events = []; + await foreach (var evt in ToAsyncEnumerableAsync(update).ToStreamingResponseAsync(request, context)) + { + events.Add(evt); + } + + // Assert: the workflow event is streamed and carries the real failure message. + var workflowEvent = Assert.Single(events.OfType()); + Assert.NotNull(workflowEvent.Data); + JsonElement data = workflowEvent.Data.Value; + Assert.Equal(nameof(WorkflowErrorEvent), data.GetProperty("event_type").GetString()); + Assert.Equal(ErrorMessage, data.GetProperty("data").GetString()); + } + + /// Returns an actually-thrown exception, so TargetSite is populated. + private static InvalidOperationException Thrown(string message) + { + try + { + throw new InvalidOperationException(message); + } + catch (InvalidOperationException ex) + { + return ex; + } + } + + private static async IAsyncEnumerable ToAsyncEnumerableAsync(params AgentResponseUpdate[] updates) + { + foreach (var update in updates) + { + yield return update; + } + + await Task.CompletedTask; + } +}